過去三天,我們探索了三個問題,分別是:這份資料是誰的;它什麼時候算舊、什麼時候該消失;以及寫入之後誰負責讓其他人知道。
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 整整兩天的東西。
一樣的起手成本,不一樣的深度。而深度差在哪裡,前面那張表已經指過了:快取端有沒有一個生命週期的擁有者。
這件事本身不奇怪,那正是深模組的樣子。
奇怪的是另一件事。
那些被藏起來的零件,在介面上其實留了幾個名字:staleTime、gcTime。它們不是憑空多出來的設定項,是那兩條線露在外面的旋鈕。
而 Day 05 那個誤解就長在這裡:我把兩條線讀成了一條鏈,以為資料過期了所以被回收。因為模組替我維護著兩個我看不見的計時器,所以我要調它們,就得先在腦子裡把它們重新蓋出來一次。
所以深的那一側並不是免費的。它承接得多,而它承接的那些東西,會以概念的形式回到呼叫端身上。
兩個都是可以成立的答案。至於什麼時候值得付那個概念的代價、什麼時候不值得。我現在還沒有可以拿來量的東西,只有一個感覺:深不一定總是比較好。
這三天問的都是同一份資料:它屬於誰、什麼時候算舊又什麼時候該消失、不對了之後誰負責讓其他人知道。
不過,那個 Map 從 Day 04 開始就一直活在 React 外面,而我讀它的方式,到今天都沒有變過:useEffect 掛上去,setState 把值搬進來。它能動,這三天也就這樣過去了。
那麼,一個活在 React 外面的狀態,要讓元件讀到它,至少得做到什麼?
本文是以
@tanstack/react-query5.102.8、swr2.5.1 為基礎,於 2026-08-31 檢閱;而useQuery與useSWR的呼叫簽章則複查於 2026-09-22。