[Firestore+React] 玩前端不用自己架後端 Server,交給 Firestore 吧

無痛生Server,輕鬆玩前端

很多人在開發前端專案的時候,可能會有需要使用資料庫的問題,但不論有沒有後端開發的背景,對於新手或專職前端的工程師想要練習前端開發或者製作小型的 Side Project,寫後端程式以及架伺服器去連接資料庫都是耗時又費力的,那有沒有一種工具能夠讓前端工程師省掉寫後端程式架伺服器的苦工呢?

當然有!有請本文主角 — Firestore

Firestore 是什麼?

Firestore 是 Google Firebase 底下的雲端“非關聯式”(NoSQL)資料庫服務,是一種“無伺服器架構”,這並非代表著沒有伺服器,而是由第三方雲端廠商負責後端與伺服器基礎結構的維護,對於前端工程師來說是一大福音。

想了解更多關聯式與非關聯式資料庫請看這邊: RDBMS vs. NOSQL | 關聯式資料庫 vs. 非關聯式資料庫

接著進入正題,本文要實作的是在 React 應用程式中使用對 Firestore 資料庫進行基本 CRUD 的功能,一般來說前端是不應該直接對資料庫進行操作的,必須要透過送請求到後端程式來執行,但有了 Firestore 所提供的方法,便可以在前端呼叫方法來送請求對資料庫內的資料進行 CRUD。

在一切開始之前,必須先建立一個 React 應用程式,也就是要用來連接 Firebase 的應用程式

1
npx create-react-app APP_NAME

創建完成之後,在使用 npm 自動化工具安裝 Firebase

1
npm install firebase

接著在開始寫程式之前,別忘了先建立好 Firebase 的專案,會需要一個 Google 帳號,登入之後就大膽地按下“Get Started”,並建立專案,可能會需要等上一段時間。

Firebase 首頁

等到專案建立完成之後呢,就會到你的 Console 畫面,接著點選網頁的 icon 來取得可以連結你應用程式與 Firebase 的配置檔案(或者從左側選單齒輪內的 “專案設定” 中的 “一般設定” 下面來找)

稍後會使用 npm 來安裝 Firebase ,所以在這裡選擇 “使用 npm”,

Firebase 配置

然後就會拿到配置檔,包含了 API KEY,要注意的是唯一能夠安全的保護你的 API KEY 的方法就是把它放在你的後端程式(伺服器端)裡,所以如果要把程式放到 Github 上或部署到網路上,都要特別注意處理 API KEY,不可以放在前端程式碼(Client 端)中,不然可能誰都可以使用你的 API KEY 來壞壞了。

下一步在 React App 中創建一個 JS 檔案,取一個方便辨識的名稱(eg. firebase-config.js),用來放上面的配置檔,也就是 React App 與 Firebase 的連接配置,然後除了配置檔之外,也要導入 “getFirestore”,用來連接稍後會建立的 Firestore Database

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { initializeApp } from "firebase/app";
// 導入 getFirestore 連結 db
import { getFirestore } from "@firebase/firestore";

const firebaseConfig = {
  apiKey: "XXXXXXXXXXXXXXXXXXX",
  authDomain: "OOOOOOOOOOOOOOOOOOOOO",
  databaseURL: "WWWWWWWWWWWWWWWWWWWWWWW",
  projectId: "DDDDDDDDDDDDDDDDDDDD",
  storageBucket: "ZZZZZZZZZZZZZZZZZZZZZZZ",
  messagingSenderId: "0000000000000",
  appId: ":) welcome",
  measurementId: "QQQQQQQQQQ",
};

// 使用 firebase 的 initialzeApp 並傳入配置檔
const app = initializeApp(firebaseConfig);

// 傳入剛剛初始化的 app 並且 export db,稍後在外面就可以使用 db 來連接
export const db = getFirestore(app);

