首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau
百百 BLOG
首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau

Kurau Blog

「隨心而寫,真真假假,都是我」

一個記錄生活、輸出興趣的個人空間。
遊戲、影視、閱讀、學習……每一段體驗都值得留下文字。

頁面導覽

  • 學習紀錄
  • 遊戲心得
  • 影視Life
  • 書單
  • 委託作品與二創
  • Kurau
  • 合作邀請

找到我

歡迎來 Discord 找我聊天!

“曾經發生的事不可能忘記,只是暫時想不起來而已。”-《神隱少女》

© 2026 Kurau All rights reserved

後端與資料庫

Firebase 資料庫如何建立 與 上傳

By Kurau·2023-02-04·Updated 2026-05-09·6 分鐘閱讀

Firebase Firestore 建立與上傳資料

TL;DR
文章類資料用單一 posts collection + 分類欄位 + 索引 比多個 collection 好維護。內容用 array of map 結構儲存。資料量大時走 分頁(cursor-based)+ 過濾(只取需要欄位)+ 快取(redux-persist / TanStack Query)+ SSG。

集合(Collection)結構決策

兩個方案

方案結構適合
多個 Collectionfood-posts / travel-posts / tech-posts預計類別少且固定
單一 Collection ⭐統一 posts,用 category 欄位區分類別會增加 / 動態

為什麼推薦單一 + 分類欄位

// posts collection 中的一筆
{
  id: "abc123",
  title: "我的食譜筆記",
  category: "food",          // ⭐ 用欄位分類
  tags: ["dessert", "asian"],
  authorId: "user-uuid",
  content: [...],
  createdAt: serverTimestamp(),
}
typescript

好處:

  • 加新類別 = 新增一個 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);
javascript
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://..."
}
javascript

優點:

  • 易於遍歷渲染(map 就好)
  • 可隨意插入新 type(code / quote / embed)
  • Frontend 拿到資料即所見

缺點:

  • 查詢內部欄位困難(找「所有提到 X 的段落」不好做)
  • 修改某段落要更新整個 array

替代:純 Markdown / HTML 字串

{
  title: "...",
  body: "# 標題\n\n段落...\n\n## 子標題\n..."
}
javascript

優點:

  • 結構超簡單
  • 支援所有 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;
  }
}
typescript
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(),
});
typescript

存 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() }));
}
typescript

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]);
typescript

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 },            // 詳情頁才查
}
typescript

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
bash
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);
typescript
// App.tsx
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';

<Provider store={store}>
  <PersistGate persistor={persistor}>
    <App />
  </PersistGate>
</Provider>
tsx

更現代: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 移除
});
typescript

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>;
}
typescript

詳見 從現在開始改用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>,
}
typescript

每個 collection 有單一目的,查詢時依需求 query。

目錄

    ◆ 相關文章

    • Firebase 驗證 儲存到useContext

      2026-05-09
    • Firebase 前端API 是安全的

      2026-05-09
    • CORS firebase

      2026-05-09
    • 個人網站怎麼選資料庫 (關聯 非關聯)

      2026-05-09
    ← 上一篇index key 請使用 uuid下一篇 →Function Component 介紹

    ◆ 關於作者

    Kurau

    個人寫作 / 創作的 SoT,記錄遊戲、影視、學習與生活。

    更多 Kurau 的文章