iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

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

【 Day 11 】依賴一定要被描述嗎?|共享狀態(四完)

  • 分享至 

  • xImage
  •  

過去兩天,不管是選擇器還是節點,我們都是先把依賴寫下來。store 不會自己知道誰在乎什麼,所以呼叫端需要先告訴它。

可是 Valtio 看起來不是這樣。

它既沒有使用選擇器,也沒有用上節點。如果呼叫端什麼都不寫,store 還有辦法知道誰該被通知嗎?

今天是探索共享狀態的最後一天,就讓我們透過 Valtio,來看看依賴是不是一定要被描述。

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

什麼都不寫的元件

沿用昨天那一家店,不過今天我們把狀態交給 proxy 管理。每次寫入就是一次賦值;元件用 useSnapshot 讀它,沒有選擇器,也沒有節點。兩個元件各放一行 console.log,記錄它們什麼時候重新渲染:

interface Shop {
  cartCount: number;
  theme: "light" | "dark";
  userName: string;
}

const shop = proxy<Shop>({ cartCount: 0, theme: "light", userName: "訪客" });

const CartBadge = () => {
  const snap = useSnapshot(shop);
  console.log("CartBadge 渲染");
  return <p>購物車:{snap.cartCount}</p>;
};

const Greeting = () => {
  const snap = useSnapshot(shop);
  console.log("Greeting 渲染");
  return <p>{snap.userName},你好</p>;
};

export const App = () => (
  <div>
    <CartBadge />
    <Greeting />
    <button
      onClick={() => {
        shop.cartCount += 1;
      }}
    >
      加一件
    </button>
    <button
      onClick={() => {
        shop.userName = "Leon";
      }}
    >
      改名字
    </button>
  </div>
);

按下「加一件」,主控台印出:

CartBadge 渲染

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

Greeting 渲染

結果跟 Day 09 的選擇器、Day 10 的節點一樣,沒有人被多叫醒一次。

差別在呼叫端。今天用的 useSnapshot(shop) Hook,在兩個元件的寫法都一樣,而不需要購物車資料的 Greeting 裡,並沒有任何一個地方寫著「我不在乎購物車」。

那麼,它是怎麼知道的?

讓我們回到這兩個元件上,CartBadge 最後只讀取 snap.cartCount,而 Greeting 則是只有讀 snap.userName。

如果「讀了哪裡」本身就是依賴,呼叫端就不必另外把它寫下來。

這樣一來,依賴不再是呼叫端交給 store 的一份描述,而是元件渲染時留下來的紀錄。

那麼,這份紀錄要怎麼留下來?又要在什麼時候拿出來比?

寫入:一次賦值,換一份快照

讓我們先看看寫入那一端。

前三天,寫入都要呼叫一個函式:setState、set。今天呼叫端只寫 shop.cartCount += 1,這是一行普通的賦值,store 要怎麼知道它發生了?

JavaScript 內建的 Proxy 做的就是這件事。new Proxy(target, handler) 會交回一個看起來跟 target 一樣的物件,但對它的每一次讀取與寫入,都會先經過 handler 裡的 get 與 set。我們可以先用一個最小的例子看它攔下了什麼:

const target = { cartCount: 0 };
const watched = new Proxy(target, {
  get(target, key) {
    console.log(`讀了 ${String(key)}`);
    return Reflect.get(target, key);
  },
  set(target, key, value) {
    console.log(`寫了 ${String(key)}:${value}`);
    return Reflect.set(target, key, value);
  },
});

watched.cartCount += 1;

執行之後,主控台印出:

讀了 cartCount
寫了 cartCount:1

一行 +=,其實是先讀一次、再寫一次,兩次都被攔下來了。呼叫端的寫法完全沒變,攔截發生在物件這一層。

用 Object.defineProperty 替每個欄位各定義一組 getter 與 setter,也能做到類似的事,但得事先知道有哪些欄位。Proxy 不必,它攔的是整個物件。

這一篇會用到它兩次:寫入那一端攔 set,讀取那一端攔 get。

先看攔 set 的那一個。proxy 底下是我們在 Day 08 寫的 createStore,一行都沒有改。它只做一件事:把攔下來的賦值,翻譯成一次 setState:

export function proxy<T extends object>(initialState: T): T {
  const store = createStore<T>({ ...initialState });

  const state = new Proxy(initialState, {
    get(_target, key) {
      return Reflect.get(store.getState(), key);
    },
    set(target, key, value) {
      if (!Object.hasOwn(target, key)) return false;
      store.setState((prev) =>
        Object.is(Reflect.get(prev, key), value)
          ? prev
          : { ...prev, [key]: value },
      );
      return true;
    },
  });

  stores.set(state, store);
  return state;
}

