Firebase

Firebase

2021/11/02 (更新內容)
2021/11/09 (更新內容)
2021/12/28 (更新內容)
2022/01/07 (新增內容)
2023/03/12 (更新內容)

簡介

Firebase是個雲端服務,現在已經被google收購。原本firebase (realtime database)是以key-value為主的NoSQL,在google收購後,與原本google的data store整合,推出firestore。Firestore的主要是以兩個概念構成:collection及document,一個collection中可以有很多document,每個document中還可以有document或collection。

現在firebase建議開發者使用firestore,應該不久的將來realtime database會停止服務。

Firebase提供的服務很多,除了兩個NoSQL服務: realtime database、firestore,還有cloud storageauthenticationcloud message

firebase.ppt

設定

react使用firebase套件,目前最新版本是9.1.3 (modular),由於語法有大幅度的修改,8.10.0 (namespaced)也還能使用(2021/10/21)。使用新版(第九版)的好處是縮小程式碼的大小,語法也改變了,不再有一大堆「.」了。

直接安裝的話,會是最新版 (9.1.3)

npm install firebase

如果要安裝特定版本,如:8.10.0

npm install firebase@8.10.0

以下範例都以9.1.3 為主,網路上很多範例還是過去的版本,參考時請注意。

寫第一個測試程式,參考firestore文件 (Get started with Cloud Firestore ),修改一下我們的ProductList。

先import我們需要的套件

import { initializeApp } from "firebase/app";

import { getFirestore } from "firebase/firestore";

在firebase console裡的專案設定中,可以在一般設定下找到 (老師把我的apiKey改為XXXXX)

const firebaseConfig = {

  apiKey: "XXXXXXXXXXX",

  authDomain: "product-a87b2.firebaseapp.com",

  databaseURL: "https://product-a87b2.firebaseio.com",

  projectId: "product-a87b2",

  storageBucket: "product-a87b2.appspot.com",

  messagingSenderId: "576564577770",

  appId: "1:576564577770:web:a6e3f73c173f1782dd5e93"

};

因為apiKey是需要被隱藏的,建議將apiKey的內容放到.env,因為我們利用create-react-app產生react專案,可以直接使用.env,把一些參數放在.env的好處是,可以將.env放進.gitignore,同步時,大家使用各自的.env,在這裡,只示範將apiKey放在.env,也可以將所有的參數都儲存在.env中:

/.env 

** 注意** 1.不需要雙引號 2.要放在最外層 (跟package.json一起)

REACT_APP_FIREBASE_API_KEY=XXXXXXXXXXX

在react裡就可以取得環境變數

** 注意** 不需要雙引號!!

apiKey: process.env.REACT_APP_FIREBASE_API_KEY,

其他的內容不需要隱藏,可以放在firebaseConfig.js

/src/settings/firebaseConfig.js

export const config = {

    apiKey: process.env.REACT_APP_FIREBASE_API_KEY,

    authDomain: "product-a87b2.firebaseapp.com",

    databaseURL: "https://product-a87b2.firebaseio.com",

    projectId: "product-a87b2",

    storageBucket: "product-a87b2.appspot.com",

    messagingSenderId: "576564577770",

    appId: "1:576564577770:web:a6e3f73c173f1782dd5e93"

  };

另外,根據我們所產生的應用程式,利用firebase_config.js設定相關參數:

import {config} from '../settings/firebaseConfig';

接下來啟動firebase app

  const firebaseApp = initializeApp(config);

啟動firestore

  const db = getFirestore();

為避免重複啟動,可以利用getApps().length先檢查一下:

  import { getApp, getApps, initializeApp } from "firebase/app";



  getApps().length === 0 ? initializeApp(config) : getApp();

等於

  if (getApps().length===0) {

    initializeApp(config);

  }

  else {

    getApp();

  }

讀取資料

Firestore讀取資料的範例(Get started with Cloud Firestore),這個範例是透過collection(),去從前面設定的db裡,取得一個名字為product的collection,並透過getDocs()取得所有的文件,由於取得資料是非同步的動作,所以,採用javascript的await (請參考: Javascript 中Asynchronous的說明),等到取得資料才會執行下一步