這樣就完成了 React App 與 Firebase 專案的連接,不過還需要用到一個資料庫,也就是等等要使用 React App 來進行 CRUD 的資料庫,回到剛剛 Firebase 的控制台,從左側選單中選擇 “Firestore Database”,點選建立資料庫,直接以正式版啟動,啟動之前要先設定資料庫的儲存位置

Firebase Dashboard

基於安全理由,資料庫預設是不能夠進行讀寫的,得到 “規則” 頁面中把“allow read, write: if false;” 改為 “true” ,並且發布

完成讀寫設置之後,到 “資料” 頁面,按 “新增集合” ,NoSQL 內的 Collection (集合)就像是 RDBMS 裡的 Table (資料表),輸入好欄位,使用自動產生的 ID,完成後按儲存

資料庫中就會有剛剛新增的資料

Firestore Database

這次的資料為 “聯絡人資訊(contacts)”,會有 “姓名(name)” 以及 “電話(phone)” 欄位,而在資料下方會有編輯(update/ edit)用的輸入框,如下圖所示


READ

下一個步驟就要開始從 React App 中使用 firebase/firestore 內的 “getDocs” 方法,來向資料庫讀取資料,getDocs 為一個 “讀取全部資料(Read)” 的方法,像是 SQL 的 Select all,並且我設計了一個 Child Component ,將會等 App.js 內定義的方法從資料庫端取得 response 之後,使用 React 的 useState Hook 的方法把 response 設定 State 的值,再把這個值當成 props 傳到 Child Component 進行渲染 (render)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { useState, useEffect } from "react";
// 引入剛剛配置檔內所導出的 db 資料庫
import { db } from "./firebase-config";
// 從 firestore 引入方法
import { collection, getDocs } from "firebase/firestore";

// 導入建立好的 Child Component (請參考下一段程式碼)
import ContactCard from "./ContactCard";

const App = () => {
  // 使用 React useState Hook 並且設定預設值為空陣列,用來裝等等回傳的資料
  const [contacts, setContacts] = useState([]);
  // 指定所使用的集合為"contacts",並放入剛剛導入的資料庫 db 做為參數
  const contactsCollectionRef = collection(db, "contacts");

  // 使用 useEffect Hook 來確保每次頁面載入時就會發出請求
  useEffect(() => {
    // async await 方法來向外部發出 API 請求
    const getAllContacts = async () => {
      // getDocs 方法放入剛剛建立好的集合參考,並 await response
      const responseData = await getDocs(contactsCollectionRef);
      // 可以印出來看看資料長怎樣,養成看log好習慣
      console.log(responseData);

      // 接著使用 useState 的 set 方法把回傳的資料處理並設定為 state
      setContacts(
        responseData.docs.map((contact) => ({
          ...contact.data(),
          id: contact.id, // 要記得拿 id
        }))
      );
    };

    // 直接執行剛剛發出請求的方法
    getAllContacts();
  }, []);

  return (
    <div>
      // 使用 map 方法把 state 內的陣列值一一傳給 child component
      {contacts.map((contact) => {
        return (
          <ContactCard
            contactName={contact.name}
            phone={contact.phone}
            id={contact.id}
            key={contact.id}
          />
        );
      })}
    </div>
  );
};

export default App;

以下為 Child Component — ContactCard.js 的程式碼供參考,實際上練習也可以直接在 App.js 內實作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import React from "react";
import styles from "./ContactCard.module.css";

const ContactCard = ({ contactName, phone, id }) => {
  return (
    <div className={styles.container}>
      <div className={styles.contactCard}>
        <h3>Name: {contactName}</h3>
        <p>Phone: {phone}</p>
        <input type="text" placeholder="New Name" />
        <input type="text" placeholder="New Phone" />
        <div className={styles.btnContaier}>
          <button>EDIT</button>
          <button>Delete</button>
        </div>
      </div>
    </div>
  );
};

export default ContactCard;

另外也有 .CSS 可以參考,或者 完整的程式碼 (沒有囉唆注釋)

完成到這邊如果執行程式的話應該就可以看到跟上一張圖一樣的狀態,已經成功從 Firestore 資料庫內撈到資料,並且呈現在前端中


