💡 命名微調通知:從今天起,我會在標題標注各領域探索的進度,方便用來文章導航
從「遠端世界」、「共享狀態」,再到「使用者輸入」領域探索的過程中,有沒有發現我們一直在回答「誰該知道」這個問題:遠端資料抓回來之後,這份資料是誰的;共享狀態變了,哪些人該知道;每次擊鍵,誰該被通知。
上一個領域的最後,form action 把值留在 <input> 裡。打字的時候,React 的元件樹沒有收到通知,只在送出的那一刻讀一次。
瀏覽器替我們保管的值,除了輸入框裡的字之外,其實還有 localStorage。這就像用戶端的小型資料庫,用起來十分直覺:要讀就從裡面 getItem,要寫進去就 setItem,似乎沒有誰需要持續知道什麼。
今天進入第四個設計問題:瀏覽器 API 的複雜度 —— 包一層,就算封裝了嗎?
正式進入討論之前,先讓我們把瀏覽器提供的 localStorage 相關方法包成一個 Hook,然後在同一個頁面的兩個元件裡使用它。再來探索它是不是真的不需要任何人知道。
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API ✍️ · 互動行為 · 真實 DOM
我們會透過 key 來讀寫 localStorage 的值,寫入時指定 key 名稱存入資料 (setItem(key, value)),然後使用 key 來讀取該 key 對應的值 (getItem(key))。
由於在 localStorage 儲存的資料不同於頁面的狀態,在重新整理後還會存在,是一種持久化儲存的選項,常用於儲存主題、偏好設定之類的非隱私資料。
假使我們要從 localStorage 取得資料作為元件的狀態,狀態更新時也同時寫入 localStorage。為了保持簡單,值只存字串:
import { useState } from "react";
export function useLocalStorage(
key: string,
initialValue: string,
): [string, (value: string) => void] {
const [value, setValue] = useState(
() => localStorage.getItem(key) ?? initialValue,
);
const set = (next: string): void => {
localStorage.setItem(key, next);
setValue(next);
};
return [value, set];
}
接著把它放進兩個元件:ThemeSwitch 是負責切換主題的按鈕,而 ThemeLabel 則負責顯示目前的主題。兩個元件各自呼叫一次 useLocalStorage("theme", "light"):
const ThemeSwitch = () => {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<button
type="button"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
切換主題(目前:{theme})
</button>
);
};
const ThemeLabel = () => {
const [theme] = useLocalStorage("theme", "light");
return <p>頁首顯示的主題:{theme}</p>;
};
export const App = () => (
<>
<ThemeSwitch />
<ThemeLabel />
</>
);
掛載時,因為 localStorage 裡還沒有 theme,兩個元件都退回初始值,顯示 light。目前,這個 Hook 似乎能夠正常運作。
但是,按下按鈕之後,我們會發現這樣的情形:
切換主題(目前:dark)
頁首顯示的主題:light
ThemeSwitch 知道主題換了,ThemeLabel 卻不知道。
打開開發者工具看 localStorage,theme 確實已經變成 dark。如果嘗試重新整理頁面,ThemeLabel 也會變成 dark。
值是對的,只是 ThemeLabel 沒有跟上。為什麼?
回頭看 set。它做了兩件事:把值寫進 localStorage,再更新自己的狀態。按鈕呼叫的是 ThemeSwitch 那一份 useLocalStorage 交出來的 set,所以被更新的只有 ThemeSwitch。
負責顯示主題的 ThemeLabel,手上的 theme 是它掛載那一刻從 localStorage 讀出來的。在那之後,它沒有再讀過,也沒有人告訴它值換了。
那麼,瀏覽器呢?
瀏覽器其實有一個 storage 事件,localStorage 被改動時會觸發。但它只會送到共用同一份 localStorage 的其他頁面,做出改動的這個頁面,自己收不到。
所以在同一個頁面裡,我們寫的 Hook 不會告訴 ThemeLabel 值換了,瀏覽器也不會。
localStorage 並沒有出錯。讀和寫都照我們的意思發生了,存在裡面的值也一直是對的。
讓 ThemeLabel 落後的是另一件事:同一份值,有兩個元件在看。ThemeSwitch 有自己的狀態,ThemeLabel 也有自己的狀態,這兩個狀態的初始值都來自於 localStorage,而寫入時只有其中一份被更新。
如果這不是 storage 的問題,那問題會出在哪裡?
這個問題我們其實很熟悉。Day 04 問的是兩個元件要同一份遠端資料;Day 06 問的是寫入之後,誰負責讓其他人知道;Day 08 之後,問的是活在 React 外面的狀態變了,哪些元件該知道。前三個領域一路在問的,都是這件事的不同版本。
這份值不屬於 ThemeSwitch,也不屬於 ThemeLabel,它的擁有者是瀏覽器。正因為它不屬於任何一個元件,兩個元件才會各自拿著一份副本,而不是一起看著同一個狀態。
這是同一份狀態有不只一個觀察者的情況。
在上篇結尾,我曾經問過:瀏覽器替我們保管的值,如果也根本不需要持續被知道呢?
對 localStorage 來說,答案是:不,它也需要。
form action 不需要,是因為讀那份值的只有一個時刻,也就是送出的那一刻。而 theme 不一樣,兩個元件從掛載起就一直把它顯示在畫面上,所以只要有一個改了它,另一個就得知道。
那麼,常見的 Hook 函式庫是怎麼處理這件事的?
我挑了三個都提供 localStorage Hook 的函式庫,依它們出現的先後排列:react-use、usehooks-ts 與 @react-hookz/web。它們的介面看起來差不多,給一個 key,拿回一個值與改它的方法。讓我們用簡化過的版本,來看看這三個函式庫大概是如何處理寫入之後的邏輯。
react-use 的 set,去掉錯誤處理之後是這樣:
const set: SetValue<T> = (valueOrUpdater) => {
const next =
valueOrUpdater instanceof Function ? valueOrUpdater(state) : valueOrUpdater;
if (next === undefined) return;
const raw = JSON.stringify(next);
localStorage.setItem(key, raw);
setState(JSON.parse(raw) as T);
};
它跟我們一開始寫的 set 幾乎一樣:寫進 localStorage,再更新自己的狀態,多出來的只是把值轉成 JSON。整份 Hook 裡,沒有任何地方會去找另一個用同一個 key 的元件。
在 react-use 的原始碼 useLocalStorage.ts 裡,寫入之後執行的也是更新自己狀態的這一行:
setState(deserializer(value));
把它放進 ThemeSwitch 與 ThemeLabel,結果跟我們的版本相同:ThemeSwitch 換成 dark,ThemeLabel 留在 light。
要讓兩邊一致,得由呼叫端自己安排。例如只在共同的父元件呼叫一次 Hook,再把值與 set 往下傳給兩個元件。這樣畫面上只剩一份狀態,也就沒有第二個元件需要被通知。
usehooks-ts 的寫入,去掉錯誤處理與環境檢查之後是這樣:
const setValue: SetValue<T> = (value) => {
const newValue = value instanceof Function ? value(readValue()) : value;
window.localStorage.setItem(key, serialize(newValue));
setStoredValue(newValue);
window.dispatchEvent(new StorageEvent("local-storage", { key }));
};
寫進 localStorage、更新自己之後,它多做了一件事:在 window 上丟出一個名為 local-storage 的事件,事件裡帶著 key。
瀏覽器的 storage 事件不會送到同一個頁面,usehooks-ts 就自己造一個。原始碼 useLocalStorage.ts 在這一行的上方註明,這是為了讓每一個同類的 useLocalStorage 都收到通知:
window.dispatchEvent(new StorageEvent("local-storage", { key }));
收的那一端,每個用到這個 Hook 的元件,都在 window 上掛了監聽:
useEffect(() => {
const handleStorageChange = (event: StorageEvent): void => {
if (event.key && event.key !== key) return;
setStoredValue(readValue());
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("local-storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("local-storage", handleStorageChange);
};
});
storage 是瀏覽器從其他頁面送來的事件,local-storage 是同一個頁面裡自己丟出來的,兩種事件交給同一個處理函式。處理函式不看事件帶了什麼值,只確認 key 對得上,就回到 localStorage 重讀一次。
所以按下按鈕之後,ThemeLabel 收到 local-storage,重讀到 dark,畫面跟上了。
window 在這裡是一條所有元件共用的廣播頻道。寫入的元件不知道誰在聽,聽的元件也不知道是誰寫的,只知道「這個 key 變了,所以再去讀一次」。
相對於 usehooks-ts 用 window 廣播,讓所有元件自己去讀新狀態,@react-hookz/web 則是在模組層放了一份登記簿,當狀態更新時,告訴使用該狀態的元件最新值是什麼:
type Listener = (raw: string | null) => void;
const storageListeners = new Map<Storage, Map<string, Set<Listener>>>();
const invokeStorageKeyListeners = (
storage: Storage,
key: string,
raw: string | null,
): void => {
const listeners = storageListeners.get(storage)?.get(key);
if (listeners === undefined) return;
for (const listener of listeners) listener(raw);
};
登記簿有兩層:第一層是哪一個 Storage,第二層是 key,最裡面是一組監聽函式。invokeStorageKeyListeners 找到同一個 Storage、同一個 key 的那一組,把字串交給其中每一個。
如果 ThemeSwitch 與 ThemeLabel 都掛上了,登記簿大概是這樣:
storageListeners
└── localStorage
└── "theme" → { ThemeSwitch 的監聽函式, ThemeLabel 的監聽函式 }
在原始碼 useStorageValue/index.ts 裡,宣告這份登記簿的是這一行:
const storageListeners = new Map<Storage, Map<string, Set<CallableFunction>>>();
每個元件掛載時把自己的監聽函式登記進去,卸載時拿出來。監聽函式收到字串,解析之後更新自己的狀態:
useLayoutEffect(() => {
const listener: Listener = (raw) => setState(parse(raw));
addStorageListener(storage, key, listener);
return () => removeStorageListener(storage, key, listener);
}, [storage, key]);
寫入時,它先寫進 storage,再把同一個字串交給登記簿。同樣去掉環境檢查之後是這樣:
set(value: T | ((prev: T | null | undefined) => T)): void {
const next = value instanceof Function ? value(stateRef.current) : value;
const raw = stringify(next);
if (raw === null) return;
storage.setItem(key, raw);
invokeStorageKeyListeners(storage, key, raw);
},
raw === null 那一行只是序列化的檢查,這裡要看的是最後一行。
set 裡沒有呼叫 setState。寫入的 ThemeSwitch 自己也在登記簿裡,它跟 ThemeLabel 一樣,是從登記簿收到 dark 的。
其他頁面送來的 storage 事件,也是先進到這份登記簿,再分給同一組監聽函式。整個模組只在 window 上掛一個監聽,第一個元件登記時掛上,最後一個離開時拿掉。
把三份並排,寫入之後,另一個元件是這樣知道的:
| 函式庫 | 寫入之後,另一個元件怎麼知道 |
|---|---|
| react-use | 不會知道;要一致,由呼叫端安排 |
| usehooks-ts | 寫入的元件在 window 上廣播,每個元件收到後自己重讀 localStorage |
| @react-hookz/web | 模組層記著每個 key 有誰在看,寫入時把值直接交給它們 |
三份都是「把 localStorage 包一層」,介面也都是一個 key 換一個值與幾個方法。可是包進去的東西不一樣多。
react-use 包進去的,大致上就是我們一開始寫的那幾行,「另一個元件」不在它的範圍裡。usehooks-ts 把「另一個元件」放進了 Hook,但傳話用的是 window,每個元件收到之後各自去讀。@react-hookz/web 則讓模組本身記得,誰在看哪一個 key。
而這三種答案,都不是 localStorage 給的。localStorage 本身只負責讀和寫,並沒有提供方式讓同一個頁面裡的元件知道它變了。「誰在看同一個 key」這件事要不要接手,是包它的人決定的。
只要呼叫端願意把 Hook 提到共同的父元件,react-use 的版本也能讓兩個元件一致。
三種答案都能動。那差別到底在哪裡?
本文對照
usehooks-ts3.1.1、react-use17.6.1、@react-hookz/web26.1.0,查核於 2026-08-31;文中引用的原始碼行號查核於 2026-10-01。storage事件的行為依 MDN 文件,查核於 2026-10-01;實驗跑在react19.3.0 與react-dom19.3.0,查核於 2026-10-01。