先import這兩個元件:

import { getFirestore, collection, getDocs } from "firebase/firestore";

取得資料

const querySnapshot = await getDocs(collection(db, "product"));

取得資料之後,利用forEach去取得每一筆文件。

    querySnapshot.forEach((doc) => {


    });

試試看:

  const getData = async function() {

    const querySnapshot = await getDocs(collection(db, "product"));

    querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

      console.log(doc.id, " => ", doc.data());

    });

  

  }

  getData();

因為還沒有輸入任何資料,應該是看不到任何資料。

記得,在firestore裡的存取規則必須允許read,否則,會有錯誤:

** 注意,這是不安全的作法,所以,firebase建議設定一個使用期限,Authentication 會介紹較安全的作法 **

rules_version = '1';

service cloud.firestore {

  match /databases/{database}/documents {


    match /{document=**} {

      allow read, write: if request.time < timestamp.date(2021, 11, 1);

      

    }

  }

}

接下來,我們先在firestore新增一些資料。先新增一個product的collection,再新增兩個文件,文件裡有兩個欄位:desc跟price,desc是個字串(string),price是個數字(number)。

就可以在console中看到資料了。

接下來,我們要怎麼把讀到的資料放到products陣列中? 如果我們寫成:

  const products=[

    {desc:"iPad", price:20000},

    {desc:"iPhone X", price:30000},

   ];


  const readData = async function() {

    const querySnapshot = await getDocs(collection(db, "product"));

    querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

      console.log(doc.id, " => ", doc.data());

      products.push({desc:doc.data().desc, price:doc.data().price});

      

    });

  };

  readData();

會發現螢幕上還是只有原來的兩筆資料,但是從console裡可以看到資料被讀取了。原因是,因為讀取資料是非同步動作,所以會先顯示畫面再去讀取,可是,當資料已讀取時,並不會去rerender畫面。那要怎麼rerender呢? 首先,要將資料改為state變數。  

  const [products,setProducts]=useState([

    {desc:"iPad", price:20000},

    {desc:"iPhone X", price:30000},

   ]);
  const readData = async function() {

    const querySnapshot = await getDocs(collection(db, "product"));

    const temp = [];

    querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

      console.log(doc.id, " => ", doc.data());

      temp.push({desc:doc.data().desc, price:doc.data().price});

      

    });

    setProducts([...temp]);

  };

  readData();

可是,打開console會發現產生無窮迴圈。因為,每次setProducts之後,畫面重整,又會執行readData,執行readData就會setProducts,形成無窮迴圈。

怎麼辦呢? 那就要把readData放到useEffect裡,因為希望只要讀一次,就加上空陣列。

  const readData = async function() {

    const querySnapshot = await getDocs(collection(db, "product"));

    const temp = [];

    querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

      console.log(doc.id, " => ", doc.data());

      temp.push({desc:doc.data().desc, price:doc.data().price});

      

    });

    setProducts([...temp]);

  };

  //readData();



useEffect(readData

  ,[]);

這時候,會看到

Line 41:20:  Effect callbacks are synchronous to prevent race conditions. Put the async function inside:


useEffect(() => {

  async function fetchData() {

    // You can await here

    const response = await MyAPI.getData(someId);

    // ...

  }

  fetchData();

}, [someId]); // Or [] if effect doesn't need props or state

所以,為了避免賽跑情境 (詳參: Avoiding Race Conditions when Fetching Data with React Hooks ),將程式改寫為:

  useEffect(()=>{

    async function readData() {

    

      const querySnapshot = await getDocs(collection(db, "product"));

      const temp = [];

      querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

        console.log(doc.id, " => ", doc.data());

        temp.push({desc:doc.data().desc, price:doc.data().price});

      

      });

      console.log(temp);

      setProducts([...temp]);

    }

    readData();

  },[]);

結果,產生了一個警告:

Line 40:5:  React Hook useEffect has a missing dependency: 'db'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

