localStorage 5MB 限制夠用嗎?對小遊戲存個進度夠,但要存對戰回放、棋譜、大量圖片就不夠。IndexedDB 是瀏覽器提供的「真資料庫」 — 容量到 GB 級、有 index 查詢、支援 Promise(透過 idb 套件)。這篇講從 localStorage 升級的時機與實作。
對比表
| 項目 | localStorage | IndexedDB |
|---|---|---|
| 容量 | 5–10 MB | 50% 可用磁碟(GB 級) |
| API | 同步 | 非同步 |
| 資料型別 | 只能字串 | JS 物件、Blob、ArrayBuffer |
| 查詢 | 只能 by key | 可建 index + range query |
| Schema | 無 | object stores + version |
| 學習曲線 | 5 分鐘 | 1–2 小時 |
什麼時候該升級
- 單一資料 > 1MB(大量圖片、影片)
- 需要查詢「所有大於 X 分數的紀錄」
- 需要存 Blob(如玩家繪製的圖、錄音)
- 跨多個物件的關聯
- 離線優先(PWA 大量資料快取)
用 idb 套件簡化
原生 IndexedDB API 用 callback 寫起來很痛。idb(5KB)把它包成 Promise:
import { openDB } from 'idb';
const db = await openDB('hao-games', 1, {
upgrade(db) {
const store = db.createObjectStore('replays', { keyPath: 'id' });
store.createIndex('by-score', 'score');
store.createIndex('by-date', 'createdAt');
},
});
// 寫
await db.put('replays', {
id: 'r-' + Date.now(),
score: 8240,
createdAt: Date.now(),
frames: replayFrames, // Array of objects
});
// 讀
const r = await db.get('replays', 'r-...');
// 查詢
const top = await db.getAllFromIndex('replays', 'by-score', null, 10);
Schema 版本管理
每次改 schema 升 version。idb 自動跑 upgrade callback:
const db = await openDB('hao-games', 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore('replays', { keyPath: 'id' });
}
if (oldVersion < 2) {
const store = db.transaction.objectStore('replays');
store.createIndex('by-score', 'score');
}
},
});
從 localStorage 遷移
async function migrateFromLocalStorage() {
const raw = localStorage.getItem('DM');
if (!raw) return;
try {
const data = JSON.parse(raw);
await db.put('saves', { id: 'main', ...data });
localStorage.removeItem('DM');
} catch (e) {
console.warn('Migration failed:', e);
}
}
Range Query 範例
「查 7 天內 score > 5000 的所有 replay」:
const cutoff = Date.now() - 7 * 24 * 3600_000;
const tx = db.transaction('replays', 'readonly');
const idx = tx.store.index('by-date');
const range = IDBKeyRange.lowerBound(cutoff);
const results = [];
for await (const cursor of idx.iterate(range)) {
if (cursor.value.score > 5000) results.push(cursor.value);
}
Blob 儲存
使用者畫的圖片、錄的音直接存 Blob:
// 從 Canvas 取 Blob
const blob = await new Promise(r => canvas.toBlob(r, 'image/webp', 0.8));
await db.put('drawings', { id: 'd-' + Date.now(), blob, createdAt: Date.now() });
// 讀回顯示
const d = await db.get('drawings', 'd-xxx');
const url = URL.createObjectURL(d.blob);
img.src = url;
跨頁 / 跨 tab 同步
localStorage 有 storage event。IndexedDB 沒有,但可以用 BroadcastChannel:
const channel = new BroadcastChannel('hao-games');
channel.onmessage = (e) => {
if (e.data.type === 'replay-added') reloadReplays();
};
// 寫入後 broadcast
await db.put('replays', replay);
channel.postMessage({ type: 'replay-added', id: replay.id });
儲存配額(Storage Estimate)
const { usage, quota } = await navigator.storage.estimate();
console.log(`Used ${(usage/1024/1024).toFixed(1)}MB / ${(quota/1024/1024).toFixed(0)}MB`);
滿了之前就要 cleanup。
持久化(Persistent Storage)
瀏覽器在儲存空間吃緊時會自動清掉「臨時」資料。重要資料申請 persistent:
if (await navigator.storage.persisted()) {
console.log('已持久化');
} else {
const granted = await navigator.storage.persist();
console.log('申請結果:', granted);
}
性能對比
| 操作 | localStorage | IndexedDB |
|---|---|---|
| 讀小資料 (1KB) | 0.1ms | 2ms |
| 讀中資料 (1MB) | 5ms | 10ms |
| 讀大資料 (10MB) | 無法 | 30ms |
| 查詢 (10000 筆) | 無法(要遍歷) | 5ms(有 index) |
什麼時候別升級
- 資料 < 1MB 且結構單純:localStorage 簡單夠用
- 程式碼長期維護人力少:IndexedDB 出 bug 時的 debug 成本高
- 跨瀏覽器一致性要求高:Safari 對 IndexedDB 的實作偶爾有怪癖
結語
IndexedDB 不是 localStorage 的替代品 — 是另一個工具。多數小遊戲 localStorage 夠用,但當資料規模或查詢需求超過時,IndexedDB 是優雅的升級路徑。idb 套件讓使用體驗大幅提升,建議直接用而不是寫原生 API。