在昨天,我們透過 react-use、usehooks-ts 與 @react-hookz/web 三個函式庫,看到同樣封裝瀏覽器 localStorage 的 Hook,在面對是不是在伺服器上執行時,各自在呼叫介面底下有不同的處理方式。
我們從而推論:Hook 替呼叫端承擔得越多,呼叫端需要知道的事情可能就越少。
這看起來是件好事,但模組深一點,真的總是比較好嗎?
今天讓我們回到 Day 16 主題切換的例子,不過把它的使用場景,從同一個頁面,切換到不同分頁的情境,試著從中探索這個問題。
這是一個用 @react-hookz/web 的 useLocalStorageValue 存主題的元件。除了切換成深色,它多了一顆按鈕,用來恢復預設主題:
import { useLocalStorageValue } from "@react-hookz/web";
export const App = () => {
const theme = useLocalStorageValue<string>("theme", {
defaultValue: "light",
});
return (
<>
<p>目前主題:{theme.value}</p>
<button onClick={() => theme.set("dark")}>深色</button>
<button onClick={() => theme.remove()}>恢復預設主題</button>
</>
);
};
remove 會把 theme 這個 key 從 localStorage 裡刪掉。刪掉之後,value 退回 defaultValue,也就是 light。
把同一個頁面開在兩個分頁,姑且叫它們分頁 A 與分頁 B。一開始兩邊都顯示 light。
在分頁 A 按下「深色」,分頁 B 也跟著變成 dark。這是我們提過的 storage 事件:瀏覽器會把一個分頁對 localStorage 的改動,通知同一個網站的其他分頁。目前,跨分頁的同步似乎能夠正常運作。
接著,在分頁 A 按下「恢復預設主題」,我們會發現這樣的情形:
分頁 A:目前主題:light
分頁 B:目前主題:dark
分頁 B 沒有跟上。可是重新整理分頁 B,它又會顯示 light。由於 localStorage 裡的 theme 確實已經被刪掉了,所以分頁 B 一重讀就對了。它只是沒有被通知到。
把 Hook 換成 usehooks-ts 的 useLocalStorage,其餘照舊:
import { useLocalStorage } from "usehooks-ts";
export const App = () => {
const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light");
return (
<>
<p>目前主題:{theme}</p>
<button onClick={() => setTheme("dark")}>深色</button>
<button onClick={() => removeTheme()}>恢復預設主題</button>
</>
);
};
同樣的步驟,這次在分頁 A 恢復預設主題之後,分頁 B 也跟著回到了 light。
寫入傳得過來,刪除卻傳不過來。所以這不是有沒有做跨分頁同步的問題。
同一條路上,有一種改變,沒有被當成一次改變。
而這個差異,不是在 Hook 的介面上,而是在 Hook 幫我們藏起來的地方。
那麼,一個替呼叫端承擔更多的模組,它付出的代價會落在哪裡?
Day 16 與 Day 17 各看了紅綠表的幾列。這次讓三個函式庫的臨摹版把 pnpm test 裡的九個情境全部跑完。✓ 表示這份實作替呼叫端承擔了這件事,✗ 表示沒有:
| 情境 | react-use | usehooks-ts | @react-hookz/web |
|---|---|---|---|
| 同一頁兩個元件:一邊寫入,另一邊看得到 | ✗ | ✓ | ✓ |
| 同一頁兩個元件:一邊刪除,另一邊看得到 | ✗ | ✓ | ✓ |
另一個分頁寫入(storage 事件) |
✗ | ✓ | ✓ |
另一個分頁刪除(storage 事件,newValue 是 null) |
✗ | ✓ | ✗ |
| 伺服器端渲染不丟錯 | ✓ | ✓ | ✓ |
| 預設選項下,水合前後一致 | ✗ | ✗ | ✗ |
呼叫端關掉初次讀取(initializeWithValue: false)後,水合前後一致 |
✗ | ✓ | ✓ |
| 存的不是合法 JSON:退回初始值 | ✓ | ✓ | ✓ |
存的不是合法 JSON:留下痕跡(console) |
✗ | ✓ | ✓ |
react-use 前四列都是紅的,因為它沒有監聽任何事件,寫入與刪除都只更新呼叫的那個元件自己。這是 Day 16 已經看過的事。
值得看的是後兩欄。九列裡,usehooks-ts 與 @react-hookz/web 只有一列不同:第四列,而綠的是 usehooks-ts。
第二列是綠的:同一頁的刪除,@react-hookz/web 傳得到。第三列也是綠的:另一個分頁的寫入,它也傳得到。只有在另一個分頁刪除的時候,才沒有傳過來。
Day 16 看過 @react-hookz/web 的登記簿:模組層記著每一個 key 有哪些監聽函式,寫入時把字串交給它們。而登記簿的入口其實有兩個。
remove 直接交給登記簿同一頁的刪除,走的是 remove。概念上大概是這樣:
remove(): void {
storage.removeItem(key);
invokeStorageKeyListeners(storage, key, null);
},
刪掉之後,它把 null 交給登記簿。每個監聽函式收到 null,交給 parse。Day 17 看過的 defaultParse 遇到 null,就交回 fallback,畫面因此退回預設值。
所以登記簿本身是認得 null 的。對它來說,null 的意思是「這個 key 已經不在了」。
storage 事件的處理函式另一個分頁的改動,則是從瀏覽器的 storage 事件進來。整個模組在 window 上只掛一個處理函式,事件先經過它,才分給登記簿。
而另一個分頁呼叫 removeItem 時,瀏覽器送來的 storage 事件裡,newValue 是 null。
在原始碼 useStorageValue/index.ts 裡,這個處理函式的本體是一個條件判斷,寫在這一行:
if (evt.storageArea && evt.key !== null && evt.key !== '' && evt.newValue !== null && evt.newValue !== '') {
條件全部成立,它才把 evt.newValue 交給登記簿。newValue !== null 這一項,讓刪除產生的事件停在這裡,登記簿收不到。
同一個 null,從 remove 進來,代表「已經不在了」;從 storage 事件進來,則被當成沒有東西需要傳遞。
這是不是刻意的取捨,其實不重要。
重要的是,刪除事件在這裡被擋了下來,而呼叫端看不到這件事。
那麼,usehooks-ts 為什麼傳得過來?
Day 16 看過它的處理函式 handleStorageChange:
const handleStorageChange = (event: StorageEvent): void => {
if (event.key && event.key !== key) return;
setStoredValue(readValue());
};
它只確認 key 對得上,就回到 localStorage 重讀一次。事件帶來的 newValue 是什麼,它完全不看。
這看起來有點繞路。事件明明已經帶著新的值,它卻還是回頭讀一次 localStorage。
但正因為它不看事件帶來的值,也就沒有任何一個地方,需要決定 null 代表什麼。刪除之後,readValue 在 localStorage 裡讀不到這個 key,於是交回初始值。對它來說,刪除只是「重讀」的其中一種結果。
@react-hookz/web 則直接使用事件帶來的值,不再回頭讀 localStorage。少讀一次,代價是它得自己判斷事件裡的每一種值,而 null 是其中一種。
假使我們用的是 @react-hookz/web,又碰到了分頁 B 沒跟上的情況。從呼叫端能看到的,是什麼?
介面上只有 value、set、remove 與 fetch。remove 在自己這一頁是有效的,另一個分頁的寫入也傳得過來。「另一個分頁的刪除不會傳過來」這件事,在介面上找不到對應的地方。
要知道原因,得去讀那個掛在 window 上的處理函式。它不在 Hook 的本體裡,而在模組層,由所有用到這個模組的元件共用。
usehooks-ts 的 handleStorageChange 就寫在 useLocalStorage 裡面,跟讀寫 localStorage 的程式碼放在同一個檔案、同一個函式。
@react-hookz/web 把訂閱、去重都藏進了模組層,呼叫端因此不必知道它們怎麼運作。可是當它的行為跟預期不同時,呼叫端要找原因,也得到那個被藏起來的地方找。
useSessionStorage 要寫幾行usehooks-ts 的代價則出現在另一個地方。
除了 localStorage,瀏覽器還有一個介面相同的 sessionStorage,存的資料只活在這個分頁的這次瀏覽裡。三個函式庫都另外提供了 sessionStorage 的版本。
在 usehooks-ts 的原始碼裡,useLocalStorage.ts 與 useSessionStorage.ts 各是 191 行,其中 160 行一字不差。其餘 31 行的差別,幾乎都是把 local 換成 session:型別與函式的名稱、註解與警告訊息、window.localStorage,以及自訂事件的名稱。
@react-hookz/web 的做法則是一個 303 行、接收 Storage 作為參數的 useStorageValue。useLocalStorageValue 與 useSessionStorageValue 是它前面的兩個門面,各 37 行。
這是原庫的行數。那麼,在三份臨摹已經有 localStorage 版本的情況下,再加一個 sessionStorage 版本,各要寫幾行?
我讓三份臨摹照各自的作法各加一個:react-use 與 usehooks-ts 複製整份再修改,@react-hookz/web 則多寫一個門面。pnpm test 會量出行數,表格的四欄是:
localStorage 版逐字相同的行,也就是複製貼上的份量| 實作 | 新寫的程式碼行 | 其中跟 localStorage 版相同 | 其中要改的 | 原封不動重用的 |
|---|---|---|---|---|
| react-use | 46 | 40 | 6 | 0 |
| usehooks-ts | 88 | 73 | 15 | 0 |
| @react-hookz/web | 20 | 13 | 7 | 162 |
usehooks-ts 新寫的 88 行裡,有 73 行是從 localStorage 版複製過來的。複製過來的是整份邏輯:讀取、寫入、廣播、handleStorageChange。之後要修改其中任何一處,就得在兩個檔案各改一次。
@react-hookz/web 新寫的只有 20 行,做的是探測 sessionStorage 能不能用,再把它交給 useStorageValue。登記簿、storage 事件的處理函式、序列化與 Hook 本體,那 162 行一行都沒有動。
重用越多,共用的地方就越重要。
而那一行條件判斷,也只有一份。兩個門面共用同一個處理函式,所以它擋下什麼、放過什麼,兩個門面都會一起承受。
把兩種寫法並排,它們回答的是同一個問題:另一個地方改了這個 key,這裡要怎麼知道?
同一個問題,不同的實作策略,把正確性的成本放到了不同的位置。
usehooks-ts 的代價攤在每一份複製出來的檔案上,看得見,但要付很多次。@react-hookz/web 的代價只付一次,卻放在呼叫端不容易看見的地方。
深模組並不是沒有洞,比較像是把洞換到了別的地方。
兩種都有代價。那,有沒有第三種答案?
本文對照
usehooks-ts3.1.1、react-use17.6.1、@react-hookz/web26.1.0,查核於 2026-08-31;文中引用的原始碼行號與行數查核於 2026-10-02。removeItem送出的storage事件中newValue為null,依 HTML Standard 的 Web Storage 章節,查核於 2026-10-02。開頭兩個App以 npm 上的@react-hookz/web26.1.0 與usehooks-ts3.1.1,在react19.3.0 與 jsdom 中模擬另一個分頁跑過,查核於 2026-10-02。