iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

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

【 Day 10 】如果訂閱的單位不是切片,而是圖上的節點?|共享狀態(三)

  • 分享至 

  • xImage
  •  

昨天我們用選擇器告訴 React 元件在乎哪一塊,ThemeLabel 從此不再跟著購物車重新渲染。但 store 的通知方式沒有變:每一次寫入,每個選擇器還是會跑一次,而「跟我有沒有關」的判斷,要靠呼叫端先把在乎的那一塊寫成函式。

但有些值不是直接從 store 算出來,而是從另一個選擇器算出來的。

一旦選擇器開始依賴選擇器,store 還看得見發生了什麼嗎?

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

一個選擇器呼叫另一個選擇器

頁面上常有一些值不是直接存著的,而是從別的值算出來的。例如結帳區塊上的字:購物車有東西就寫「去結帳」,沒有就寫「購物車是空的」。而「購物車有沒有東西」本身,也是從購物車數量算出來的。

沿用昨天的 Shop、shopStore 與 useSlice,我們把這兩層各寫成一個選擇器,讓後者呼叫前者,並在兩個選擇器裡各放一行 console.log,記錄它們什麼時候被呼叫:

const selectHasItems = (shop: Shop): boolean => {
  console.log("selectHasItems 被呼叫");
  return shop.cartCount > 0;
};

const selectCheckoutLabel = (shop: Shop): string => {
  console.log("selectCheckoutLabel 被呼叫");
  return selectHasItems(shop) ? "去結帳" : "購物車是空的";
};

const CheckoutLabel = () => {
  const label = useSlice(shopStore, selectCheckoutLabel);
  console.log("CheckoutLabel 渲染");
  return <p>{label}</p>;
};

export const App = () => (
  <div>
    <CheckoutLabel />
    <button
      onClick={() => shopStore.setState((s) => ({ ...s, userName: "Leon" }))}
    >
      改名字
    </button>
    <button
      onClick={() =>
        shopStore.setState((s) => ({ ...s, cartCount: s.cartCount + 1 }))
      }
    >
      加一件
    </button>
  </div>
);

先按一次「加一件」,讓購物車裡有東西。接著再按一次,購物車從 1 變成 2,主控台印出:

selectCheckoutLabel 被呼叫
selectHasItems 被呼叫

CheckoutLabel 沒有重新渲染,代表昨天的切片仍然有效。但兩個選擇器還是跑了一遍。

然後,點擊「改名字」,我們會發現,主控台還是會印出這兩行。改名字跟購物車無關,它們照樣被呼叫;selectHasItems 算出來的仍然是 true,selectCheckoutLabel 還是接著把標籤算了一次。

那麼,明明中間那一層沒有變,為什麼後面那一層停不下來?

因為「selectCheckoutLabel 依賴 selectHasItems」這件事,只寫在它的函式本體裡。

store 看得到的是一整包 Shop 與一組聽眾,React 看得到的是 selectCheckoutLabel 交回來的那個字串。但選擇器裡面又呼叫了誰,這兩個地方都看不出來。

這不是選擇器跑太多次的問題,而是值與值之間已經有一張圖,只是這張圖寫在函式裡,store 看不見它。

如果把這張圖交給 store 保管,讓圖上的每個值各自成為一個可以被訂閱的單位,一次寫入能不能只沿著它,走到真的需要重算的地方?

把一個 store 拆成許多節點

要讓 store 看見這張圖,得先讓圖上的每個點都是 store 認得的東西。我們把這種點稱為節點 (atom)。

節點有兩種。帶一個初始值的是來源節點;帶一個函式的是衍生節點,它在函式裡用 get 讀了誰,誰就是它的依賴:

const cartCountAtom = atom(0);
const userNameAtom = atom("訪客");

const hasItemsAtom = atom((get) => get(cartCountAtom) > 0);

const checkoutLabelAtom = atom((get) =>
  get(hasItemsAtom) ? "去結帳" : "購物車是空的",
);

如果將其畫成圖,大概是這樣:

