Engineering

localStorage 在遊戲開發的 5 個應用模式

2026 年 5 月 6 日約 9 分鐘閱讀作者:Hao0321 Studio

本工作室 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));

好處:

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 的東西

備份 / 還原

提供「匯出存檔」按鈕讓玩家保護自己資料:

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 即可。