CREATE

既然可以取全部的資料了,接下來就來寫新增資料的功能吧!

接下來會需要新增兩個 input ,且要對表單進行處理,使用 useState Hook 讓 input 受到 React 的控管

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{......}

const App = () => {
  // 新增兩個 state 用來控管 input 的值
  const [name, setName] = useState("");
  const [phone, setPhone] = useState("");

{......}

  return (
    <div>
      // 請原諒我懶惰寫 inlineCSS,好寶寶除非萬不得已不要寫這種難維護的東東ㄛ
      <div
        style={{ display: "flex", justifyContent: "center", marginTop: "1rem" }}
      >
        // input 的 value 即為 state 的值
        // 並且只要 input 欄位內的值改變(onChange)
        // 就把改變的值使用 set 方法設定 state
        // 如此一來 input 就受到 React 控管啦
        <input
          type="text"
          placeholder="name..."
          value={name}
          onChange={(e) => {
            setName(e.target.value);
          }}
        />
        <input
          type="text"
          placeholder="phone..."
          value={phone}
          onChange={(e) => {
            setPhone(e.target.value);
          }}
        />
        <button>Add Contact</button>
      </div>
        {......}
    </div>
  );
};

export default App;

完成表單之後,就寫準備向 Firestore 發出請求的方法,要使用 firebase/firestore 的 “addDoc” 方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{......}
// 多導入一個 addDoc 方法
import { collection, getDocs, addDoc } from "firebase/firestore";

{......}

const App = () => {
  const [name, setName] = useState("");
  const [phone, setPhone] = useState("");

{......}

  // addDoc 內放入集合參考,並且放入 state (ES6 key/value 同名直接寫單個就好)
  const addContact = async () => {
    await addDoc(contactsCollectionRef, { name, phone });
  };

 {......}

  return (
    <div>
      <div
        style={......}
      >
        <input
          {......}
        />
        <input
          {......}
        />
        // button 的 onClick 調用 addContact 方法
        <button onClick={addContact}>Add Contact</button>
      </div>
      {contacts.map((contact) => {
        return (
            {......}
          );
      })}
    </div>
  );
};

export default App;

完成之後只要在表單內輸入資料且按下 Add Contact 按鈕,重新整理後就可以在頁面上還有 Firebase 中看到所新增的資料了


UPDATE

如果可以新增資料,那修改資料當然是不會少啦~不過我自己是覺得 Update 方法是基本 CRUD 內最麻煩的,得先從資料庫內撈出所要更新的資料,然後把新的資料塞進去取代或覆寫舊的資料,不過因為在 Parent Component 已經有把 ID 保存並且傳給 Child Component ,所以等等只要直接把 ID 交給 Firestore 的方法創建“參考”,就可以對資料進行更新了。

我的作法是在 App.js 定義好 “updateContact” 方法,並且把它當作 props 傳給 Child Component 作為一個回調函數(Callback function),Child Component 會把參數(id, newName, newPhone)交給這個 Callback function 執行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{......}
// 導入 setDoc 與 doc
import { (省略) , setDoc, doc } from "firebase/firestore";

{......}

const App = () => {
{......}

  // 建立 update 方法,會需要傳入三個參數,為文件ID,以及新的 name, phone
  const updateContact = async (contactId, newName, newPhone) => {
    // 使用 doc 建立文件參考,放入 db,集合名稱,以及文件ID
    const contactDocRef = doc(db, "contacts", contactId);
    // 創建一個物件用來更新值(放入新的值)
    const newContact = { name: newName, phone: newPhone };
    // 使用 setDoc 方法,放入參考及新的物件,
    // merge 設定為 true 可以避免整個文件被覆寫
    await setDoc(contactDocRef, newContact, { merge: true });
  };

{......}


  return (
    <div>
      {......}

      {contacts.map((contact) => {
        return (
          <ContactCard
            contactName={contact.name}
            phone={contact.phone}
            contactId={contact.id}
            key={contact.id}
            // 把建立好的 update function 傳給 child component
            updateContact={updateContact}
          />
        );
      })}
    </div>
  );
};

