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 storage、authentication、cloud message。
![](https://www.google.com/images/icons/product/drive-32.png)
Firebase
新增專案
新增應用程式
建立資料庫
新增集合
新增文件
設定
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去設計collection及document
先輸入一些測試資料,並且讀取資料
新增資料
根據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的部分,有兩種做法:
第一種做法是,直接從firestore讀取最新的資料,這樣的話,如果有別人也更動資料,也會一併讀進來。
第二種做法就是利用onSnapshot去監控內容是否更新 (詳參: Get realtime updates with Cloud Firestore)。
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中管理索引)
試試看,如何讀取商品資料時,讓商品依價格排序?
前面的範例展示了新增,請完成刪除及修改。在firestore裡,修改或刪除文件,需要有document的id,因為我們產生document的時候,是利用自動產生的id,所以,在讀取資料時也要同時取得id。
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"));
在firestore裡,利用deleteDoc去刪除文件時,需要document的id,要先取得id(詳參:Delete data from Cloud Firestore )。接下來把document的id傳到deleteDoc裡。
首先,透過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()
在firestore裡,利用set或update去修改文件內容,這時候需要有document的id,因為我們產生document的時候,是利用自動產生的id,所以,要先取得id(詳參:Add data to Cloud Firestore )。
updateDoc更新文件的部分內容,例如,只想更新country的內容 (詳參: Update a document )
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()
在firestore裡,利用set或update去修改文件內容,這時候需要有document的id,因為我們產生document的時候,是利用自動產生的id,所以,要先取得id(詳參:Add data to Cloud Firestore )。
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
為什麼我console.log可以取得doc.id但是doc.data()是有問題的,因為doc.id會取得我們傳給getDoc()的值,所以,就算是錯的,也不會有問題。但,如果這個值在firestore裡面不存在,就無法透過doc.data()取得資料,可以利用exists檢查:
if (doc.exists) {
console.log (doc.data().event_id);
}
為什麼我可以取得資料,但是,資料卻似乎不是我的? 或無法取得資料???
請檢查/src/settings/firebaseConfig.js的內容,是不是連到了老師的資料庫?
為何在forEach中讀取資料,內容不會依照順序讀取?
這就是「非同步的 race condition」
如何讀取doc下的collection
在location (collection)下,每個doc裡有machine (collection)
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>
);
}