iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Modern Web

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

【 Day 19 】什麼時候,正確答案是不要寫這個 Hook?|瀏覽器 API(四完)

  • 分享至 

  • xImage
  •  

過去三天,我們從 localStorage 出發,探索了三個問題:兩個元件用同一個 key,為什麼一邊改、另一邊不知道;把它包成 Hook 之後,呼叫端還需要知道什麼;以及模組做得深一點,代價會落在哪裡。

react-use、usehooks-ts 與 @react-hookz/web 給了三種答案。react-use 寫入之後只更新自己;usehooks-ts 在 window 上廣播,收到的元件各自重讀;@react-hookz/web 則在模組層放了一份登記簿。昨天我們也看到,後兩者把正確性的成本放在不同的位置:一個攤在每一份複製出來的檔案上,一個放在呼叫端不容易看見的地方。

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

其實,有一個答案我們早就寫過了。今天是探索瀏覽器 API 的最後一天,讓我們先看看這個答案為什麼沒有出現在三個函式庫裡,再把這四個領域的答案放到同一張圖上。

遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API ✓ · 互動行為 · 真實 DOM

一個我們寫過的答案

我們在 Day 08 探索共享狀態時寫過一個活在 React 外面的 store,並用兩種方式讓元件讀它。第一種是 useNaiveStore:用狀態存一份副本,再用 useEffect 訂閱,值變了就寫回那份副本。第二種則是 useSyncExternalStore:

export function useStore<T>(store: Store<T>): T {
  return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}

useNaiveStore 擋不住撕裂;換成 useSyncExternalStore 之後,成功解決了撕裂的問題。

探索使用者輸入的領域時,我們又看了兩種訂閱關係的寫法:RHF 讀了就算,TanStack Form 寫了才算,而 TanStack Form 的 useSelector 也是接在 useSyncExternalStore 上。

回頭看這四天的三個 Hook,它們一個都沒有用上 useSyncExternalStore,也沒有 RHF、TanStack Form 這兩種訂閱關係。三份原始碼都是用狀態存一份副本,再用 effect 把值搬進來,跟 useNaiveStore 是同一個形狀。

為什麼?

我能想到比較合理的解釋是時代因素。useSyncExternalStore 是 React 18 才加入的,而這三個函式庫到現在都還支援更早的 React 版本。

這大概能解釋一部分。但比起時代,我更好奇的是另一件事:如果真的要接上 useSyncExternalStore,它的第一個引數要填什麼?

subscribe 這一格要填什麼

讓我們簡單複習一下 useSyncExternalStore 的語法:

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  • subscribe(callback):React 會交給它一個回呼,store 變的時候,我們要呼叫這個回呼;它還要回傳一個取消訂閱的函式。
  • getSnapshot():回傳當前的值,值沒變時要回傳同一個。
  • getServerSnapshot():伺服器渲染與水合時用的值;只在瀏覽器裡渲染的話可以省略。

Day 08 說過,subscribe 回答的是「store 變的時候,誰該被叫醒」。那麼,這三個 Hook 手上,有沒有現成的東西可以填進這一格?

函式庫 可以填進 subscribe 的東西
react-use 沒有。寫入之後只更新自己,Hook 裡沒有任何地方會說「store 變了」
usehooks-ts window 上的兩種事件:其他頁面送來的 storage,以及自己丟出的 local-storage
@react-hookz/web 模組層的登記簿:把監聽函式登記在某一個 Storage 的某一個 key 底下

這張表跟 Day 16 那張很像。後兩者其實都已經自己寫了一份訂閱,只是沒有交給 useSyncExternalStore,而是自己把值搬進狀態。

react-use 則不同。要讓它接上 useSyncExternalStore,不是把一個 API 換成另一個,而是得先回答一個它從來沒有問過的問題:這個值變的時候,誰該知道?

它把 localStorage 當成一個函式呼叫:要讀就 getItem,要寫就 setItem,讀寫之間沒有別人。