get 一律去讀 store 目前那一份狀態;set 則是每一次寫入都組一個新物件交給 setState,所以 getState() 交回的那一份,之後不會再被任何人修改。它是某一刻的狀態,被固定下來,也就是一份快照 (snapshot)。

直接改同一個物件,看起來比較省事。之所以每次都換一份,是因為接下來有三個地方,都需要「上一刻」還留著:

  • useSyncExternalStore 用 Object.is 比較 getSnapshot 交回的值,來決定要不要重新渲染。一直改同一個物件,身分永遠不變,React 看不出它變了。
  • Day 08 的撕裂,是同一次渲染讀到了兩個版本。快照不會再變,一次渲染只要拿著同一份,讀到的就是同一刻的值。
  • 等一下的 isChanged 要拿上一份與這一份逐欄比較。原地修改的話,上一份的值早就被蓋掉,沒有東西可以比。

只記一個版本號也不夠。版本號說得出「有東西變了」,卻說不出變的是哪一個欄位,而今天要的正是後者。

寫入相同的值時,set 交回原本那一份,地基的 setState 看到身分沒變,就不會通知聽眾。

最後一行把代理與它背後的 store 記進一個叫 stores 的 WeakMap,讀的那一端要靠它找回 store。

這一端完全不判斷誰該被通知。值變了,它就像 Day 08 一樣,通知所有訂閱它的元件。

讀取:讀到哪個欄位,就記下哪個欄位

所以判斷只能落在讀的那一端。

useSnapshot 交給元件的不是快照本身,而是包在快照外面的另一層代理。這次攔的是 get:元件渲染時每讀一個欄位,就記下一筆。

記下來的東西,放在一個型別叫 Affected 的 WeakMap 裡:

export type Affected = WeakMap<object, Set<PropertyKey>>;

鍵是一份快照,值是這份快照被讀過的欄位。後面說的「帳」,指的就是它。

鍵之所以是快照,是因為每一次寫入都會換一份新的,而等一下要問的是「上一份畫出來的快照,被讀過哪些欄位」。帳得跟著快照走。

用 WeakMap 而不用 Map,是因為舊快照會一份一份累積下來。Map 會一直拿著它的鍵,舊快照就會一直留在記憶體裡;WeakMap 不會,一份快照不再被任何人參考時,它連同帳上那一筆可以一起被垃圾回收,不必自己清。前面的 stores 用 WeakMap 也是同一個理由:代理沒有人用了,背後的 store 就跟著放掉。

track 負責包上這層代理,每讀一次,就往那一份快照的帳上加一個欄位:

export function track<T extends object>(snapshot: T, affected: Affected): T {
  return new Proxy(snapshot, {
    get(target, key) {
      const used = affected.get(target) ?? new Set<PropertyKey>();
      used.add(key);
      affected.set(target, used);
      return Reflect.get(target, key);
    },
  });
}

Greeting 渲染一次之後,它的帳大概是這樣:

affected
  第 1 份快照  →  { userName }

有了這份帳,下一次有寫入時,就可以只比上一份快照被讀過的那幾個欄位:

export function isChanged<T extends object>(
  prev: T,
  next: T,
  affected: Affected,
): boolean {
  if (Object.is(prev, next)) return false;
  const used = affected.get(prev);
  if (used === undefined) return true;
  return [...used].some(
    (key) => !Object.is(Reflect.get(prev, key), Reflect.get(next, key)),
  );
}

按下「加一件」之後,store 換成了第 2 份快照,cartCount 從 0 變成 1。可是 Greeting 的帳上只有 userName,兩份快照的 userName 都是「訪客」,所以對它來說,這次寫入沒有變。

一個欄位都沒讀過的快照無從比起,一律當成變了。

接上 React:還是同一行

接上 React 的部分,跟前三天一樣是一行 useSyncExternalStore:

import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";

export function useSnapshot<T extends object>(state: T): Readonly<T> {
  const store = storeOf(state);
  const affected = useMemo<Affected>(() => new WeakMap(), [state]);
  const lastSnapshot = useRef<T | null>(null);

  let inRender = true;
  const getSnapshot = (): T => {
    const next = store.getState();
    const last = lastSnapshot.current;
    if (!inRender && last !== null && !isChanged(last, next, affected)) {
      return last;
    }
    return next;
  };

  const snapshot = useSyncExternalStore(
    store.subscribe,
    getSnapshot,
    store.getState,
  );
  inRender = false;

  useLayoutEffect(() => {
    lastSnapshot.current = snapshot;
  });

  return track(snapshot, affected);
}

