1153 字
6 分鐘

localStorage JSON 讀寫失敗:安全 fallback 與版本檢查

偏好設定原本能從 localStorage 讀回,更新後卻讓整個頁面初始化失敗。只在 JSON.parse() 外面加 catch 還不夠:取得 storage、讀取 key、解析字串與檢查資料版本,是四個不同的步驟。

讀取失敗時先用可用的預設狀態維持畫面,寫入失敗則明確標示尚未保存。 不要在初始化 catch 裡呼叫 localStorage.clear(),也不要自動把無法辨識的舊值覆寫成預設值。本文以可丟棄的主題偏好為例;未保存的編輯草稿應另外提供匯出或復原路徑。

先確認是沒有資料,還是資料不能使用#

MDN 的 getItem() 文件 說明:key 不存在時回傳 null。這和字串 'null'、空字串、損壞 JSON 不同。

JSON.parse('null'); // 合法 JSON,結果是 null
JSON.parse(''); // SyntaxError
JSON.parse('{'); // SyntaxError

即使解析成功,{"version":1,"theme":42} 也不能直接交給需要字串的 UI。先驗證形狀與版本,再接受資料。不要靠 JSON.parse(raw) || defaultValue 把所有情況混成一種成功結果。

MDN 的 localStorage 文件 也列出存取時的 SecurityError。因此要把 window.localStorage 的取得放在 try 裡,不能先在函式外取得,再期待內部 catch 捕捉 getter 的失敗。

回傳狀態,讓畫面決定如何復原#

這份範例只接受 version 1 的主題偏好;遇到新版或不合法資料時保留原字串,不在讀取時自動遷移或刪除。getStorage 是函式,讓瀏覽器存取和測試替身走相同入口。

const preferenceKey = 'example:preferences';
const defaultPreferences = () => ({ version: 1, theme: 'system' });
function isPreferences(value) {
return value !== null && typeof value === 'object' &&
value.version === 1 &&
['light', 'dark', 'system'].includes(value.theme);
}
function loadPreferences(getStorage) {
let raw;
try {
raw = getStorage().getItem(preferenceKey);
} catch (error) {
return { status: 'unavailable', value: defaultPreferences(), error };
}
if (raw === null) {
return { status: 'missing', value: defaultPreferences() };
}
let value;
try {
value = JSON.parse(raw);
} catch {
return { status: 'invalid-json', value: defaultPreferences() };
}
if (!isPreferences(value)) {
return { status: 'invalid-schema', value: defaultPreferences() };
}
return { status: 'loaded', value };
}
function savePreferences(getStorage, value) {
if (!isPreferences(value)) {
return { status: 'invalid-schema' };
}
// 限定輸出欄位,避免把不相關的資料一起保存。
const serializable = { version: 1, theme: value.theme };
try {
getStorage().setItem(preferenceKey, JSON.stringify(serializable));
return { status: 'saved' };
} catch (error) {
return { status: 'write-failed', error };
}
}

在瀏覽器事件或 client 初始化階段使用,別放在 Astro server frontmatter 或 SSR 的模組頂層:

const result = loadPreferences(() => window.localStorage);
// 示意:把 result.value 交給你的畫面狀態;依 result.status 顯示提示。
const saved = savePreferences(() => window.localStorage, {
version: 1, theme: 'dark',
});
// 只有 saved.status === 'saved' 才顯示「設定已保存」。

MDN 的 setItem() 列出 QuotaExceededError。上面統一回報 write-failed,呼叫端可依 error.name 提供進一步診斷,但不要把每一個寫入例外都說成容量不足。

用替身測試缺值、損壞與拒絕存取#

將函式與以下測試放在同一個 .mjs 檔案。這些 assertion 已在 Node.js v24.13.0 執行;Map 模擬 storage,沒有讀寫瀏覽器中的使用者資料。

import assert from 'node:assert/strict';
const values = new Map();
const storage = {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
};
const getStorage = () => storage;
assert.equal(loadPreferences(getStorage).status, 'missing');
values.set(preferenceKey, '{');
assert.equal(loadPreferences(getStorage).status, 'invalid-json');
assert.equal(values.get(preferenceKey), '{'); // 讀取沒有覆寫舊值
values.set(preferenceKey, '{"version":2,"theme":"dark"}');
assert.equal(loadPreferences(getStorage).status, 'invalid-schema');
values.set(preferenceKey, 'null');
assert.equal(loadPreferences(getStorage).status, 'invalid-schema');
assert.equal(savePreferences(getStorage, {
version: 1, theme: 'dark',
}).status, 'saved');
assert.equal(loadPreferences(getStorage).value.theme, 'dark');
assert.equal(savePreferences(getStorage, {
version: 1, theme: 42,
}).status, 'invalid-schema');
const denied = () => { throw new DOMException('Blocked', 'SecurityError'); };
assert.equal(loadPreferences(denied).status, 'unavailable');
assert.equal(savePreferences(denied, defaultPreferences()).status, 'write-failed');
const full = () => ({
setItem() { throw new DOMException('Full', 'QuotaExceededError'); },
});
assert.equal(savePreferences(full, defaultPreferences()).error.name, 'QuotaExceededError');

復原只處理自己擁有的 key#

若 invalid-json 或 invalid-schema 出現,先檢查這個 key 的寫入來源,以及前一版本是否需要遷移。讓使用者明確重設主題時,再把合法預設值交給 savePreferences();寫入成功才算完成重設。不必清掉同一個 origin 的其他草稿或設定。

跨分頁同時修改、版本遷移與多筆資料交易沒有在這份 helper 裡實作。若功能要求避免遺失重要資料,應另外設計資料版本、衝突處理與備份;不能因為 setItem() 沒拋錯,就宣稱已驗證斷電或程序崩潰後的磁碟耐久性。

上線前在實際瀏覽器確認正常模式、私密模式、封鎖持久化設定和 HTTP/HTTPS 切換的行為。localStorage 依 origin 分隔;本文沒有執行這些瀏覽器環境測試,也沒有用 Node 的 storage 實作代替它們。

若問題其實發生在 API 回應解析,改看 fetch JSON 分層排錯;若要複製記憶體中的物件,則看 structuredClone 的型別與轉移限制。保存、解析和複製各有不同契約,先確認失敗的是哪一步。

參考資料:

MDN:Window.localStorage

MDN:Storage.getItem()

MDN:Storage.setItem()

MDN:JSON.parse()

localStorage JSON 讀寫失敗:安全 fallback 與版本檢查
https://laplusda.com/posts/javascript-localstorage-json-recovery/
作者
Zero
發佈於
2026-10-05
許可協議
CC BY-NC-SA 4.0