而 useSyncExternalStore 要求我們先承認,外面有一個會自己改變的 store,才寫得出 subscribe。

沒有那個承認,就長不出那個 API。

這是我從三份實作的行為推測出來的想法,不是函式庫作者的說法。我沒有找到三個函式庫說明過為什麼不用它,作者當時怎麼想,從程式碼上看不出來。

看得出來的是,useSyncExternalStore 在這裡比較像是一個問題,而不是一個答案:這個 Hook 認為,外面那個 store 是什麼?

  • react-use 沒有認出 store。值讀進來之後,就是這個元件自己的狀態。
  • usehooks-ts 認出了「有東西變了」,但它的通知走的是 window,收到的元件各自回頭重讀。
  • @react-hookz/web 認出的是「某一個 Storage 的某一個 key」,登記簿的兩層也是照著這個單位排的。

三位作者看見的問題不一樣。差別並不在誰用的 API 比較新,因為三個都沒有用。

三個先放著的感覺

看見了什麼,最後會落在呼叫端身上:Hook 沒有看見的事,呼叫端就得自己知道。

這讓我想起前三個領域收尾時,各留下了一個沒有回答的感覺。

  • Day 07:TanStack Query 與 SWR 介面寬度相近,深度不同。深的那一側承接得多,而它承接的東西,會以概念的形式回到呼叫端身上。什麼時候值得付那個代價,我還沒有東西可以量。
  • Day 11:Zustand、Jotai、Valtio 的介面越來越窄,深度越來越深。可是承接得越多,我越難從程式碼看出它替我做了什麼決定。
  • Day 15:RHF 與 TanStack Form 兩個都深,深在不同地方。要拿什麼來比較這樣的兩份答案,我說不出來。

三次卡住的地方很像。我手上只有一把量尺,只能問模組承接了多少。

Day 15 卡得最明顯。兩份答案的深度差不多,用這把量尺去量,它們會疊在同一個位置上。

但其實,Day 17 已經用過另一把了:呼叫端還需要知道什麼?

把四個領域放上同一張圖

我們在 Day 02 說過,深比較像是一個座標,而不是一個分數。只有一把量尺時,量得出來的是分數;有了兩把,才畫得出座標:

  • 介面寬度:呼叫端為了用對它,還需要知道多少
  • 實作深度:模組替呼叫端承擔了多少

x 軸是介面寬度,越往右越寬,y 軸是實作深度,越往上越深。這是我用這兩個軸,粗略畫出四個領域的落點:

四個領域的落點:遠端世界的 Query 在 SWR 上方;共享狀態由 Zustand 往左上依序是 Jotai、Valtio;使用者輸入的 RHF 與 TanStack Form 在同一個高度;瀏覽器 API 的 react-use 在斜線上,usehooks-ts 在它左上方,@react-hookz/web 在 usehooks-ts 正上方

這些點的位置是我自己的判斷,沒有經過量測。同一張小圖裡的點彼此才能比較;不同小圖之間的距離沒有意義,Query 並不因為畫在 Valtio 下面,就比較淺。

每張小圖被虛線分成四塊:左上是窄而深,左下是窄而淺,右上是寬而深,右下是寬而淺。

不過,值得看的不是這四塊,而是那條從左下到右上的斜線。

斜線上的點,介面的複雜度跟實作的複雜度差不多:模組做了多少,呼叫端就得知道多少。學會用它,跟讀懂它的實作,幾乎是同一件事。

離開斜線往左上走,代表模組承擔的,比它要求呼叫端知道的更多。Day 02 說的深模組,就是離開這條斜線的那些點。

前三個領域的形狀

有了這條斜線,再回頭看前三個領域。

遠端世界的兩個點,上下排成一直線。介面寬度相近,而 Query 多承接了資料的生命週期,所以畫得比 SWR 高。Day 07 那個感覺,在圖上是一段垂直的落差:起手的成本一樣,差的是高度。