cartCountAtom
      │
      ▼
hasItemsAtom
      │
      ▼
checkoutLabelAtom

userNameAtom

atom 本身不存值,它只是一份描述:來源節點記著初始值,衍生節點記著那個函式。值由 store 保管。

接著是 store。我們在 Day 08 裡寫的 store 只保管一個值,所以讀、寫、訂閱都不必說明對象是誰。現在它要保管許多個值,於是每個動作都多指名一個節點:

// Day 08:一個 store,一個值
shopStore.getState();
shopStore.setState(next);
shopStore.subscribe(listener);

// 今天:一個 store,許多節點
store.get(checkoutLabelAtom);
store.set(cartCountAtom, next);
store.sub(checkoutLabelAtom, listener);

Day 08 那個 let state 與那一組 listeners,也跟著拆到每個節點身上。每個節點在 store 裡都有一份自己的紀錄。以 hasItemsAtom 為例,購物車從 0 變成 1 之後,它的紀錄長這樣:

hasItemsAtom
  value       true
  version     1
  deps        cartCountAtom(讀到的是第 1 版)
  dependents  checkoutLabelAtom
  listeners   (沒有人直接訂閱它)

value 與 listeners 是從地基拆下來的,一個是值,一個是聽眾。多出來的三格,是為了回答一個地基從來不需要回答的問題:寫了這個節點,還有誰跟著變?

  • version:值真的變了,才加一。hasItemsAtom 從 false 變成 true,所以是第 1 版。
  • deps:上一次算的時候讀了哪些節點,以及當時讀到的是第幾版。
  • dependents:反向的邊,記著誰上一次算的時候讀過我。

deps 往上看,看「我依賴誰」;dependents 往下看,看「誰依賴我」。

在程式碼裡,這份紀錄叫做 AtomState。store 以節點本身當鑰匙去查它:stateOf 負責查,查不到就回傳 null;createState 負責建一份新的。

後面的程式碼裡,節點的型別寫作 Atom,其中來源節點寫作 PrimitiveAtom。

算的時候,記下讀了誰

衍生節點的值由 compute 算出來。它交給 read 的 get 不只是讀值,還會記下讀了誰、當時是第幾版:

const compute = <T>(
  atom: Atom<T>,
  previous: AtomState<T> | null,
): AtomState<T> => {
  const deps = new Map<AnyAtom, number>();
  const value = atom.read((dep) => {
    const depState = readState(dep);
    deps.set(dep, depState.version);
    return depState.value;
  });

算完之後,依照這一次實際讀到的節點更新反向的邊,這一次沒再讀的拆掉,新讀到的補上:

  for (const dep of previous?.deps.keys() ?? []) {
    if (!deps.has(dep)) stateOf(dep)?.dependents.delete(atom);
  }
  for (const dep of deps.keys()) stateOf(dep)?.dependents.add(atom);

這裡沒有依賴陣列。圖長什麼樣,取決於上一次 read 實際 get 了哪幾個節點。

最後是版本號。值用 Object.is 比對過確實變了,版本才加一:

  if (previous === null) return createState(atom, value, deps);

  previous.deps = deps;
  if (!Object.is(previous.value, value)) {
    previous.value = value;
    previous.version += 1;
  }
  return previous;
};

有了版本號,讀一個衍生節點時,就可以先看它記下的每個依賴,版本是不是都跟當時一樣。一樣就沿用上一次的值,不一樣才重新算:

const isFresh = (state: AtomState<unknown>): boolean =>
  [...state.deps].every(([dep, version]) =>
    Object.is(readState(dep).version, version),
  );

const readState = <T>(atom: Atom<T>): AtomState<T> => {
  const state = stateOf(atom);

  if (isPrimitive(atom)) {
    return state ?? createState<T>(atom, atom.init as T, new Map());
  }

  if (state !== null && isFresh(state)) return state;
  return compute(atom, state);
};

isFresh 在比對版本之前,會先用 readState 把每個依賴讀到最新。所以一個節點重新算的時候,它讀到的依賴都已經是新的。

