iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Modern Web

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

【 Day 07 】這兩種答案,各自把什麼藏了起來?

  • 分享至 

  • xImage
  •  

過去三天,我們探索了三個問題,分別是:這份資料是誰的;它什麼時候算舊、什麼時候該消失;以及寫入之後誰負責讓其他人知道。

TanStack Query 及 SWR 分別給出不同的答案:一邊把生命週期做成快取裡的零件,另一邊把它留在應用端。

今天是探索遠端世界的最後一天,讓我們透過測試來比較兩邊的作法,以及分別付出什麼代價。

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

同一組斷言,兩個變體

我把這三天的過程中,遇到的關鍵提問寫成測試用的斷言。這並不是想驗證「哪個實作比較對」,而是想了解「遠端資料的生命週期,有哪幾件事需要有人負責」。

全部的斷言如下方表格所見,共有五組,然後用兩個變體去跑:一個是 Day 04 那個十幾行的最小全域 Map;另一個是隨著 Day 05、Day 06 一路長出來的最後版本,也就是人數加上兩個計時器,再加上結構化的 key。

跑完之後,結果是這樣:

斷言 最小全域 Map 所有權模型版
兩個消費者,一份資料 🟢 🟢
人數歸零之後,這一格才開始倒數 🔴 🟢
新鮮度與觀察狀態是兩條線,互不干涉 🔴 🟢
invalidate 命中的是前綴,不是單一 key 🔴 🟢
有沒有人在看,決定 invalidate 之後是否立刻重抓 🔴 🟢

先看第一列。這是最小全域 Map 唯一綠掉的一條,它確實能夠滿足最初的需求,把兩次請求收斂成一次,證明它並不是做壞的實作。

它回答了一個問題,然後停在那裡。

至於另外四條為什麼紅,讓我們先看看它們分別在測什麼。

五條斷言分別在測什麼

首先,先解釋一下這五條共用的三樣東西。

第一樣是一個會自己計數的抓取函式,用來回答「這一段過程總共送出了幾次請求」:

const countingFetcher = <T>(
  value: T,
): { fetcher: () => Promise<T>; calls: number } => {
  let calls = 0;
  return {
    fetcher: async (): Promise<T> => {
      calls += 1;
      return value;
    },
    get calls(): number {
      return calls;
    },
  };
};

第二樣是 peek,它讀一格資料的現況而不動到它:有幾個人在看、是不是已經舊了。那一格要是已經被回收掉,它回傳 null

第三樣是假的計時器。我們有數一秒、數五秒的兩個計時器,但測試不能真的坐在那裡等,所以時間是用 advance 推的。

另外還有一個 flush,它推零毫秒,主要的作用在於,讓事件迴圈先跑一輪,把目前已經到期或已經排隊等待執行的工作處理完,但不要讓測試裡的虛擬時鐘往前走。

const advance = (ms: number): Promise<void> =>
  vi.advanceTimersByTimeAsync(ms).then(() => {});

const flush = (): Promise<void> => advance(0);

第一條:兩個消費者,一份資料

同一個 key 讀兩次,兩邊要拿到同一份值,而請求只能送出一次:

const first = cache.read(["todos", 1], todo.fetcher);
const second = cache.read(["todos", 1], todo.fetcher);
await flush();
expect(todo.calls).toBe(1);

接著把時間推過 STALE_TIME 讓那一格變舊,再讓兩個消費者同時來讀一次。這時候可以重抓,但不該又變成兩個請求。

這一條兩邊都綠 —— Day 04 那個 Map 就是停在這裡。

第二條:人數歸零之後,這一格才開始倒數

訂閱兩次,人數是 2;退掉一個,剩 1,這時把時間推到回收時限的兩倍,那一格必須還在,因為還有人看著它。等第二個也退掉,倒數才開始:

leave[1]?.();
await advance(GC_TIME - 1);
expect(cache.peek(["todos", 1])).not.toBeNull();
await advance(2);
expect(cache.peek(["todos", 1])).toBeNull();

差一毫秒的時候還在,跨過去就不在了。

左邊那一欄紅在這裡,理由是沒有人被數過:那個版本沒有人數,也就沒有「歸零」這一刻,也就沒有東西可以觸發回收。

第三條:新鮮度與觀察狀態是兩條線,互不干涉

這一條擺了兩格資料,往相反的方向為難它們。

第一格有人看著。推過 STALE_TIME 之後,它要同時滿足「已經舊了」與「還在」:

await advance(STALE_TIME + 1);
expect(cache.peek(["todos", 1])?.isStale).toBe(true);
expect(cache.peek(["todos", 1])).not.toBeNull();

第二格反過來,訂閱完立刻退掉。它很新,可是沒有人在看,所以它照樣得走完回收的倒數;而倒數途中要是有人回來,倒數就得取消,之後它變舊了也還要在。

假如那兩條線是一條鏈,第一格會在變舊之後被刪掉,第二格會因為還新而躲過倒數。這一條要的,就是兩件事都不發生。

左邊那一欄的理由是連一條線都稱不上:資料進來之後既不會變舊,也不會消失。

第四條:invalidate 命中的是前綴,不是單一 key

先讀進 ["todos", 1]["todos", 2]["users", 1] 三格,然後只對 ["todos"] 出手:

cache.invalidate(["todos"]);
expect(cache.peek(["todos", 1])?.isStale).toBe(true);
expect(cache.peek(["todos", 2])?.isStale).toBe(true);
expect(cache.peek(["users", 1])?.isStale).toBe(false);

最後那個 false 和前面兩個 true 一樣重要。一次指到一個範圍的資料,前提是它也得指得住邊界。

左邊那一欄的理由是 key 被壓成字串:「範圍」這個概念在那個版本裡不存在,invalidate 只能精確刪掉一格。

