1846 字
9 分鐘

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 的其他草稿或設定。

其他分頁改了設定,重新讀取而不自動回寫#

MDN 的 storage 事件文件說明:同源的其他分頁更動 localStorage 時,這個分頁可以收到事件;發起寫入的視窗不會因此收到自己的 storage 事件。目前分頁仍須依 savePreferences() 的回傳結果更新畫面,不能等待事件替自己完成保存提示。

事件是重新檢查資料的通知。不要把 event.newValue 不經 schema 驗證就交給畫面;處理事件時,儲存區也可能已被下一次寫入改變。以下延用 loadPreferences() 重新讀取當下的值,只接受自己的 key;key === null 代表 clear,仍只重讀自己的設定,不清其他資料。

function subscribePreferences(target, getStorage, onRead) {
function handleStorage(event) {
if (event.key !== preferenceKey && event.key !== null) return;
let storage;
try {
storage = getStorage();
} catch (error) {
onRead({ status: 'unavailable', value: defaultPreferences(), error });
return;
}
if (event.storageArea !== storage) return;
onRead(loadPreferences(() => storage));
}
target.addEventListener('storage', handleStorage);
return () => target.removeEventListener('storage', handleStorage);
}

storageArea 篩選可避免把 sessionStorage 的事件當成本地偏好更新。收到事件只讀取與通知,不呼叫 savePreferences(),以免事件接收與寫入互相觸發。onRead 應是同步且不拋錯的畫面更新函式;元件卸載時呼叫回傳的清理函式。下面是瀏覽器整合示意,renderPreferences 要換成專案的渲染函式:

const unsubscribe = subscribePreferences(
window,
() => window.localStorage,
(result) => renderPreferences(result.value, result.status),
);
// 元件卸載時呼叫 unsubscribe();

這份策略只適合可以接受後續設定覆蓋的主題偏好。若目前畫面有未保存草稿,收到其他分頁更新時應提示衝突、保留本地內容,讓使用者決定;不能直接把 fallback 或其他分頁內容寫進編輯器。WHATWG Web Storage 規範提醒不要假設不同視窗間有鎖;「先讀取、加一、再寫回」無法靠 storage 事件變成交易。

測試事件篩選、過時 payload 與清理#

把下面測試接在前面的 helper 與 Map 測試後,放進同一份 .mjs 執行。這裡手動注入事件替身,驗證訂閱函式的分支,不代表已測到瀏覽器的跨分頁事件派送。

const handlers = new Set();
const target = {
addEventListener(type, handler) {
assert.equal(type, 'storage');
handlers.add(handler);
},
removeEventListener(type, handler) {
assert.equal(type, 'storage');
handlers.delete(handler);
},
};
const updates = [];
const stop = subscribePreferences(target, getStorage, (r) => updates.push(r));
function notify(key, storageArea = storage, newValue = '舊事件內容') {
for (const handler of handlers) handler({ key, storageArea, newValue });
}
notify('another:feature');
notify(preferenceKey, {}); // 不同儲存區
assert.equal(updates.length, 0);
values.set(preferenceKey, '{"version":1,"theme":"light"}');
notify(preferenceKey, storage, '{"version":1,"theme":"dark"}');
assert.equal(updates.at(-1).value.theme, 'light'); // 重讀當下值
values.set(preferenceKey, '{');
notify(preferenceKey);
assert.equal(updates.at(-1).status, 'invalid-json');
assert.equal(values.get(preferenceKey), '{'); // 沒有自動回寫
values.delete(preferenceKey);
notify(null); // clear 通知仍只讀自己的 key
assert.equal(updates.at(-1).status, 'missing');
stop();
const count = updates.length;
notify(preferenceKey);
assert.equal(updates.length, count);
assert.equal(handlers.size, 0);
const deniedUpdates = [];
const stopDenied = subscribePreferences(target, denied, (r) => deniedUpdates.push(r));
notify(preferenceKey);
assert.equal(deniedUpdates.at(-1).status, 'unavailable');
stopDenied();
assert.equal(handlers.size, 0);
console.log('preference read/write/subscription checks passed');

2026-10-16 維護時,以上讀寫與訂閱測試已在 Node.js v24.13.0 執行。它們驗證錯誤狀態、資料保留與清理邏輯,沒有開啟兩個瀏覽器分頁。版本遷移、多筆交易與草稿衝突解決仍需額外設計;setItem() 沒拋錯也不構成斷電後磁碟耐久性的實測證據。

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

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

參考資料:

MDN:Window.localStorage

MDN:Storage.getItem()

MDN:Storage.setItem()

MDN:JSON.parse()

MDN:Window.storage event

WHATWG:Web Storage

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