因為react發現用到了db,建議要將db放到dependency array,所以,修改一下程式:

  useEffect(()=>{

    async function readData() {

    

      const querySnapshot = await getDocs(collection(db, "product"));

      const temp = [];

      querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

        console.log(doc.id, " => ", doc.data());

        temp.push({desc:doc.data().desc, price:doc.data().price});

      

      });

      console.log(temp);

      setProducts([...temp]);

    }

    readData();

  },[db]);

現在的做法是只讀取一次,也可以去監聽是否有新資料,只要資料更動就重新讀取,這部分比較複雜,有興趣的同學可以研究一下

現在,試試看把await去掉,會看到:

Unhandled Rejection (TypeError): querySnapshot.forEach is not a function

原因是去掉await之後,javascript就不會停下來等資料回傳回來,這時候querySnapshot是null,所以,無法執行.forEach()。

小組作業

新增資料

根據Firestore新增文件範例使用addDoc時,firestore會自動產生id:

import { collection, addDoc } from "firebase/firestore"; 


// Add a new document with a generated id.

const docRef = await addDoc(collection(db, "cities"), {

  name: "Tokyo",

  country: "Japan"

});

console.log("Document written with ID: ", docRef.id);

也可以在新增文件時,指定id:

import { doc, setDoc } from "firebase/firestore"; 


// Add a new document in collection "cities"

await setDoc(doc(db, "cities", "LA"), {

  name: "Los Angeles",

  state: "CA",

  country: "USA"

});

試試看,怎麼改我們的新增產品?

接下來,根據範例,我們來修改ProductAdd.js,讓輸入的資料新增到firestore裡,也利用async/await來呼叫add()。這邊稍微注意一點,如果直接將product內容上傳,price的值會是字串

    try{

      const docRef = await addDoc(collection(db,"product"),{

        desc:product.desc,

        price:product.price

        });

      console.log(docRef.id);

    }

    catch(e){

      console.log(e);

    }

如果希望儲存的內容是整數,就要先轉成整數

    try{

      const docRef = await addDoc(collection(db,"product"),{

        desc:product.desc,

        price:parseInt(product.price)

        });

      console.log(docRef.id);

    }

    catch(e){

      console.log(e);

    }

注意firestore的規則必須允許write,否則,會有錯誤:

** 注意,這是不安全的作法,所以,firebase建議設定一個使用期限,在Authentication 會介紹較安全的作法 **

rules_version = '1';

service cloud.firestore {

  match /databases/{database}/documents {


    match /{document=**} {

      allow read, write: if request.time < timestamp.date(2021, 11, 1);

      

    }

  }

}

如果看到:

FirebaseError: The caller does not have permission

那就是權限設定錯誤了!

注意,新增的文件是依照doc.id排序,所以,不會是最後一筆。另外,因為我們原本的設定,不會去管權限的問題,所以,所有人都能讀寫,在Authentication 會介紹,如何讓登入者可以寫資料。

ProductList的部分,有兩種做法:

import { doc, onSnapshot } from "firebase/firestore";


const unsub = onSnapshot(doc(db, "cities", "SF"), (doc) => {

    console.log("Current data: ", doc.data());

});

但是,如果考慮到資料量很大,不希望再讀取一次所有資料,有人會用前面的做法,利用ProductAdd更新在ProductList裡的products內容,而不是再去讀一次firestore裡的內容,只是,這樣無法確認資料是否有效的寫進firestore。所以,一般的做法是採用cache。這個做法留在PWA的部分討論。

如果是採用第一種方法,那要如何讓ProductList啟動useEffect? 

使用Progress

當網路速度不是很快的時候,讀取資料時會稍微延遲,可以利用一些技巧讓使用者知道內容正在讀取,例如:可使用material-ui提供的Progress,Progress有很多展現的方式,可使用CircularProgress。可以利用conditional rendering的技巧,當isLoading是false時,顯示元件,否則,顯示CircularProgress:

  return (

    <Box sx={{

      width: '100vw',

      height: '100vh',

      backgroundColor: 'background.paper',

      color: 'black',

      textAlign: 'left'

    }}>

    <AppMenu/>

    {!isLoading ?

      <ProductListComponent/>

       :

      <CircularProgress />

    }

    

    </Box>


  );

