iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Modern Web

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

【 Day 08 】訂閱一個活在 React 之外的狀態,至少要做到什麼?|共享狀態(一)

  • 分享至 

  • xImage
  •  

在探索「遠端世界的複雜度」這個設計問題時,我們用來當作快取的 Map 始終活在 React 外面,而我讀它的方式,四天下來也都沒有變過:useEffect 掛上去訂閱,setState 把值搬進來。它一直都能動,所以這件事也就一直沒有被追究。

今天進入第二個設計問題:共享狀態的複雜度 —— 共享狀態變了,哪些人該知道?

不過在討論「哪些人」之前,還有一個更前面的問題:先前用的共享狀態設置,到底夠不夠?今天想把它單獨拿出來,放進一個看得見它出問題的場景裡。

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

建立一個最小的 store

要談「怎麼讀一個活在 React 外面的狀態」,得先有一個活在 React 外面的狀態。

它不需要很聰明,只要能做三件事:讓人讀到當下的值、讓人寫、讓想知道變動的人登記一下。先把這三件事寫成型別:

export type Listener = () => void;

export interface Store<T> {
  getState(): T;
  setState(next: T | ((prev: T) => T)): void;
  subscribe(listener: Listener): () => void;
}

getState 會在渲染中被呼叫,所以它同步、便宜;subscribe 回傳的那個函式是離場的方式。接著是實作,一個值配一組聽眾:

const isUpdater = <T>(next: T | ((prev: T) => T)): next is (prev: T) => T =>
  typeof next === "function";

export function createStore<T>(initialState: T): Store<T> {
  let state = initialState;
  const listeners = new Set<Listener>();

  const getState = (): T => state;

  const setState = (next: T | ((prev: T) => T)): void => {
    const value = isUpdater(next) ? next(state) : next;
    if (Object.is(value, state)) return;
    state = value;
    for (const listener of [...listeners]) listener();
  };

  const subscribe = (listener: Listener): (() => void) => {
    listeners.add(listener);
    return (): void => {
      listeners.delete(listener);
    };
  };

  return { getState, setState, subscribe };
}

我們刻意將這個實作停在最小:一個值、一組聽眾、一個通知。沒有持久化,沒有中介層,也沒有任何一行跟 React 有關。

所以今天的難處不在它身上。難的是它活在 React 外面,想變就變,而 React 的一次渲染有它自己的節奏;兩者接起來的那一刻,才是問題發生的地方。

先前用的訂閱邏輯

要讓元件讀到它,我們在 Day 03 的做法是:用狀態存一份副本,再用 useEffect 去訂閱來源,值變了就寫回那份副本。

寫出來只有五行:

export function useNaiveStore<T>(store: Store<T>): T {
  const [state, setState] = useState(store.getState);

  useEffect(() => store.subscribe(() => setState(store.getState())), [store]);

  return state;
}

它的寫法相對單純,也是我這四天在用的東西,我想多數人第一次要讓元件讀一個模組層級的變數、localStorage 或第三方 SDK 的內部狀態時,大概也會寫出很接近的版本。

比如說,拿它來接一個購物車數量,是這樣用的:

const cartStore = createStore(0);

const CartBadge = ({ label }: { label: string }) => {
  const count = useNaiveStore(cartStore);
  return (
    <span>
      {label}:{count}
    </span>
  );
};

export const App = () => (
  <div>
    <CartBadge label="頁首" />
    <CartBadge label="側邊欄" />
    <button onClick={() => cartStore.setState((c) => c + 1)}>加一件</button>
  </div>
);

按下「加一件」,兩個徽章一起從 0 變成 1、再變成 2。換句話說,目前這個 Hook 確實能夠正常運作。

而且它已經在做一件不容易的事:狀態放在 React 外面,兩個元件之間沒有共同的祖先在傳值,它們卻一直對得上。

把渲染撐長,看外面的世界插隊

我們在 Day 03 說過,撕裂要兩個東西同時湊齊:一次被切開的渲染,以及剛好落在那個切口上的一次改動。平常兩件事很難碰在一起,所以這一節要做的,就是刻意把它們湊齊。

首先,讓每列空轉幾毫秒,把一次渲染撐長:

const BURN_MS = 2;

const burn = (ms: number): void => {
  const until = performance.now() + ms;
  while (performance.now() < until) {
    /* 空轉 */
  }
};

const Row = ({ index }: { index: number }) => {
  const value = useNaiveStore(cartStore);
  burn(BURN_MS);
  return <li data-testid={`row-${index}`}>{value}</li>;
};

四十列,每列兩毫秒,這次渲染因此有八十毫秒左右。接著用 startTransition 把它降成可被切片的更新,同時安排一次落在切口上的改動:

useEffect(() => {
  const timer = setTimeout(() => cartStore.setState(1), 20);
  startTransition(() => setMounted(true));

  return () => clearTimeout(timer);
}, []);

這裡兩行各做一件事。setTimeout 是外面的世界:時間到就寫,不問 React 畫到哪裡,就像一則 WebSocket 訊息。而 startTransition 把「掛上這四十列」這件事降成低優先級。會被切片、也會在切片之間把執行緒讓出去的,通常就是這一種渲染。

這裡沒有任何元件在渲染階段動手腳。改值的是一個計時器,它之所以插得進去,是因為這次渲染真的被切開了。

跑起來,四十列印出來的東西是這樣:

row-0  0
row-1  0
 ...
row-7  0
row-8  1
row-9  1
 ...
row-39 1

前面幾列讀到 0,後面的讀到 1。我連續跑了六次,分界落在第 8 列與第 12 列之間,不是固定哪一列。