共享狀態的三個點,從 Zustand 往左上斜著走到 Valtio。每往上一點,呼叫端要寫的就少一些。Day 11 那個感覺也在這條線上:越往左上,呼叫端要知道的越少,也就越難從自己的程式碼,讀出模組替它決定了什麼。

使用者輸入的兩個點,左右排成一橫線。RHF 的 useFormState 只收一個 control;TanStack Form 的 useSelector 還要一個選擇器,必要時再加一個比較函式。兩邊承接的份量,看起來差不多。

這就是 Day 15 說不出來的地方。只問承擔了多少,兩個點疊在一起;加上呼叫端還需要知道什麼,它們就在同一個高度上分開了。

而且這兩個點都在斜線的左上方。TanStack Form 的介面比較寬,但它承擔的仍然比它要求呼叫端知道的多,它只是把訂閱關係交給呼叫端寫。

瀏覽器 API 的三個點

react-use 的點,落在斜線上。

因為它做的事,跟我們在 Day 16 一開始自己寫的那幾行幾乎一樣,所以呼叫端要知道的事,也跟直接呼叫 localStorage 差不多:要讓另一個元件一致,得自己把狀態提到共同的父元件;水合要在 Hook 外面處理;拿到初始值的時候,分不出是沒存過、讀不懂,還是 localStorage 不能用。

usehooks-ts 往左上離開了斜線:同一個 key 的同步,以及另一個分頁的寫入與刪除,它都替呼叫端承擔了。@react-hookz/web 在差不多的寬度上,又往上走了一段:訂閱、去重與伺服器上的探測,都收進了模組層。

那麼,什麼時候,正確答案是不要寫這個 Hook?

我目前的想法是:寫完之後,再問一次「呼叫端還需要知道什麼?」。如果答案跟沒寫之前一樣多,這個 Hook 就落在斜線上,跟它包住的 API 疊在同一個點。

它替呼叫端承擔的,可能只有一個名字。

「呼叫端還需要知道什麼?」這個問句,大概就是我想從這四天帶走的那把尺。

這並不代表它寫錯了。react-use 在回答的,比較像是「讓 localStorage 用起來像 useState」這個問題,對一個只有一個元件在讀的值,這樣就夠了。Day 02 的 usePrevious 也是淺模組,它能封裝的複雜度本來就很有限。

麻煩在於,名字會讓人以為那件事已經被處理了。包了一層之後,我很容易就不再去問,另一個元件會不會知道。

包一層,不一定就算封裝。如果那一層落在斜線上,直接呼叫 localStorage,至少還看得出哪些事得自己處理。

第二個變體

還有一個時刻,比較容易看出一個 Hook 承擔了什麼:需要第二個變體的時候。

昨天我們讓三份臨摹各加一個 sessionStorage 的版本,pnpm test 量出來的行數是這樣:

實作 新寫的程式碼行 其中跟 localStorage 版相同 其中要改的 原封不動重用的
react-use 46 40 6 0
usehooks-ts 88 73 15 0
@react-hookz/web 20 13 7 162

react-use 與 usehooks-ts 新寫的行,大多是從 localStorage 版複製過來的。@react-hookz/web 新寫的只有 20 行,另外 162 行一行都沒有動。原庫也是這樣:usehooks-ts 的第二個變體是另一份 191 行,@react-hookz/web 只多一個 37 行的門面。

如果要把這張表濃縮成一句好記的話,大概是:

薄封裝在你需要第二個變體時複製貼上,深模組只多 37 行。

這四天的複雜度,從哪裡來

那麼,用 Day 02 的詞來說,這個領域的複雜度,到底是從哪裡長出來的?

不在 localStorage 本身。它的方法一看就會用:getItem、setItem、removeItem。