我們把原本的內容移到ProductListComponent,有兩個原因:1.isLoading的部分會跟products.map衝突,2.也讓上面的程式容易理解:

  const ProductListComponent = function (){

    return (

      <List subheader="Product list" aria-label="product list">

      {products.map((product, index) => 

        <ListItem divider key={index}>

          <ListItemText primary={product.desc} secondary={"NT$"+product.price}></ListItemText>

        </ListItem>)}

      </List>

    )

  }


  return (

    <Box sx={{

      width: '100vw',

      height: '100vh',

      backgroundColor: 'background.paper',

      color: 'black',

      textAlign: 'left'

    }}>

    <AppMenu/>

    {!isLoading ?

      <ProductListComponent/>

       :

      <CircularProgress />

    }

    

    </Box>


  );

記得,要增加:

  const [isLoading, setIsLoading] = useState(false);

在讀取時,先將isLoading設為true,完成後,再將isLoading設為false:

    useEffect(()=>{

    async function readData() {

      setIsLoading(true);

      const querySnapshot = await getDocs(collection(db, "product"));

      const temp = [];

      querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

        console.log(doc.id, " => ", doc.data());

        temp.push({desc:doc.data().desc, price:doc.data().price});

      

      });

      console.log(temp);

      setProducts([...temp]);

      setIsLoading(false);

    }

    readData();

  },[db, open]);

資料排序與篩選

可以試試看如何讓資料根據欄位排序或設定篩選的條件,官網範例如下:

var citiesRef = collection(db, "cities");

取得collection後

import { query, orderBy } from "firebase/firestore";  


const q = query(citiesRef, orderBy("state"), orderBy("population", "desc"));

const q = query(citiesRef, where("population", ">", 100000), orderBy("population"), limit(2));

可以利用where進行條件查詢 (詳參: 在Cloud Firestore中執行簡單和復合查詢)

const q = query(citiesRef, where("state", "==", "CA"));

複合查詢

const q = query(citiesRef, where("state", "==", "CO"), where("name", "==", "Denver"));

可以指定排序的方式,也可以限制只傳前幾筆 (詳參:使用Cloud Firestore排序和限制資料數量

const q = query(citiesRef, orderBy("name"), limit(3));

利用getDocs取得資料

const querySnapshot = getDocs(q);

如果讓產品依照desc排序,程式片段如下:

  useEffect(()=>{

    async function readData() {

      setIsLoading(true);

      //const querySnapshot = await getDocs(collection(db, "product"));

      const querySnapshot = await getDocs(query(collection(db, "product"), orderBy("desc")));

      const temp = [];

      querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

        console.log(doc.id, " => ", doc.data());

        temp.push({desc:doc.data().desc, price:doc.data().price});

      

      });

      console.log(temp);

      setProducts([...temp]);

      setIsLoading(false);

    }

    readData();

  },[db, open]);

當資料量大的時候,需要建立索引,增加搜尋的速度。(詳參: 在Cloud Firestore中管理索引)

      querySnapshot.forEach((doc) => {

      // doc.data() is never undefined for query doc snapshots

        console.log(doc.id, " => ", doc.data());

        temp.push({id:doc.id, desc:doc.data().desc, price:doc.data().price});

      

      });

小組作業

資料修改、刪除

增加刪除、修改的按鈕的部分,請參考Form

deleteDoc()

await deleteDoc(doc(db, "cities", "DC"));

首先,透過id進行刪除:

  const deleteData = async function(id){

    try{

      setIsLoading(true);

      await deleteDoc(doc(db, "product", id));

      //console.log("deleted");

      setDeleted(deleted+1);

      setIsLoading(false);

    }

    catch (error){

      console.log(error);

    }

  }