export default App;

到了 Child Component 要記得接收剛剛傳下來的 Callback Function,而一樣在這裡也要讓表單受 React 控制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{......}

// 記得接收來自 Parent component 的 callback function
const ContactCard = ({ (省略) , updateContact }) => {

  // 定義 useState Hook 用來控制表單
  const [newName, setNewName] = useState("");
  const [newPhone, setNewPhone] = useState("");

  return (
    <div className={styles.container}>
        {......}
         // input 一樣要納入 React 的控制版圖
        <input
          type="text"
          placeholder="New Name"
          value={newName}
          onChange={(e) => {
            setNewName(e.target.value);
          }}
        />
        <input
          type="text"
          placeholder="New Phone"
          value={newPhone}
          onChange={(e) => {
            setNewPhone(e.target.value);
          }}
        />
        <div className={styles.btnContaier}>
          <button
            // 在這裡調用傳過來的 callback function 並把參數給它
            onClick={() => {
              updateContact(contactId, newName, newPhone);
            }}
          >
            EDIT
          </button>
          <button>Delete</button>
        </div>
      </div>
    </div>
  );
};

export default ContactCard;

這麼一來一但輸入完成之後按下 EDIT,重新整理頁面就會看到新的資料呈現在前端畫面,Firebase 也會同步更新,Update 的功能也就完成啦~


DELETE

喘口氣,來到最後的刪除功能了,這個方法相對前面的方法來的容易許多,只需要帶入 ID ,就可以直接把資料從資料庫中刪除

這裡一樣先在 App.js 定義好 “deleteContact” 方法,並且當作 Callback Function 傳給 Chind Component

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{......}

// 記得引入 deleteDoc 方法
import { (省略) , deleteDoc } from "firebase/firestore";

{......}

const App = () => {

{......}

  // 定義刪除的方法,帶入 ID 做為參數
  const deleteContact = async (contactId) => {
    // 創建文件參考
    const contactDocRef = doc(db, "contacts", contactId);
    // 刪除該筆資料
    await deleteDoc(contactDocRef);
  };

{......}

  return (
    <div>
      {......}

      {contacts.map((contact) => {
        return (
          <ContactCard

            {......}

            // 一樣要記得傳給 child component
            deleteContact={deleteContact}
          />
        );
      })}
    </div>
  );
};

export default App;

最後只要在 Child Component 接收 Callback Function 並且傳入參數就大功告成啦!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{......}

// 接收來自 Parent component 的 callback function
const ContactCard = ({ (省略) , deleteContact }) => {

    {......}

  return (
    <div className={styles.container}>

        {......}

         // 調用 callback function 並給它 id 做為參數
          <button
            onClick={() => {
              deleteContact(contactId);
            }}
          >
            Delete
          </button>
        {......}
    </div>
  );
};

export default ContactCard;

完成!

以上就是在 React 應用程式中使用 Firebase 資料庫基本 CRUD 功能的方法,不得不說對於前端開發真的是非常方便的一個工具,但實際上送出 API 請求還得要做 “處理回應” 與 “異常處理” ,比如說使用 try 與 catch 來送 API,而且如果是良好的使用者體驗更不可能收到回應要自己重新整理頁面才會渲染新的資料,所以真正在做專案的時候還是要想一下該如何處理資料,並給使用者最良好的體驗

謝謝你跟著我到這裡,如果不嫌棄的話可以訂閱我,未來也會盡力產出,大家可以多多交流指教,一起建立更豐富的社群 :D

[Github] 完整程式碼


Reference

Firestore Document

Firebase Cloud Firestore 基礎入門

CRUD Tutorial Using React + Firebase | Firebase 9 and Firestore Tutorial

選擇 — 關聯式與非關聯式 (SQL vs. NoSQL )

使用 Hugo 建立
主題 StackJimmy 設計