iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

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

【 Day 16 】兩個元件用同一個 key,為什麼一邊改、另一邊不知道?|瀏覽器 API(一)

  • 分享至 

  • xImage
  •  

💡 命名微調通知:從今天起,我會在標題標注各領域探索的進度,方便用來文章導航

從「遠端世界」、「共享狀態」,再到「使用者輸入」領域探索的過程中,有沒有發現我們一直在回答「誰該知道」這個問題:遠端資料抓回來之後,這份資料是誰的;共享狀態變了,哪些人該知道;每次擊鍵,誰該被通知。

上一個領域的最後,form action 把值留在 <input> 裡。打字的時候,React 的元件樹沒有收到通知,只在送出的那一刻讀一次。

瀏覽器替我們保管的值,除了輸入框裡的字之外,其實還有 localStorage。這就像用戶端的小型資料庫,用起來十分直覺:要讀就從裡面 getItem,要寫進去就 setItem,似乎沒有誰需要持續知道什麼。

今天進入第四個設計問題:瀏覽器 API 的複雜度 —— 包一層,就算封裝了嗎?

正式進入討論之前,先讓我們把瀏覽器提供的 localStorage 相關方法包成一個 Hook,然後在同一個頁面的兩個元件裡使用它。再來探索它是不是真的不需要任何人知道。

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

把 localStorage 包成一個 Hook

我們會透過 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:寫入之後,只更新自己

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:在 window 上廣播一個事件

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 變了,所以再去讀一次」。

@react-hookz/web:模組自己記得誰在看

相對於 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-ts 3.1.1、react-use 17.6.1、@react-hookz/web 26.1.0,查核於 2026-08-31;文中引用的原始碼行號查核於 2026-10-01。storage 事件的行為依 MDN 文件,查核於 2026-10-01;實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-10-01。


上一篇
【 Day 15 】提交這件事,真的需要持續訂閱嗎?|使用者輸入(四完)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言