記得要將product.id傳給deleteData

  const ProductListComponent = function (){

    return (

      <List subheader="Product list" aria-label="product list">

      {products.map((product, index) => 

        <ListItem divider key={index}>

          <ListItemText primary={product.desc} secondary={"NT$"+product.price}></ListItemText>

          <IconButton edge="end" aria-label="delete" onClick={()=>deleteData(product.id)}>

            <DeleteIcon />

          </IconButton>

        </ListItem>)}

      </List>

    )

  }

所以,別忘了在讀取資料時,也要讀取doc.id

useEffect(()=>{

    async function readData() {

      setIsLoading(true);

      const querySnapshot = await getDocs(collection(db, "product"));

      const temp = [];

      querySnapshot.forEach((doc) => {

        temp.push({id:doc.id, desc:doc.data().desc, price:doc.data().price});     

      });

      setProducts([...temp]);

      setIsLoading(false);

    }

    readData();

  },[db, open, deleted]);

updateDoc()

const washingtonRef = doc(db, "cities", "DC");
await updateDoc(washingtonRef, {

  capital: true

});

更新的部分:

        await updateDoc(doc(db,"product",product.id),{

          desc:product.desc,

          price:parseInt(product.price)

        });

如果將新增與修改合併:

const update = async function(){

    const db = getFirestore();

    try{

      if (action === "新增"){

        const docRef = await addDoc(collection(db,"product"),{

          desc:product.desc,

          price:parseInt(product.price)

          });

        console.log(docRef.id);

      }

      else {

        await updateDoc(doc(db,"product",product.id),{

          desc:product.desc,

          price:parseInt(product.price)

        });

      }

    }

    catch(e){

      console.log(e);

    }

    props.close();

  }

setDoc()

await setDoc(doc(db, "cities", "LA"), {

  name: "Los Angeles",

  state: "CA",

  country: "USA"

});

更新的部分:

        await setDoc(doc(db,"product",product.id),{

          desc:product.desc,

          price:parseInt(product.price)

        });

小組作業

FAQ

if (doc.exists) {
  console.log (doc.data().event_id);
}

import React, {useEffect, useState} from 'react';

import { Box, CircularProgress, List, ListItem, ListItemText } from '@mui/material';


import { getApp, getApps,initializeApp } from "firebase/app";

import { getFirestore, collection, getDocs } from "firebase/firestore";


import {config} from '../settings/firebaseConfig';

import AppMenu from '../ui/AppMenu';


export default function MachineList() {


  getApps().length === 0 ? initializeApp(config) : getApp();

  const db = getFirestore();

  const [isLoading, setIsLoading] = useState(false);

  const [locations,setLocations]=useState([]);

  

  

  

  useEffect(()=>{

    async function readData() {

      setIsLoading(true);

      setLocations([]);

      const querySnapshot = await getDocs(collection(db, "location"));

      querySnapshot.forEach(async (doc) => {

        console.log(doc.data().name);

        const querySnapshotMachine = await getDocs(collection(db, "location/"+doc.id+"/machine"));

        const tempMachines =[];

        querySnapshotMachine.forEach (async(machine)=>{

          console.log(machine.data());

            tempMachines.push(machine.data().location);

        })

        setLocations((current)=>[...current, {id:doc.id, name:doc.data().name, machines:[...tempMachines]}]);

      });

      setIsLoading(false);

    }

    readData();

  },[db]);


  const MachineListComponent = function (){

    return (

      <List subheader="Location list" aria-label="location list">

        {console.log("locations:", locations)}

      {locations.map((location) => 

        <ListItem divider key={location.id}>

          <ListItemText primary={location.name} 

            secondary = {location.machines.map((machine)=>machine+" ")}>

          </ListItemText>

        </ListItem>)}

      </List>

    )

  }

  return (

    <Box sx={{

      width: '100vw',

      height: '100vh',

      backgroundColor: 'background.paper',

      color: 'black',

      textAlign: 'left'

    }}>

      <AppMenu/>

      {!isLoading ?

      <MachineListComponent/>

       :

      <CircularProgress />

      }

    </Box>


  );

}

參考資料

權限設定

其他