第五條:有沒有人在看,決定 invalidate 之後是否立刻重抓

這次擺兩格:一格有人訂閱著,一格只是被讀過,沒有人掛在上面。兩格都 invalidate 掉,然後數請求次數:

cache.invalidate(["todos"]);
cache.invalidate(["users"]);
await flush();
expect(watched.calls).toBe(2);
expect(lonely.calls).toBe(1);

有人看著的那格立刻重抓,次數變成 2;沒有人看的那格停在 1,它只拿到一個記號。要等到下一個人來讀它,那一次請求才會送出。

左邊那一欄的理由是不知道有沒有人在看:就沒有東西可以拿來決定 invalidate 之後要不要立刻重抓。

紅的四條項目其實是同一件事

我原本以為這四條是四個不同的缺口,補起來要做四件事。

可是把那四句紅的理由抽出來並排 —— 沒有人被數過、連一條線都稱不上、key 被壓成字串、不知道有沒有人在看,它們其實是同一句話的四個面向:這個版本有快取,但快取沒有擁有者。

沒有人數,是因為沒有人在記帳;沒有兩條線,是因為沒有人在計時;key 沒有分段,是因為沒有人需要一次指到一群;不知道有沒有人在看,還是因為沒有人在記帳。

所以 Day 05 與 Day 06 的那些零件不是四個各自獨立的功能,它們是同一個決定的下游後果:有人得擁有這份資料的生命週期。

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

回頭看 Day 04 那個入口:兩個元件、兩次 fetch、兩份狀態。把它換成一個 Map 之後,請求確實只剩一次,而問題並沒有結束。

也就是說,複雜的不是抓資料。抓資料是一次 fetch 加一個 await,Day 04 開頭那十幾行就做完了。

複雜度是從後面兩個條件同時成立的時候長出來的:同一份資料有多個消費者,而且它的生命週期不能再跟著任何一個消費者一起走。

少掉任何一個,這三天的問題都不會成立。只有一個消費者,那一份資料的生死跟著它走就好;生命週期真的跟著消費者走,抓一次放著也就夠了。

這是協調的複雜度,不是輸入輸出的複雜度。

停在第一項,也是一個答案

按敘事的順序,這三天是從小的版本走向大的版本,所以那個十幾行的 Map 一直站在「起點」的位置上。

而 SWR 的快取,預設就是那樣的一個全域 Map

被當成起點,聽起來就像它還沒走完。我想把這件事講清楚:它不是一個未完成的版本,它是一個停在那裡的決定。

用 Day 02 的詞來說,兩邊藏起來的東西不一樣多。

所有權模型那一側藏起了人數、兩個計時器、還有 key 要怎麼分段比對。這些東西呼叫端看不到,可是它們一直在跑。

SWR 藏得少。「這份資料還有沒有人要」與「它什麼時候該消失」這兩個問題,它沒有在快取裡準備位置,那份責任留在應用端。

換回來的是更少要學的東西:沒有人數要理解,沒有兩條線要分辨,也沒有結構化 key 的分段規則要記。

而且它不是靠少答一題硬撐過去的。昨天提過,那一側的元件重新掛載時,本來就會依 revalidateIfStale 再抓一次。換句話說,「舊」這個記號在它的模型裡不是必要的。少了這個狀態,它同時也少了一個要維護的東西。

兩者給呼叫端的「介面寬度」差不多

那麼,把兩邊的介面擺在一起呢?

要用起來,Query 那一側是給一個 key、給一個抓取函式:

useQuery({ queryKey: ["todos"], queryFn: fetchTodos });

SWR 這一側同樣是一個 key、一個抓取函式,只是寫成位置參數:

useSWR("todos", fetchTodos);

兩邊回來的也都是一組同型的東西:資料、錯誤、載入中。

所以,開始使用它們所必須知道的事情是差不多的,也就是介面寬度相近。可是介面後面承接的,差了 Day 05 與 Day 06 整整兩天的東西。

一樣的起手成本,不一樣的深度。而深度差在哪裡,前面那張表已經指過了:快取端有沒有一個生命週期的擁有者。

這件事本身不奇怪,那正是深模組的樣子。

奇怪的是另一件事。

那些被藏起來的零件,在介面上其實留了幾個名字:staleTimegcTime。它們不是憑空多出來的設定項,是那兩條線露在外面的旋鈕。

而 Day 05 那個誤解就長在這裡:我把兩條線讀成了一條鏈,以為資料過期了所以被回收。因為模組替我維護著兩個我看不見的計時器,所以我要調它們,就得先在腦子裡把它們重新蓋出來一次。

所以深的那一側並不是免費的。它承接得多,而它承接的那些東西,會以概念的形式回到呼叫端身上。

兩個都是可以成立的答案。至於什麼時候值得付那個概念的代價、什麼時候不值得。我現在還沒有可以拿來量的東西,只有一個感覺:深不一定總是比較好。

遠端資料的生命週期吿一段落

這三天問的都是同一份資料:它屬於誰、什麼時候算舊又什麼時候該消失、不對了之後誰負責讓其他人知道。

不過,那個 Map 從 Day 04 開始就一直活在 React 外面,而我讀它的方式,到今天都沒有變過:useEffect 掛上去,setState 把值搬進來。它能動,這三天也就這樣過去了。

那麼,一個活在 React 外面的狀態,要讓元件讀到它,至少得做到什麼?

本文是以 @tanstack/react-query 5.102.8、swr 2.5.1 為基礎,於 2026-08-31 檢閱;而 useQueryuseSWR 的呼叫簽章則複查於 2026-09-22。


上一篇
【 Day 06 】寫入之後,誰負責讓其他人知道?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言