iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 19 篇

【 Day 18 】那,深模組有沒有代價?|瀏覽器 API(三)

  • 分享至 

  • xImage
  •  

在昨天,我們透過 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

那麼,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 版逐字相同的行,也就是複製貼上的份量
  • 其中要改的:新寫的行裡,複製過來之後改過的行
  • 原封不動重用的:一行都沒動、直接拿來用的既有程式碼
實作 新寫的程式碼行 其中跟 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-ts 的代價攤在每一份複製出來的檔案上,看得見,但要付很多次。@react-hookz/web 的代價只付一次,卻放在呼叫端不容易看見的地方。

深模組並不是沒有洞,比較像是把洞換到了別的地方。

兩種都有代價。那,有沒有第三種答案?

本文對照 usehooks-ts 3.1.1、react-use 17.6.1、@react-hookz/web 26.1.0,查核於 2026-08-31;文中引用的原始碼行號與行數查核於 2026-10-02。removeItem 送出的 storage 事件中 newValue 為 null,依 HTML Standard 的 Web Storage 章節,查核於 2026-10-02。開頭兩個 App 以 npm 上的 @react-hookz/web 26.1.0 與 usehooks-ts 3.1.1,在 react 19.3.0 與 jsdom 中模擬另一個分頁跑過,查核於 2026-10-02。


上一篇
【 Day 17 】呼叫端還需要知道什麼?|瀏覽器 API(二)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言