寫入之後,沿著反向的邊走

set 只收來源節點。衍生節點的值是算出來的,這份 store 不讓它被直接寫入。

set 的開頭跟 Day 08 的 setState 一樣,先寫來源節點,值沒變就直接回去:

const set = <T>(atom: PrimitiveAtom<T>, next: T | ((prev: T) => T)): void => {
  const state = readState(atom);
  const value = isUpdater(next) ? next(state.value) : next;
  if (Object.is(value, state.value)) return;
  state.value = value;
  state.version += 1;

接下來要找出這次寫入可能影響到誰。從來源節點出發,沿著 dependents 一路往下走,走得到的節點才有機會變:

const reachableDependents = (source: AnyAtom): Set<AnyAtom> => {
  const reached = new Set<AnyAtom>();
  const visit = (atom: AnyAtom): void => {
    for (const dependent of stateOf(atom)?.dependents ?? []) {
      if (reached.has(dependent)) continue;
      reached.add(dependent);
      visit(dependent);
    }
  };
  visit(source);
  return reached;
};

然後分三步:先把這些節點現在的版本全部記下來,再逐一讀過一次,最後只叫醒版本動了的那幾個節點的聽眾:

  const before = new Map<AnyAtom, number | null>();
  for (const dependent of reachableDependents(atom)) {
    before.set(dependent, stateOf(dependent)?.version ?? null);
  }

  const changed: AnyAtom[] = [atom];
  for (const [dependent, version] of before) {
    if (!Object.is(readState(dependent).version, version)) {
      changed.push(dependent);
    }
  }

  for (const changedAtom of changed) {
    for (const listener of [...(stateOf(changedAtom)?.listeners ?? [])])
      listener();
  }
};

「逐一讀過」用的就是剛才的 readState,依賴的版本都沒動,就不會重算。

最後是 sub。它跟 Day 08 的 subscribe 幾乎一樣,只是聽眾掛在節點上。它先讀一次,是因為反向的邊要在算的時候才會畫上去,沒算過的節點,傳播走不到它:

const sub = (atom: AnyAtom, listener: Listener): (() => void) => {
  const state = readState(atom);
  state.listeners.add(listener);
  return (): void => {
    state.listeners.delete(listener);
  };
};

💡 這裡的 createStore 是為了看清楚「傳播範圍由誰算」的實驗,不是 Jotai 的實作,也不是 production-ready 的實作。Jotai 只在有人訂閱、或被已訂閱節點依賴的節點之間維護反向的邊,其餘的衍生節點等到下次被讀時才重算;這裡算過的節點都留著邊。非同步的節點、節點之間的循環依賴,這裡都沒有處理。

同一組按鈕,換成節點

接上 React 的部分,跟 Day 08 的 useStore、昨天的 useSlice 是同一行 useSyncExternalStore:

import { useCallback, useSyncExternalStore } from "react";

