在探索「遠端世界的複雜度」這個設計問題時,我們用來當作快取的 Map 始終活在 React 外面,而我讀它的方式,四天下來也都沒有變過:useEffect 掛上去訂閱,setState 把值搬進來。它一直都能動,所以這件事也就一直沒有被追究。
今天進入第二個設計問題:共享狀態的複雜度 —— 共享狀態變了,哪些人該知道?
不過在討論「哪些人」之前,還有一個更前面的問題:先前用的共享狀態設置,到底夠不夠?今天想把它單獨拿出來,放進一個看得見它出問題的場景裡。
遠端世界 ✓ · 共享狀態 ✍️ · 使用者輸入 · 瀏覽器 API · 互動行為 · 真實 DOM
要談「怎麼讀一個活在 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,畫面上四十列卻整整齊齊全都是 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,我在乎的是哪一塊?
本文的實驗跑在
react19.3.0 與react-dom19.3.0,查核於 2026-09-23。