Firebase 資料庫如何建立 與 上傳
Firebase Firestore 建立與上傳資料
TL;DR文章類資料用單一 posts collection + 分類欄位 + 索引 比多個 collection 好維護。內容用 array of map 結構儲存。資料量大時走 分頁(cursor-based)+ 過濾(只取需要欄位)+ 快取(redux-persist / TanStack Query)+ SSG。
集合(Collection)結構決策
兩個方案
| 方案 | 結構 | 適合 |
|---|---|---|
| 多個 Collection | food-posts / travel-posts / tech-posts | 預計類別少且固定 |
| 單一 Collection ⭐ | 統一 posts,用 category 欄位區分 | 類別會增加 / 動態 |
為什麼推薦單一 + 分類欄位
// posts collection 中的一筆
{
id: "abc123",
title: "我的食譜筆記",
category: "food", // ⭐ 用欄位分類
tags: ["dessert", "asian"],
authorId: "user-uuid",
content: [...],
createdAt: serverTimestamp(),
}
好處:
- 加新類別 = 新增一個
category值,不用建 collection - 查詢「所有作者的文章」一次 query 搞定
- 排序、分頁、search 邏輯統一
- Firestore Security Rules 寫一次涵蓋所有 posts
代價:單一 collection 會比較大,但 Firestore 用 Composite Index 解決查詢效率,實務上不是問題。
// 查特定類別 + 最新
import { collection, query, where, orderBy, limit, getDocs } from 'firebase/firestore';
const q = query(
collection(db, 'posts'),
where('category', '==', 'food'),
orderBy('createdAt', 'desc'),
limit(10)
);
const snapshot = await getDocs(q);
Firestore 自動建索引提示第一次跑 composite query 時會 console 報錯,提供一個 自動建立 index 的連結,點下去 Firebase 會幫你建好。第二次以後就快了。
內容儲存格式
推薦:Array of Map
{
title: "My Blog Article",
author: "John Doe",
category: "cooking",
content: [
{ type: "p", text: "段落內容..." },
{ type: "h1", text: "章節標題" },
{ type: "img", src: "https://...", alt: "說明" },
{ type: "p", text: "另一段..." }
],
cover: "https://..."
}
優點:
- 易於遍歷渲染(map 就好)
- 可隨意插入新 type(
code/quote/embed) - Frontend 拿到資料即所見
缺點:
- 查詢內部欄位困難(找「所有提到 X 的段落」不好做)
- 修改某段落要更新整個 array
替代:純 Markdown / HTML 字串
{
title: "...",
body: "# 標題\n\n段落...\n\n## 子標題\n..."
}
優點:
- 結構超簡單
- 支援所有 markdown / HTML 工具(Obsidian / VS Code 編輯)
- 可以走 SSG + MDX 渲染
缺點:
- 需要 markdown parser 在 client / server 端
- 圖片要另外處理 storage path
怎麼選
- 互動性高的編輯器(Notion-like)→ Array of Map
- 簡單部落格(像我的 BoboBlog)→ 純 Markdown,我自己選這個
上傳實作
JSON 上傳
import { addDoc, collection, serverTimestamp } from 'firebase/firestore';
import { db } from '../../lib/init-firebase';
const article = {
title: "Example Article",
author: "John Doe",
category: "tech",
body: "...",
createdAt: serverTimestamp(), // ⭐ 用 server 時間,不用 client 時間
};
async function uploadArticle(article: typeof article) {
try {
const docRef = await addDoc(collection(db, 'posts'), article);
console.log('Document written with ID:', docRef.id);
return docRef.id;
} catch (error) {
console.error('Error adding document:', error);
throw error;
}
}
serverTimestamp() vs new Date()永遠用 serverTimestamp(),因為 client 端時鐘可能不準(時區、使用者改系統時間)。Firebase server 時間是唯一可信來源。
Markdown 上傳
import MarkdownIt from 'markdown-it';
import { addDoc, collection, serverTimestamp } from 'firebase/firestore';
const md = new MarkdownIt();
const markdownText = `
# Example Article
This is an example.
## Subsection
`;
const htmlBody = md.render(markdownText);
await addDoc(collection(db, 'posts'), {
title: 'Example',
body: htmlBody,
rawMarkdown: markdownText, // ⭐ 也存原始 md,以後可重新 render
createdAt: serverTimestamp(),
});
存 raw markdown 一份 + render 後 HTML 一份,以後想換 renderer 不用重新處理舊資料。
資料量變多時的優化
1. 分頁(必做)
千萬不要 getDocs(collection(db, 'posts'))一次撈整個 collection 會吃光 quota + 頁面卡死。Firestore 收費按讀次計算,1000 篇 = 1000 次 read。
推薦 cursor-based 分頁:
import { query, orderBy, limit, startAfter, getDocs, DocumentSnapshot } from 'firebase/firestore';
let lastDoc: DocumentSnapshot | null = null;
const PAGE_SIZE = 10;
async function loadNextPage() {
let q = query(
collection(db, 'posts'),
orderBy('createdAt', 'desc'),
limit(PAGE_SIZE)
);
if (lastDoc) {
q = query(q, startAfter(lastDoc));
}
const snap = await getDocs(q);
lastDoc = snap.docs[snap.docs.length - 1];
return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
}
startAfter(lastDoc) 是 cursor-based,效能 O(1),不像 SQL 的 OFFSET 是 O(n)。
2. 預加載
使用者在第 N 頁時,背景 fetch 第 N+1 頁:
// 在 page N 顯示時,啟動 N+1 prefetch
useEffect(() => {
if (currentPage === N) {
prefetchPage(N + 1).then(setNextPageCache);
}
}, [currentPage]);
TanStack Query 的 useInfiniteQuery 內建 prefetch 行為。
3. 過濾(只取需要的欄位)
// ❌ 撈完整 doc(包含巨大的 body)
const snap = await getDocs(query(collection(db, 'posts')));
// ✅ Firestore 不像 SQL 有 SELECT,但可以拆 collection
// 主 collection 只放 metadata,body 另存
{
posts: { id, title, excerpt, category, createdAt }, // 列表用
posts_content: { postId → body, html, raw }, // 詳情頁才查
}
Firestore 沒有 SQL 的 SELECT col1, col2 機制,要透過 schema 設計 來控制讀取量。
Firestore 收費計算每次 getDocs 回多少 document 就算多少 read,跟 doc 大小無關。但 doc 大會吃 client 流量 + 渲染慢。所以 小列表 doc + 大內容 doc 分開 是最佳策略。
4. 快取(redux-persist / TanStack Query)
用 redux-persist 持久化
npm install redux-persist
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { configureStore } from '@reduxjs/toolkit';
const persistConfig = { key: 'root', storage };
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({ reducer: persistedReducer });
export const persistor = persistStore(store);
// App.tsx
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
<Provider store={store}>
<PersistGate persistor={persistor}>
<App />
</PersistGate>
</Provider>
更現代:TanStack Query
import { useQuery } from '@tanstack/react-query';
const { data: posts } = useQuery({
queryKey: ['posts', { category: 'food' }],
queryFn: () => fetchPosts('food'),
staleTime: 5 * 60_000, // 5 分鐘內視為新鮮,不重 fetch
gcTime: 30 * 60_000, // 30 分鐘後才從 cache 移除
});
2026 年首選:@tanstack/react-query 已經取代 redux-persist 在 server state 場景的位置。
渲染策略:文章類用 SSG
SSG(Static Site Generation)
- 適合:部落格、文件、商品介紹(內容變化頻率低)
- 優:首屏快、SEO 滿分、零 server 成本
- 劣:每次更新內容要重 build
SSR(Server-Side Rendering)
- 適合:dashboard、個人化頁面
- 優:資料即時、SEO OK
- 劣:server 負擔、響應較慢
CSR(Client-Side Rendering)
- 適合:後台工具、社群網站
- 優:互動性最強
- 劣:首屏慢、SEO 弱
部落格 90% 走 SSG,Next.js 直接:
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts(); // 從 Firestore 撈
return posts.map((p) => ({ slug: p.slug }));
}
export default async function Post({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return <article>...</article>;
}
詳見 從現在開始改用Next.js。
實際結構:我的部落格範例
// posts collection
{
slug: "react-hooks-deep-dive", // 唯一 URL slug
title: "React Hooks 深入",
excerpt: "前 200 字摘要,給列表用",
category: "tech",
tags: ["react", "frontend"],
authorId: "bobo-uuid",
coverImageUrl: "...",
rawMarkdown: "# Title\n\n...",
htmlBody: "<h1>Title</h1>...",
publishedAt: <timestamp>,
updatedAt: <timestamp>,
viewCount: 0,
isPublished: true,
}
// users collection
{
uid: "bobo-uuid",
displayName: "Bobo",
avatarUrl: "...",
bio: "...",
}
// comments collection(scoped by post)
{
postSlug: "react-hooks-deep-dive",
authorId: "user-uuid",
body: "...",
createdAt: <timestamp>,
}
每個 collection 有單一目的,查詢時依需求 query。