iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Modern Web

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

【 Day 06 】寫入之後,誰負責讓其他人知道?

  • 分享至 

  • xImage
  •  

昨天我們把「舊了」與「該消失了」拆成兩條互不干涉的線,分別從「資料回來」與「沒人讀資料」開始計時,時間一到,就標記過期或刪除。

可是有時候我不希望等。比如我剛剛送出一筆新的待辦,那一刻我就知道手上這份清單已經不對了。

那麼,我要怎麼把這件事說出去?

遠端世界 ✍️ · 共享狀態 · 使用者輸入 · 瀏覽器 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 的另一種答案

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-query 5.102.8、swr 2.5.1 為基礎,於 2026-08-31 檢閱;invalidateQueries 的部分比對與預設 refetchType、SWR mutate 的 key filter 與重抓路徑複查於 2026-09-21。


上一篇
【 Day 05 】這份資料什麼時候算舊,什麼時候該消失?
下一篇
【 Day 07 】這兩種答案,各自把什麼藏了起來?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言