export function useAtomValue<T>(store: Store, atom: Atom<T>): T {
  const subscribe = useCallback(
    (listener: Listener): (() => void) => store.sub(atom, listener),
    [store, atom],
  );
  const getSnapshot = useCallback((): T => store.get(atom), [store, atom]);

  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

把三天的那一行並排:

// Day 08:useStore
useSyncExternalStore(store.subscribe, store.getState, store.getState);
// Day 09:useSlice
useSyncExternalStore(store.subscribe, getSlice, getSlice);
// Day 10:useAtomValue
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

昨天改的是第二個引數:通知照舊發給每個人,每個人交回自己在乎的那一塊。今天改的是第一個引數:從一開始,就只聽一個節點。

用節點重寫開頭那兩層,同樣各放一行 console.log,再加一個讀使用者名稱的 Greeting:

const store = createStore();

const cartCountAtom = atom(0);
const userNameAtom = atom("訪客");

const hasItemsAtom = atom((get) => {
  console.log("hasItemsAtom 重算");
  return get(cartCountAtom) > 0;
});

const checkoutLabelAtom = atom((get) => {
  console.log("checkoutLabelAtom 重算");
  return get(hasItemsAtom) ? "去結帳" : "購物車是空的";
});

const Greeting = () => {
  const userName = useAtomValue(store, userNameAtom);
  console.log("Greeting 渲染");
  return <p>{userName},你好</p>;
};

const CheckoutLabel = () => {
  const label = useAtomValue(store, checkoutLabelAtom);
  console.log("CheckoutLabel 渲染");
  return <p>{label}</p>;
};

export const App = () => (
  <div>
    <Greeting />
    <CheckoutLabel />
    <button onClick={() => store.set(userNameAtom, "Leon")}>改名字</button>
    <button onClick={() => store.set(cartCountAtom, (c) => c + 1)}>
      加一件
    </button>
  </div>
);

按下「改名字」,主控台只印出一行:

Greeting 渲染

按下「加一件」,購物車從 0 變成 1:

hasItemsAtom 重算
checkoutLabelAtom 重算
CheckoutLabel 渲染

再按一次,購物車從 1 變成 2:

hasItemsAtom 重算

跟開頭的選擇器版本放在一起:

按鈕 選擇器版被呼叫的 節點版重算的
改名字 selectCheckoutLabel、selectHasItems 無
加一件(從 1 變成 2) selectCheckoutLabel、selectHasItems hasItemsAtom

改名字的時候,Greeting 訂閱的就是 userNameAtom 本身,而沒有任何衍生節點讀過它。從它出發沒有邊可以走,hasItemsAtom 與 checkoutLabelAtom 因此連算都沒有算。

那麼,購物車從 1 變成 2 的時候,checkoutLabelAtom 明明在同一條路上,為什麼沒有跟著重算?

hasItemsAtom 在路上,所以被重算了;但它算出來的仍然是 true,版本沒有加一。輪到 checkoutLabelAtom 時,它記下的依賴版本一個都沒動,isFresh 讓它直接沿用上一次的值。

失效 (invalidation) 沿著邊往下走,走到值沒變的節點就停下來。

傳播到哪裡,是由圖的形狀算出來的,而不是把每個訂閱者都問過一遍。

讓節點交回物件資料

昨天的 selectHeader 每次都組一個新物件,交給 useSlice 之後,元件一掛載就停不下來。同樣的形狀寫成衍生節點:

type Theme = "light" | "dark";

const themeAtom = atom<Theme>("light");

const headerAtom = atom((get): { cartCount: number; theme: Theme } => ({
  cartCount: get(cartCountAtom),
  theme: get(themeAtom),
}));

const Header = () => {
  const header = useAtomValue(store, headerAtom);
  console.log("Header 渲染");
  return (
    <p>
      {header.theme}:{header.cartCount}
    </p>
  );
};

把 App 裡的兩個元件換成 Header。掛載之後只渲染一次,沒有警告;按下「改名字」,主控台沒有新的輸出;按下「加一件」,印出一行:

Header 渲染

畫面上變成 light:1。

同一個形狀,這裡安定下來的原因在 getSnapshot。它交回的是 store 替 headerAtom 保管的那個值,而那個值只有在依賴的版本動了、重新算過之後,才會換成新物件。React 渲染時讀一次、提交前再讀一次,兩次拿到的是同一個物件。

「變了沒」在寫入的那一刻,已經由 store 在每個節點上用 Object.is 比過了。

不過,比較也就只有 Object.is 這一種。如果衍生節點依賴的值變了,算出來的卻是內容相同的新物件,store 還是會把它當成變了:

const badgeAtom = atom((get): { visible: boolean } => ({
  visible: get(cartCountAtom) > 0,
}));

const Badge = () => {
  const badge = useAtomValue(store, badgeAtom);
  console.log("Badge 渲染");
  return badge.visible ? <span>●</span> : null;
};

把 App 裡的元件換成 Badge,按兩次「加一件」。第二次購物車從 1 變成 2,visible 還是 true,但 badgeAtom 交回的是另一個物件,主控台印出:

Badge 渲染

在這份 store 裡,沒有地方放一個比 Object.is 寬鬆的比較函式。能動的是圖本身。把 badgeAtom 改成讀 hasItemsAtom,而不是直接讀 cartCountAtom:

const badgeAtom = atom((get): { visible: boolean } => ({
  visible: get(hasItemsAtom),
}));

購物車從 1 變成 2,hasItemsAtom 的值沒變,失效在它那裡就停了,badgeAtom 不會重算,Badge 也就不會重新渲染。

昨天,「什麼算變了」由呼叫端寫一個比較函式來回答。在這裡,它比較像是由節點切在哪裡來回答。

💡 Jotai 在 jotai/utils 裡提供了 selectAtom,可以替衍生出來的節點帶一個比較函式,預設值是 Object.is。這裡沒有臨摹它。

我第一版漏掉的一次通知

set 裡的第一步「先把版本全部記下來」,是後來才補上的。我第一版是走到哪個節點,才記它的版本、接著讀它。前面的例子都照常運作,直到遇上這樣一張圖:

const a = atom(1);
const p = atom((get) => get(a) + 1);
const c = atom((get) => get(a) * 2);
const d = atom((get) => get(p) + get(c));
    a
   / \
  p   c
   \ /
    d

三個衍生節點各被訂閱一次,然後把 a 從 1 改成 2。p、c、d 算出來的值都對,但 c 的聽眾一次都沒有被叫醒。

值是對的,通知是錯的。

問題出在走訪的順序。從 a 出發,走到的順序是 p、d、c。輪到 d 的時候,它要讀 c,readState 便當場把 c 重算,版本加一。等迴圈走到 c 才去記它的版本,記到的已經是新的那一個,比對下來就成了沒變。

也就是說,走訪的順序決定了一個節點在被記下版本之前,會不會先被別人順手算好。

當時我的測試全部是綠的,這個錯是在 code review 時才被抓出來。

改法有兩種:

  • 先記再算:在重算任何節點之前,先把走得到的節點的版本一次記下來,順序就不再影響判斷。這是現在的寫法。
  • 先排再算:Jotai 2.20.3 在寫入之後,會先把要重算的節點排成依賴在前、被依賴者在後的順序,也就是拓撲排序 (topological sort),再一個一個算。輪到任何一個節點時,它讀的依賴都已經算好了。

兩種改法回答的是同一個問題:一次寫入之後,store 要怎麼知道誰真的變了。

依賴仍然由我們寫下來

回到今天開頭的那兩層。選擇器版本裡,selectCheckoutLabel 依賴 selectHasItems,這件事只有函式本體知道,所以 store 每次都得問遍所有人。換成節點之後,store 看得見這張圖,寫入只沿著邊走,走到值沒變的節點就停。

不過,那張圖是誰畫的?

cartCountAtom、userNameAtom 是我們把 Shop 拆開來的;hasItemsAtom 讀 cartCountAtom、checkoutLabelAtom 讀 hasItemsAtom,是我們在 read 裡一個一個 get 出來的;讓 Badge 不再多渲染一次,也是我們把它改接到 hasItemsAtom 上。store 從頭到尾只是沿著我們畫好的邊走。

昨天,依賴寫在選擇器裡,寫成「我要這一塊」;今天,依賴寫在節點裡,寫成「我讀了這幾個」。

store 終於看得見依賴了。但依賴本身,還是我們親手告訴它的。可是,憑什麼?

本文是以 zustand 5.0.15、jotai 2.20.3、valtio 2.3.2 為基礎,於 2026-08-31 檢閱;Jotai 寫入之後的重算順序(recomputeInvalidatedAtoms)與 selectAtom 的預設比較,於 2026-09-25 查核;實驗跑在 react 19.3.0 與 react-dom 19.3.0,於 2026-09-25 進行。


上一篇
【 Day 09 】 你怎麼告訴 React,你在乎哪一塊?|共享狀態(二)
下一篇
【 Day 11 】依賴一定要被描述嗎?|共享狀態(四完)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言