storeOf 就是從剛才那個 WeakMap 找回代理背後的 store。lastSnapshot 記的是上一份真的畫到畫面上的快照,所以在提交之後的 useLayoutEffect 裡才更新。

把這四天使用的 Hook 並排:

// 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);
// Day 11:useSnapshot
useSyncExternalStore(store.subscribe, getSnapshot, store.getState);

Day 09 改的是第二個引數,Day 10 改的是第一個。今天第一個引數沒有動,還是聽整個 store;改的又是第二個。

不過,Day 09 的第二個引數交回什麼,是呼叫端寫的選擇器決定的。今天的 getSnapshot 不收任何東西,它自己拿上一份快照的帳去比:被讀過的欄位都沒變,就交回上一份,React 看到同一個身分,便不會重新渲染。

💡 這裡的 proxy 與 useSnapshot 是為了看清楚「依賴由誰決定」的實驗,並不是 Valtio 的實作,也不是 production-ready 的實作。Valtio 預設會把同一輪的多次寫入收成一次通知;它的追蹤代理有快取,同一份快照包出來的代理身分不變;它也追蹤巢狀物件、in 與 Object.keys。這裡都沒有處理。

為什麼不在渲染中比較

getSnapshot 裡有一個條件還沒解釋:inRender。渲染中呼叫 getSnapshot 時,它不看帳,一律交回最新的快照;只有渲染之外,也就是寫入之後 React 來問「要不要重新渲染」的時候,才拿帳去比。

這個條件,我一開始照著 Valtio 的原始碼寫進來,然後逐一拿掉實作裡的判斷,看測試會不會轉紅,拿掉它的時候,測試仍然全綠。

既然帳上記著被讀過的欄位,渲染中拿來比一次,看起來也沒有什麼不對。那麼,它到底擋住了什麼?

我用一個會收合的面板,把每一次渲染畫出的內容都印出來。面板收合時只讀標題,展開時才讀內容:

const panel = proxy({ title: "標題", detail: "第一版" });

const Panel = ({ expanded }: { expanded: boolean }) => {
  const snap = useSnapshot(panel);
  const text = `${snap.title}/${expanded ? snap.detail : "收合"}`;
  console.log(text);
  return <p>{text}</p>;
};

export const App = () => {
  const [expanded, setExpanded] = useState(false);
  return (
    <div>
      <Panel expanded={expanded} />
      <button
        onClick={() => {
          panel.detail = "第二版";
        }}
      >
        改內容
      </button>
      <button onClick={() => setExpanded(true)}>展開</button>
    </div>
  );
};

掛載時印出「標題/收合」。按下「改內容」,主控台沒有新的輸出,因為收合時沒有讀 detail,這符合預期。

接著按下「展開」,拿掉 inRender 的版本印出:

標題/第一版
標題/第二版

detail 早就是「第二版」了,畫面卻先畫出一次「第一版」。

原因在帳是怎麼長出來的。上一份快照的帳上只有 title,因為收合時只讀了它。展開的那一次渲染,拿這份帳去比,title 沒變,於是交回舊快照;元件從舊快照讀到了「第一版」,也在這時才第一次把 detail 記進帳裡。提交之後,React 再問一次,這次帳上有 detail 了,一比發現變了,再畫一次。

這一次渲染會讀哪些欄位,要畫完才知道。

所以拿上一次的帳來判斷這一次渲染,等於用上一次的依賴回答這一次的問題。放回 inRender 之後,展開只印出一行:

標題/第二版

這也解釋了測試為什麼沒有轉紅。兩個版本最後停在畫面上的,都是「標題/第二版」,而我原本的那條測試只檢查最後的畫面。錯的那一次渲染夾在中間,提交之後馬上就被蓋掉了,要把每一次畫了什麼都記下來才看得到。

同一家店,三種答案

回到同一家店,把三天的讀數放在一起。選擇器與節點的那兩欄,是 Day 09 與 Day 10 量過的結果:

寫法 選擇器(Day 09) 節點(Day 10) 讀取軌跡(今天)
改購物車,只讀主題的元件 不渲染 不渲染 不渲染
每次組一個新物件(selectHeader 的形狀) 掛載時停不下來 安定 安定
讀「購物車有沒有東西」,購物車從 1 變成 2 不渲染 不渲染 重新渲染
呼叫端要寫的東西 選擇器(+比較函式) 節點 無

