localStorage JSON 讀寫失敗:安全 fallback 與版本檢查
偏好設定原本能從 localStorage 讀回,更新後卻讓整個頁面初始化失敗。只在 JSON.parse() 外面加 catch 還不夠:取得 storage、讀取 key、解析字串與檢查資料版本,是四個不同的步驟。
讀取失敗時先用可用的預設狀態維持畫面,寫入失敗則明確標示尚未保存。 不要在初始化 catch 裡呼叫 localStorage.clear(),也不要自動把無法辨識的舊值覆寫成預設值。本文以可丟棄的主題偏好為例;未保存的編輯草稿應另外提供匯出或復原路徑。
先確認是沒有資料,還是資料不能使用
MDN 的 getItem() 文件 說明:key 不存在時回傳 null。這和字串 'null'、空字串、損壞 JSON 不同。
JSON.parse('null'); // 合法 JSON,結果是 nullJSON.parse(''); // SyntaxErrorJSON.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 通知仍只讀自己的 keyassert.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 的型別與轉移限制。保存、解析和複製各有不同契約,先確認失敗的是哪一步。
參考資料: