昨天我們把「舊了」與「該消失了」拆成兩條互不干涉的線,分別從「資料回來」與「沒人讀資料」開始計時,時間一到,就標記過期或刪除。
可是有時候我不希望等。比如我剛剛送出一筆新的待辦,那一刻我就知道手上這份清單已經不對了。
那麼,我要怎麼把這件事說出去?
遠端世界 ✍️ · 共享狀態 · 使用者輸入 · 瀏覽器 API · 互動行為 · 真實 DOM
首先,我們新增一個用來新增待辦的元件,點擊按鈕就往 /api/todos 送一筆新增待辦的請求:
const addTodo = async (title: string): Promise<void> => {
console.log("送出一次新增");
await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ title }),
});
};
const AddTodo = () => {
const [title, setTitle] = useState("");
return (
<form
onSubmit={async (event) => {
event.preventDefault();
await addTodo(title);
setTitle("");
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit">新增</button>
</form>
);
};
按下新增,主控台印出一行:
送出一次新增
然後,畫面上的待辦清單依然看不到任何的變化。這是因為 shared 裡那一格 "todos" 存的還是新增之前的清單。它沒有任何理由知道自己已經不準了 —— 昨天那兩個計時器,一個在數資料放了多久,一個在數沒有人看多久,都不會因為我送了一個 POST 而改變讀數。
那就我自己去動它,把 shared.delete(key) 加進提交事件的邏輯當中。這樣一來,新增待辦之後,就會把 "todos" 那格刪掉,下一個要它的人自然會重新抓:
await addTodo(title);
shared.delete("todos");
主控台還是只有那一行「送出一次新增」。
然而,就如昨天文內所說,shared.delete(key) 同時決定了兩件事:把資料刪除,也讓下一個要它的人必須重新抓一次。
這裡真正有作用的只有「讓下一個讀取者重新抓一次」這件事,但現在根本沒有下一個讀取者 —— 清單元件早就掛在那裡,資料在它掛載時就領走了。
昨天拆開的設計邊界,今天不能因為方便就重新混在一起。
不過,新鮮度那條線上已經有一個位置可以放這件事:標記成舊的。原本是計時到了才標記過期,這次換我主動來標記。
export function invalidate(key: string): void {
const entry = shared.get(key);
if (entry) entry.isStale = true;
}
主控台依然只有那一行。因為過期是被動的,它等的是下一個來讀的人,而現在,並沒有人來觸發更新畫面。
所以問題還是存在。我確實已經知道那份資料不對了,可是這件事要傳到哪裡、傳給誰,目前還沒有人回答。
這不是一次重抓的問題。
當我說「它不對了」的時候,第一個問題是:那句話要蓋住哪幾格?
而等我真的找到那些格子之後,第二個問題才是:哪些應該立刻動起來,哪些可以等之後再說?
寫入之後,又是誰負責讓其他人知道?
讓我們先看看要把資料「傳給誰」。
假使頁面上不只清單這一格。想像點進某一筆待辦會開詳情,那是另一種請求、另一個 key;而旁邊還有一份封存清單,以及一格跟待辦完全無關的使用者資料。
這裡有四種資料,分別有四個 key:"todos"(待辦清單)、"todos-1"(待辦詳情)、"todos-archive"(封存清單)、"users-1"(使用者詳情)。
新增一筆待辦之後,清單一定不對了。至於封存清單與那一筆詳情要不要跟著刷新,我可以自己拿捏;使用者資料則顯然不該被動到。
也就是說,我指的是「跟待辦有關的資料」,而我可以決定這些資料的範圍有多大,不是單一一筆。
那麼,key 是字串,字串上要表達「相關資料」的概念,以現有的 key 名稱來說,可以使用前綴比對:
export function invalidate(prefix: string): void {
for (const [hash, entry] of shared) {
if (hash.startsWith(prefix)) entry.isStale = true;
}
}
如此一來,invalidate("todos") 就能同時命中 "todos"、"todos-1" 以及 "todos-archive"。
雖然這樣沒有問題,也不會壞掉畫面。但我沒有選擇權:invalidate("todos") 會命中所有以 "todos" 開頭的 key,每次執行它,都可能多讓一份我沒有打算刷新的清單跟著重新請求,而我大概要等到某天覺得請求有點多,才會回頭看它。問題出在 "todos-1" 的那個 -:那是我自己約定的分隔符號,字串本身並不知道它在哪裡分段。
換成陣列就有分段了:["todos"]、["todos", 1]、["todos", "archive"]。
存進 Map 的時候還是需要一個字串當索引,所以序列化一次當作 hash,同時把原本的陣列留在 Entry 上,之後比對用得到:
type QueryKey = readonly unknown[];
const hashKey = (key: QueryKey): string => JSON.stringify(key);
比對則是沿著 Query Key 的結構往下比,由於有好幾段,就先要求前面的結構都符合;如果某一段本身還是物件,也會繼續往裡面做部分比對。
const hasPrefix = (key: QueryKey, prefix: QueryKey): boolean =>
prefix.length <= key.length &&
prefix.every(
(segment, index) => hashKey([segment]) === hashKey([key[index]]),
);
["todos"] 命中 ["todos", 1],因為第一段相同;不命中 ["users", 1]。而 ["todos", "archive"] 的第二段是 "archive",我要不要動它,由我在呼叫的時候決定給幾段,不是由字串長得像不像決定。
於是 invalidate 收的不再是一格的名字,是一個前綴:
export function invalidate(prefix: QueryKey): void {
for (const entry of shared.values()) {
if (!hasPrefix(entry.key, prefix)) continue;
entry.isStale = true;
}
}
回頭看,這個粒度其實是很早以前就決定好的。Day 04 把資料的所有權從元件搬到 Map 上的時候,順帶決定了「一份資料的身分是什麼」。當時它只是一個拿來索引的名字,看起來是實作細節。現在要說「這些資料不對了」,能指到多大的範圍,就取決於那個名字的形狀。
💡 這裡的比對只是臨摹 Tanstack Query 的最小實作版本,Tanstack Query 的 key 支援巢狀物件,比對是遞迴的。
能指到資料之後,然後要傳去哪裡?
現在 invalidate(["todos"]) 會把清單和詳情的資料都標記成舊的。主控台仍然沒有新的請求,因為標記只是在那一格上留一個記號,等下一個來讀的人。
可是清單元件此刻就掛在畫面上。它不會「下一次來讀」,它現在就在看。
那被命中的都立刻重抓呢?詳情那一格是關著的,沒有人在看,替它抓回來的資料多半會在有人打開之前就又過期一次。
「現在就抓」與「等下次」之間要有一個判斷,而昨天那個 observers 剛好就是這個判斷:
export function invalidate(prefix: QueryKey): void {
for (const entry of shared.values()) {
if (!hasPrefix(entry.key, prefix)) continue;
entry.isStale = true;
cancelStale(entry);
// 有人在看就立刻重抓,沒有人在看就只留下記號
if (entry.observers > 0) refetch(entry);
}
}
昨天數人頭是為了回答「什麼時候可以刪掉這一格」。今天同一個數字回答了另一個問題:「這一格現在要不要重抓」。
所以,一格資料有沒有人在看,同時決定了它的兩件事:它什麼時候消失,以及別人宣告它不對了之後,它是立刻去拿新的,還是先躺在那裡。
💡 這裡量到的都是請求層的動靜,畫面上那份資料是另一回事:昨天那個
useTodos只在掛載時讀一次promise,重抓回來的資料不會自己走到畫面上。要讓它走過去,subscribe得能反過來通知呼叫端,而那是另一個主題。
把三格資料擺在一起跑一次:清單有人在看,使用者資料有人在看,詳情開過又關掉。然後新增一筆待辦,invalidate(["todos"])。
[0.0s] 清單與使用者掛上,詳情開了又關
["todos"] 送出一次請求
["users",1] 送出一次請求
["todos",1] 送出一次請求
["todos"] 看的人 1 個 · 還是新的
["users",1] 看的人 1 個 · 還是新的
["todos",1] 看的人 0 個 · 還是新的
[0.5s] 新增一筆待辦之後,invalidate(["todos"])
["todos"] 送出一次請求
["todos"] 看的人 1 個 · 還是新的
["users",1] 看的人 1 個 · 還是新的
["todos",1] 看的人 0 個 · 已經舊了
[1.0s] 這時候再把詳情打開
["todos",1] 送出一次請求
["todos"] 看的人 1 個 · 還是新的
["users",1] 看的人 1 個 · 還是新的
["todos",1] 看的人 1 個 · 還是新的
["users", 1] 從頭到尾沒有動過,因為前綴沒有命中它。
["todos"] 在第 0.5 秒立刻送出請求,回來之後又變回新的。["todos", 1] 同樣被命中,但它只拿到一個記號,一直維持在「已經舊了」,直到第 1.0 秒有人打開詳情,那一次請求才送出。
同一次 invalidate,三格資料三種不同的下場。決定的不是那一格有多舊,是前綴有沒有指到它,以及指到之後有沒有人在看。
TanStack Query 的 invalidateQueries({ queryKey: ['todos'] }) 走的是同一條路。它的 key 也是陣列,比對也是逐段的部分比對,所以 ['todos', 1] 會被命中;而命中之後預設只重抓「還有觀察者掛著」的那些(refetchType 的預設值是 'active'),其餘的就留下一個已失效的記號等下次。
SWR 這一側,寫入之後用的是 mutate。
mutate(key) 接的是一個 key,序列化之後查那一格,是精確比對。要一次指到一群,第一個參數改傳一個篩選函式 (filter function),它會走訪快取裡所有的 key,把原本的 key 交給這個函式自己判斷:
const { mutate } = useSWRConfig();
await addTodo(title);
mutate((key) => Array.isArray(key) && key[0] === "todos");
兩邊都做得到「一次指到一群」,分歧在那個判斷寫在哪裡。前綴比對是快取端認得的規則,因為 key 的結構是它自己定的;篩選函式則是呼叫端每次自己寫的一段程式,快取只負責把每個 key 遞過去。
命中之後也不太一樣。SWR 沒有把「舊」做成那一格身上的狀態,所以它不留記號:mutate 會去找那個 key 上註冊的重抓函式,而那些函式是由掛載中的 useSWR 註冊、卸載時移除的。因此,那一刻沒有元件掛著,就沒有函式可以呼叫,這一次 mutate 對那一格也就沒有留下什麼。
看起來相近的地方是,兩邊都只讓「有人在看」的那些立刻重抓。差別在沒有人在看的那些:一邊留下一個記號,一邊沒有地方可以放記號。
而 SWR 那邊的元件重新掛載時,本來就會依 revalidateIfStale(預設 true)再抓一次。換句話說,那個記號在它的模型裡不是必要的。少了「舊」這個狀態,它同時也少了一個要維護的東西。
💡 樂觀更新:送出的同時先把新的那筆塞進畫面,失敗再退回去。Query 用
onMutate搭配setQueryData,SWR 用optimisticData搭配rollbackOnError,兩邊繞的是同一個問題:那份還沒被伺服器確認的資料,是否算數。
這三天問了三個問題:這份資料是誰的、它什麼時候算舊又什麼時候該消失、以及寫入之後誰負責讓其他人知道。
Tanstack Query 及 SWR 分別給出不同的答案。一邊把答案做成了快取裡的零件,另一邊把它留在應用端。
那麼,這兩份答案各自付出了什麼代價?
本文以
@tanstack/react-query5.102.8、swr2.5.1 為基礎,於 2026-08-31 檢閱;invalidateQueries的部分比對與預設refetchType、SWRmutate的 key filter 與重抓路徑複查於 2026-09-21。