畫成圖的話,同一次渲染長出來的是這樣一棵樹:

一次渲染
  ├─ row-0  ┐
  ├─ ...    │ 讀到 0
  ├─ row-7  ┘
  │
  │   ← 排程器在這裡讓出執行緒,計時器趁這個空檔把值改成 1
  │
  ├─ row-8  ┐
  ├─ ...    │ 讀到 1
  └─ row-39 ┘

一份資料,一棵樹,兩個版本。這就是 Day 03 提過的現象:撕裂。

而且它沒有自己收斂。等了一百毫秒之後再看,前面那幾列還是 0。

撕裂為什麼很罕見

湊齊這兩個條件其實相當費工,費工到值得反過來驗一次:如果拿掉其中一個會怎麼樣?

把 BURN_MS 改成 0,讓每一列都畫得飛快,四十列全部印 1。因為渲染在計時器到點之前就結束了,那次改動根本沒有切口可以插進去。

把 startTransition 拿掉,改成直接 setMounted(true),四十列全部印 0。因為這次渲染變成不可中斷的一整塊,計時器只能排在後面等,等到它終於執行的時候,整棵樹早就畫完了。

兩個條件各拿掉一個,撕裂就都不見了。這件事本身是這一篇的重點:正因為它罕見到我從來沒有親眼遇過,我才會這麼安心地寫下那五行,並且用了四天都沒有想過要檢查它。

不過第二個實驗有一個地方不太對勁。

那個 1 去哪了

撕裂消失了沒錯,可是計時器明明把值改成了 1,畫面上四十列卻整整齊齊全都是 0。

問題出在訂閱的時機。useEffect 在畫面提交 (commit) 之後才執行,也就是說,這四十列是先讀了值、畫完、送上畫面,然後才去 subscribe 登記。而計時器在第 20 毫秒就寫了,那時候還沒有任何人在聽。

那一次變動於是誰也沒收到。

回頭看撕裂那個版本,前八列停在 0 的理由其實一模一樣:它們在自己那次讀取之後就沒有再對過答案。useNaiveStore 的 useEffect 只做了一件事,就是登記;它沒有在登記完成的當下回頭問一句「我剛剛讀到的,還算數嗎」。

所以那五行真正漏掉的是兩件事:

  • 值被抄了一份進狀態裡。抄的那一刻是渲染,而渲染有前後半段,同一批元件如果不是在同一個瞬間抄的,抄到的就可能不是同一個值。
  • 訂閱發生在提交之後。從抄值到登記之間有一段空窗,這段時間裡發生的變動沒有人會知道,而這個版本也從來沒有回頭對過一次。

第二件事讓第一件事的結果留在畫面上。撕裂之所以不會自己收斂,是因為沒有人負責去收斂它。

三個引數,各自回答一個問題

Day 03 曾經提過 React 18 的 useSyncExternalStore,當時只說了它為何而來。現在我們正好需要它。

同樣的 store,換成它來接:

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

它要的三個引數,各自回答一個問題:

  • subscribe —— 它變的時候,誰該被叫醒。
  • getSnapshot —— 在這一刻讀到的值是什麼。React 會在渲染中讀它,也會在提交之前再讀一次去對答案;對不上,就丟掉這次渲染的結果,改用同步的方式重畫一次。
  • getServerSnapshot —— 伺服器上沒有訂閱這回事,只有一個值。伺服器渲染的內容少了它,React 會發出警告,並退回改由客戶端重新渲染一次。

中間那一句「提交之前再讀一次去對答案」,就是 useNaiveStore 從來沒有做過的事。

把剛才那個 Row 裡的 useNaiveStore 換成它,其餘完全不動。同一批四十列,同一個在第 20 毫秒插隊的計時器,印出來全部是 1。連續跑六次都一樣。

row-0  1
row-1  1
 ...
row-39 1

值得注意的是它收斂到的是新值,不是把後半段拉回舊值。因為對答案這件事發生在提交之前,所以出事的那次渲染根本沒有機會上畫面,畫面上的那一棵樹是重畫出來的,從頭到尾只有一個版本。

回到今天開頭那個問題。換掉那五行,換到的東西並不是更快,也不是更少的程式碼,是不必去想它。

撕裂的視窗罕見到我可能這輩子都不會遇到;而正因為罕見,我才會安心地繼續寫那五行。這個 Hook 大概不是為了讓我多學一個 API 才存在的,它處理的是一個我原本不知道自己有的洞。

💡 這裡也可以看出為什麼 createStore 要把三個方法一次做好、之後身分不變。useSyncExternalStore 的引數只要有一個在每次渲染換一個身分,它就會重新訂閱一次。

現在它不會撕裂了

一致性這件事交給 React 之後,那個購物車數量在四十列元件之間終於是同一個版本。

不過剛才那四十列還有另一件事值得看:每一次 setState,四十列全部都被重新渲染了一次。可是畫面上真正變動的,其實只有一個數字。

這個裝置裡的四十列讀的是同一個值,重畫它們還說得過去。但真實的 store 通常裝著許多種值的一整包東西,而每個元件在乎的往往只是其中一小塊,當一個值改變,就可能讓與它無關的元件一起重新渲染。

我要怎麼告訴 React,我在乎的是哪一塊?

本文的實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-09-23。


上一篇
【 Day 07 】這兩種答案,各自把什麼藏了起來?|遠端世界(四完)
下一篇
【 Day 09 】 你怎麼告訴 React,你在乎哪一塊?|共享狀態(二)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言