本工作室 14 款瀏覽器遊戲都有「玩家資料持久化」需求 — 金幣、進度、最佳分數、設定。沒有後端時 localStorage 是首選。但 5–10MB 限制、字串-only、被清快取就消失等限制讓使用上有些技巧。這篇分享 5 個實戰模式。
1. Single-key namespace pattern
不要把每個欄位存一個 key,整款遊戲所有狀態用一個 key:
// 不好:太多 key
localStorage.setItem('coins', '100');
localStorage.setItem('best', '8240');
localStorage.setItem('chars', JSON.stringify({...}));
// 好:單一 key
const D = {
co: 100,
best: 8240,
chars: {...},
v: 1, // 版本號
};
localStorage.setItem('DM', JSON.stringify(D));
好處:
- 原子讀寫(不會讀到一半新一半舊)
- 跟其他遊戲的 key 不衝突
- 清空 = removeItem 一次
2. Schema versioning
遊戲改版時資料結構會變。v 欄位讓你能 migrate:
function loadData() {
const raw = localStorage.getItem('DM');
if (!raw) return defaultData();
let data = JSON.parse(raw);
// Migration chain
if (data.v === 1) {
data.skinsPurchased = {};
data.v = 2;
}
if (data.v === 2) {
data.activeChar = data.activeChar || 'basic';
data.v = 3;
}
// ... continue chain
return data;
}
遷移是累積式:每升一版只處理 v→v+1 的差異。永遠跑全部遷移確保最新格式。
3. Save 節流(debounce)
玩家每撿一個金幣就 setItem 是不必要的。每 1 秒最多一次:
let saveTimer = null;
function save() {
if (saveTimer) return;
saveTimer = setTimeout(() => {
try {
localStorage.setItem('DM', JSON.stringify(D));
} catch (e) {
// QuotaExceededError 處理(見下文)
}
saveTimer = null;
}, 1000);
}
// 重要:頁面關閉前強制 flush
window.addEventListener('beforeunload', () => {
if (saveTimer) {
clearTimeout(saveTimer);
localStorage.setItem('DM', JSON.stringify(D));
}
});
4. 損毀防護
localStorage 的字串可能因為瀏覽器 bug、儲存空間滿、人工編輯而損毀。穩固的 load 函式:
function loadData() {
try {
const raw = localStorage.getItem('DM');
if (!raw) return defaultData();
const data = JSON.parse(raw);
// 基本驗證
if (typeof data !== 'object' || data === null) {
throw new Error('not object');
}
if (typeof data.co !== 'number') data.co = 0;
if (typeof data.best !== 'number') data.best = 0;
return data;
} catch (e) {
console.warn('Corrupt save, resetting:', e);
// 備份損毀的資料以供 debug
localStorage.setItem('DM_corrupt_' + Date.now(),
localStorage.getItem('DM') || '');
return defaultData();
}
}
5. QuotaExceededError 處理
localStorage 限制 5–10MB,達到上限會 throw:
function safeSave(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch (e) {
if (e.name === 'QuotaExceededError'
|| e.code === 22) {
// 清掉舊資料
cleanupOldKeys();
try {
localStorage.setItem(key, value);
return true;
} catch {
return false; // 還是失敗
}
}
throw e;
}
}
function cleanupOldKeys() {
// 範例:刪掉 1 個月前的損毀備份
const cutoff = Date.now() - 30 * 24 * 3600_000;
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k.startsWith('DM_corrupt_')) {
const ts = parseInt(k.replace('DM_corrupt_', ''));
if (ts < cutoff) localStorage.removeItem(k);
}
}
}
跨 iframe 同步
iframe 跟父頁面共用同一個 localStorage(同源情況下)。一邊改另一邊不會自動知道。用 storage 事件:
window.addEventListener('storage', (e) => {
if (e.key === 'DM') {
// 重新載入遊戲資料
D = loadData();
updateUI();
}
});
注意:storage 事件只在其他 tab/iframe 改動時觸發,不在自己改動時。
不該存 localStorage 的東西
- 敏感資料:JWT 可以但 OAuth refresh token 不行(XSS 取得)
- 大量資料:> 1MB 應該用 IndexedDB
- 結構化資料 + 查詢:用 IndexedDB 才能 index、search
- 跨 origin 資料:localStorage 限同源,需要 server
備份 / 還原
提供「匯出存檔」按鈕讓玩家保護自己資料:
function exportSave() {
const blob = new Blob([localStorage.getItem('DM')],
{ type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dodge-master-save-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importSave(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
JSON.parse(e.target.result); // 驗證格式
localStorage.setItem('DM', e.target.result);
location.reload();
} catch {
alert('檔案格式錯誤');
}
};
reader.readAsText(file);
}
結語
localStorage 雖然簡單,但寫得正確需要這 5 個模式配合 — namespace、versioning、debounce save、損毀防護、quota 處理。本工作室所有遊戲共用一套 storage.js 模組,新遊戲直接 import 即可。