第二列的讀取軌跡版本,是把 selectHeader 那個物件改成在渲染裡組:

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

把 App 裡的兩個元件換成 Header。掛載之後只渲染一次,按下「改名字」沒有新的輸出,按下「加一件」印出一行「Header 渲染」,畫面變成 light:1。物件每次都是新的,但它不在 getSnapshot 裡,React 比的仍然是快照的身分。

第三列要看的是 Day 10 開頭那個結帳標籤:

const CheckoutLabel = () => {
  const snap = useSnapshot(shop);
  console.log("CheckoutLabel 渲染");
  return <p>{snap.cartCount > 0 ? "去結帳" : "購物車是空的"}</p>;
};

把 App 裡的元件換成 CheckoutLabel。先按一次「加一件」,再按一次,購物車從 1 變成 2,主控台印出:

CheckoutLabel 渲染

畫面上還是「去結帳」,它卻重新渲染了一次。選擇器與節點在這裡都停住了,這一版沒有。

那麼,同一件事,為什麼這一版停不下來?

因為 CheckoutLabel 讀的是 cartCount,帳上記的也是 cartCount,而 cartCount 確實從 1 變成了 2。至於元件在渲染裡從它算出了一個 true,那不在帳上。選擇器與節點比的,是算出來的那個值;這裡比的,是被讀過的欄位。

比較的單位,就是訂閱的單位。

第二列與第三列其實是同一件事的兩面。因為比較只落在欄位上,在渲染裡組新物件不會出事;也因為比較只落在欄位上,從欄位算出來的值沒變時,它沒有辦法停下來。

看不見的訂閱

既然依賴是渲染時留下的紀錄,那它會不會因為寫法不同而改變形狀?

先看兩個畫出一模一樣東西的元件。一個只解構出 cartCount,另一個把整份快照展開成一個新物件:

const Picked = () => {
  const { cartCount } = useSnapshot(shop);
  console.log("Picked 渲染");
  return <p>購物車:{cartCount}</p>;
};

const Spread = () => {
  const view = { ...useSnapshot(shop) };
  console.log("Spread 渲染");
  return <p>購物車:{view.cartCount}</p>;
};

把 App 裡的兩個元件換成 Picked 與 Spread,按下「改名字」,主控台印出:

Spread 渲染

展開會把每個欄位都讀過一遍,而每一次讀都被記進帳裡。兩個元件在畫面上的差別是零,訂閱卻差了兩個欄位。

再看另一種寫法:把快照傳給子元件,由子元件去讀內容。

const Detail = ({ snap }: { snap: { detail: string } }) => {
  console.log("Detail 渲染");
  return <p>{snap.detail}</p>;
};

const Card = () => {
  const snap = useSnapshot(panel);
  console.log("Card 渲染");
  return (
    <section>
      <h2>{snap.title}</h2>
      <Detail snap={snap} />
    </section>
  );
};

把收合面板那個 App 裡的 Panel 換成 Card,按下「改內容」,主控台印出:

Card 渲染
Detail 渲染

Card 自己只讀了 title,detail 一次都沒有出現在它的程式碼裡,被叫醒的卻是它。因為 Detail 讀的那一份代理是 Card 的 useSnapshot 交出去的,讀到的欄位也就記在 Card 的帳上。

這兩個例子裡,沒有哪一行程式碼寫錯。只是訂閱不在程式碼裡,而在渲染實際走過的那條路上;差一個解構的寫法、多傳一次 props,它就換了形狀。

選擇器版本的訂閱,寫在那個選擇器函式裡;節點版本的訂閱,寫在 read 裡 get 了誰。它們都可以指著某一行說「就是這裡」。讀取軌跡版本沒有那一行可以指。

Jotai 本身沒有用這一行

四天下來,我們的每一份臨摹都接在同一行 useSyncExternalStore 上。那麼,被臨摹的函式庫本身也是這樣接的嗎?

Jotai 預設的 useAtomValue 不是,2.20.3 與 3.0.0 都一樣。它在 useEffect 裡用 store.sub 掛上聽眾,再透過 useReducer 觸發重新渲染,形狀比較接近 Day 08 的 useNaiveStore。

只看到這裡,很容易讀成「它還沒有跟上」。但 3.0.0 發布時,另外公開了一個用 useSyncExternalStore 寫成的 useAtomValueRawSync,而遷移指南寫明,useAtomValue 仍然是預設,換用新的 Hook 不是遷移的必要步驟。