複雜度在它跟 React 元件模型接起來的地方。這份值屬於瀏覽器,不屬於任何一個元件;當兩個元件各自讀它、各自把它顯示在畫面上,同一份不屬於任何人的狀態,就有了不只一個觀察者。而值被刪掉的時候,另一個觀察者要不要跟著退回預設值,也是在這裡才變成問題。

最簡單的 API,複雜度一點也沒少,只是換了地方。

三份答案藏起來的東西,則不一樣多:

函式庫 藏起來的東西 留給呼叫端的
react-use 伺服器上不丟錯 另一個元件、水合、讀取失敗是哪一種
usehooks-ts 再加上同一個 key 的同步與跨分頁 水合的開關;換掉 deserializer 時,哪些處理跟著被換掉
@react-hookz/web 再加上訂閱、去重與伺服器上的探測 水合的開關;以及另一個分頁的刪除傳不過來

react-use 藏得最少,也因此承擔得最少。但它至少很清楚:除了不丟錯,其餘的事都得呼叫端自己來。

usehooks-ts 藏了一部分。藏一半的介面,有時候比不藏更難用,因為呼叫端得知道是哪一半沒藏:initializeWithValue 預設是 true,在先經過伺服器端渲染的頁面上,得先知道要把它設成 false;換掉 deserializer,得先知道原本的 console.error 也跟著不見了。

@react-hookz/web 藏得最多,代價是行為不透明。另一個分頁的刪除傳不過來,而介面上找不到對應的地方。

那麼,這個領域的深度在哪裡?

不在 getItem 與 setItem 的包裝。三份都包了,包法也差不多。

在於認出「一個 Storage 的一個 key」,才是真正的抽象單位。

@react-hookz/web 認出這件事之後,後面的東西幾乎都是它的下游:登記簿照著 Storage 與 key 排成兩層;寫入的元件自己也只是同一個 key 的訂閱者之一,寫入、刪除與另一個分頁送來的事件,最後都交給同一份登記簿;sessionStorage 只是傳進來的另一個 Storage,所以第二個門面只要幾十行。

usehooks-ts 承擔得並不少,但它的單位仍然是 localStorage 本身:程式碼裡直接寫著 window.localStorage,自訂事件的名字也寫死成 local-storage。所以換成 sessionStorage,整份邏輯就得再抄一份。

前面那個問題,這個 Hook 認為外面那個 store 是什麼,答案也在這裡。認出了單位,subscribe 那一格自然就有東西可以填。

瀏覽器 API 結束,前進下一個領域

這四天問的都是同一件事:包一層,就算封裝了嗎?答案從一個只更新自己的 set 開始,走到 window 上的廣播與模組層的登記簿,再走到一個只在另一個分頁刪除時才浮現的洞。

這把尺是在最簡單的 API 上量出來的;等到真實 DOM 那個領域,它會在最難的那一端再量一次。

不過,這四天包的都是一個畫面上看不見的值。

如果要包的是畫面上一段看得見的互動,比如一個用方向鍵選擇的下拉選單,而 DOM 又由你自己決定,函式庫還能替你承擔什麼?

三個函式庫都沒有使用 useSyncExternalStore,依 react-use 17.6.1 的 src/useLocalStorage.ts、usehooks-ts 3.1.1 的 useLocalStorage.ts 與 @react-hookz/web 26.1.0 的 src/useStorageValue/index.ts 的匯入,查核於 2026-10-04。三者的 peerDependencies 都接受 React 18 之前的版本(react-use 是 *,另外兩個是 16.8 以上),useSyncExternalStore 隨 React 18 發布,依 React 官方部落格,皆查核於 2026-10-04。我在三個函式庫的 GitHub issue 與 PR 裡搜尋 useSyncExternalStore,沒有找到關於 storage Hook 為什麼不用它的說明,查核於 2026-10-04。

本文對照 usehooks-ts 3.1.1、react-use 17.6.1、@react-hookz/web 26.1.0,查核於 2026-08-31。


上一篇
【 Day 18 】那,深模組有沒有代價?|瀏覽器 API(三)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言