依照官方文件,兩條路守住的東西不一樣。useAtomValueRawSync 不會撕裂,也接得住掛載期間寫入的值,代價是更新一律同步渲染,用不上並行渲染 (concurrent rendering);預設的那一條保留了並行渲染,代價是初次渲染到訂閱完成之間的變動,可能晚一步才被接住。

Jotai 並不是排斥 useSyncExternalStore,而是在不同的一致性保證之間,探索該怎麼折衷。

三種答案,各自藏了什麼

把這四天的三份臨摹放在一起,它們在多數場景上的行為是一樣的:都不撕裂,也都只叫醒該叫醒的元件。差別不在做不做得到,在那件事寫在誰的程式碼裡:

判準 選擇器 節點 讀取軌跡
訂閱的單位 一個 store + 一個選擇器 一個節點 一次讀取
切片粒度 呼叫端 呼叫端 函式庫
傳播範圍 呼叫端 函式庫 函式庫
相等性比較 呼叫端 函式庫 函式庫

用 Day 02 的詞來說,三份答案藏起來的東西一份比一份多。

Zustand 那一側藏得最少:什麼算變了,要由呼叫端寫一個比較函式來回答。Jotai 那一側藏起了傳播的路徑:寫入之後會走到哪些節點,由圖算出來,呼叫端不必想。Valtio 那一側連「你依賴了什麼」都替你藏起來了。

藏得越多,呼叫端要寫的越少;而呼叫端要寫的越少,也就越難從程式碼讀出自己的訂閱長什麼樣。前面的 Spread 與 Card 就是這件事。

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

回頭看 Day 08 那個 createStore,一個值、一組聽眾、一個通知,連同型別在內三十多行。今天的 proxy 底下也是它,一行都沒有改。所以複雜度不在「把狀態放到 React 外面保管」這件事上。

這四天真正費力的地方,都跟時間有關。Day 08 的撕裂,是一次被切開的渲染剛好碰上一次寫入;今天的 inRender,是因為一次渲染會讀哪些欄位,要等它畫完才知道;Jotai 那兩條路的取捨,談的也是掛載前後那一段空檔,以及渲染能不能被切開。

我們在 Day 03 說過,渲染可以被切開,所以接進 React 的那個通路,還得負責一致性。這四天看到的,是同一件事被放大之後的樣子:

React 的渲染是有時間性的,而外部狀態沒有。

這是一致性的複雜度,不是儲存的複雜度。

也因此,深度不在 store 本身。三份臨摹的 store 都不難寫,難的是訂閱關係怎麼被建立、又怎麼被維護:選擇器每次通知都要重跑一次;節點之間的邊,每次重算都要依照實際讀到的節點重新畫;讀取軌跡的帳,每次渲染都要重新長一次,而且只能在渲染之外拿來比。

最後,還有一件事這四天沒有問過:這份狀態是誰的。三份臨摹都把狀態放在 React 外面的一個 store 裡,它從建立起就一直在,差別全在誰該被通知,所以 Day 04 到 Day 07 的那個問題,在這裡並不是爭點。

介面越來越窄

再把呼叫端要交出去的東西排一次:Day 09 是一個選擇器函式,必要時再包一層 useShallow;Day 10 是一組節點;今天什麼都不用寫。

用 Day 02 的詞來說,這比較像是介面寬度一路遞減。而介面後面承接的東西,從一次 Object.is,到一張會自己重算的圖,再到一份每次渲染重新長出來的帳,則是一路遞增。

Day 07 的兩份答案,介面寬度相近,深度不同;這裡的三份答案,是介面越來越窄,深度越來越深。

可是承接得越多,就越難從程式碼看出它替我做了什麼決定。

共享狀態吿一段落

這四天問的都是同一件事:共享狀態變了,哪些人該知道。答案從呼叫端親手寫下依賴,一路走到讀過就算。

不過,這四天裡的每一次寫入,都是按一下按鈕。

如果狀態在每一次擊鍵都會改變呢?

本文以 zustand 5.0.15、jotai 2.20.3、valtio 2.3.2 為基礎,於 2026-08-31 檢閱;Valtio 的 useSnapshot 複查於 2026-09-26,它依賴的 proxy-compare 3.0.1 查核於 2026-09-25;Jotai 3.0.0 的 useAtomValue、useAtomValueRawSync 與遷移指南查核於 2026-09-26;實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-09-26。


上一篇
【 Day 10 】如果訂閱的單位不是切片,而是圖上的節點?|共享狀態(三)
下一篇
【 Day 12 】每次擊鍵,整份表單都得重算一次嗎?|使用者輸入(一)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言