昨天我們把遠端資料搬到了元件外面的一個 Map 上,讓兩個元件共用同一份,這個 Map 叫做快取。然而,它不知道什麼時候該刪去裡面的請求,因為它不知道還有沒有元件在用。那麼,要怎麼做呢?
遠端世界 ✍️ · 共享狀態 · 使用者輸入 · 瀏覽器 API · 互動行為 · 真實 DOM
也許,最直觀的作法是數人頭:元件掛上的時候加一,卸載的時候減一,減到零就把那一格刪掉。
為此,要存的資料變成一個物件,我們將它取做 Entry,除了原本就存的 Promise 之外,另外存一個 observers 用來觀測數量的數字:
type Entry = {
promise: Promise<unknown>;
observers: number;
};
然後把昨天的邏輯套上去,透過 key 去判斷有沒有先到的請求,有的話直接在 observers 加一;沒有的話,去 Map 領完位置再加一。
最後,多回傳一個 leave 方法來減掉 observers 的數量,供元件卸載時使用。
const shared = new Map<string, Entry>();
export function subscribe<T>(
key: string,
fetcher: () => Promise<T>,
): { promise: Promise<T>; leave: () => void } {
let entry = shared.get(key);
if (!entry) {
entry = { promise: fetcher(), observers: 0 };
shared.set(key, entry);
}
entry.observers += 1;
const current = entry;
return {
promise: current.promise as Promise<T>,
leave: (): void => {
current.observers -= 1;
if (current.observers === 0) {
shared.delete(key);
console.log(`[${key}] 從 shared 刪除`);
}
},
};
}
在呼叫端,在 useEffect 回傳的清理函式中呼叫 leave。
export function useTodos(): { todos: Todo[] | null; isLoading: boolean } {
const [todos, setTodos] = useState<Todo[] | null>(null);
useEffect(() => {
let ignore = false;
const { promise, leave } = subscribe("todos", fetchTodos);
promise.then((data) => {
if (!ignore) setTodos(data as Todo[]);
});
return () => {
ignore = true;
leave();
};
}, []);
return { todos, isLoading: todos === null };
}
按「隱藏待辦」讓兩個元件卸載,再按一次讓它們回來:
[0.0s] 首次掛載
送出一次請求
[0.0s] 隱藏待辦
[todos] 從 shared 刪除
[1.0s] 顯示待辦 - 重新掛載
送出一次請求
同樣的動作,昨天的 Map 不會重新發出請求,因為資料一直都在。
但現在不一樣了。
由於要這筆資料的最後一個元件卸載時,我們同時刪除了 Map 的資料,所以當它重新掛載時,必須重新抓取資料。也就是說,那一行 shared.delete(key) 同時決定兩件事:把資料刪除,也讓下一個要它的人必須重新抓一次。
最後一個元件離開時,回答的是:「現在還有沒有人需要這份資料?」
但刪掉它之後,事實上也同時回答了另一個問題:「下一次來的人,該不該重新抓?」
這不是資料刪得太快的問題,是**「這份資料還有沒有人要」與「這份資料還準不準」,被我用一個動作回答了**。
所以,這份資料什麼時候算舊,什麼時候該消失?
人數歸零的意思是「現在沒有人在看」,不見得是「以後也不會有人看」。既然如此,我就給它一段寬限期:當人數歸零時,啟動一個計時器,時間到才刪,而寬限期內有人回來,就把計時器收掉。
作法是這樣的,先在 Entry 裡多加一個計時器:
type Entry = {
promise: Promise<unknown>;
observers: number;
timer: ReturnType<typeof setTimeout> | null;
};
在 leave 刪除資料前,先啟動計時器,時間到才刪;另外,也要在 subscribe 裡,多檢查計時器有沒有殘留,有的話要清除。
entry.observers += 1;
if (entry.timer !== null) {
clearTimeout(entry.timer);
entry.timer = null;
}
// 元件卸載時呼叫
leave: (): void => {
current.observers -= 1;
if (current.observers === 0) {
current.timer = setTimeout(() => shared.delete(key), 5_000);
}
},
這次切走一秒再切回來,然後就讓畫面那樣開著:
[0.0s] 第一次顯示
送出一次請求
[0.0s] 隱藏待辦
[1.0s] 一秒後再次顯示
[601.0s] 然後就這樣開著,過了十分鐘
那一格還在,而且從頭到尾沒有再抓過
切走一秒再回來確實沒有重抓,這是我要的。可是畫面就那樣開著十分鐘,那份資料一次也沒有更新過:因為元件一直在,所以人數沒有歸零,計時器也就沒有啟動過。
那把計時器改成不看人數,資料一回來就開始跑呢?這樣放久了的資料會被丟掉,下一個要它的人自然會重抓。可是這麼一改,正在看著它的那兩個元件,手上的資料會在計時器到期的時候被撤走。
但仔細想想,我一直嘗試用這個計時器解決的,其實是兩個問題。
這兩個問題,分別是:
既然是兩個問題,那就分別給它們一個計時器,這兩個計時器彼此獨立,互相不影響,可各自設定一段時間:
const STALE_TIME = 3_000;
const GC_TIME = 5_000;
我們用 STALE_TIME 表示資料的有效期限,當資料回來,就開始計時,計時到了就標記過期:
const scheduleStale = (entry: Entry): void => {
entry.staleTimer = setTimeout(() => {
entry.isStale = true;
}, STALE_TIME);
};
另一方面,GC_TIME 則在人數歸零那刻啟動計時,計時到了就回收資料:
const scheduleGc = (key: string, entry: Entry): void => {
entry.gcTimer = setTimeout(() => shared.delete(key), GC_TIME);
};
進場與離場也跟著分成兩件事。進場的時候把回收的倒數收掉,順便看看手上這一格是不是已經舊了;離場的時候只把人數減掉,然後啟動倒數。
export function subscribe<T>(
key: string,
fetcher: () => Promise<T>,
): { promise: Promise<T>; leave: () => void } {
// ensure => 取出這一格,沒有就建立並送出第一次請求
const entry = ensure(key, fetcher);
entry.observers += 1;
// 取消 GC 排程
cancelGc(entry);
// 過期的資料就重新抓取
if (entry.isStale) refetch(entry, fetcher);
return {
promise: entry.promise as Promise<T>,
leave: (): void => {
entry.observers -= 1;
// 沒有人在看,啟動 GC 排程,不直接刪除
if (entry.observers === 0) scheduleGc(key, entry);
},
};
}
💡
ensure、cancelGc及refetch因為篇幅考量,這裡沒有實作出來,它們處理的事情如備註所述。
其實,STALE_TIME 及 GC_TIME 的概念分別來自 TanStack Query 的 staleTime、gcTime。在該函式庫中,它們的預設值分別是 0 與五分鐘,也就是說:資料一抓回來就算舊了,而沒有人在看之後,那一格還會留五分鐘。
至於 observers 的概念。TanStack Query 實際上保存的是 QueryObserver 陣列,陣列長度就是目前的 observer 數量。
現在,讓我們來看看使用最新版本之後的三個情境。
第一個情境:兩個元件掛上之後什麼都不做,等到資料變舊,再讓它們一起卸載。
情境一
[0.0s] 兩個元件掛上
送出一次請求
看的人 2 個 · 還是新的 · 資料還在
[3.1s] 什麼都沒做,只是等了三秒
看的人 2 個 · 已經舊了 · 資料還在
[3.1s] 兩個元件都卸載
看的人 0 個 · 已經舊了 · 資料還在
[8.2s] 再等五秒
那一格已經不在了
第二個情境反過來:資料才剛回來,就讓兩個元件卸載。
情境二
[0.0s] 兩個元件掛上
送出一次請求
看的人 2 個 · 還是新的 · 資料還在
[0.5s] 兩個元件都卸載
看的人 0 個 · 還是新的 · 資料還在
[5.6s] 再等五秒
那一格已經不在了
情境一裡,那一格在第三秒被標記成舊的,可是它還在,因為兩個元件還看著它。等到兩個元件都卸載,倒數才開始,第八秒它才消失。
情境二剛好相反:資料只放了半秒,還很新,可是沒有人在看,一樣走完五秒之後被刪掉。
舊的可以活著,新的也可以被回收。
第三個情境,則是把兩件事放在一起看:
情境三
[0.0s] 兩個元件掛上
送出一次請求
看的人 2 個 · 還是新的 · 資料還在
[0.5s] 兩個元件都卸載
[4.0s] 四秒後還沒有人回來
看的人 0 個 · 已經舊了 · 資料還在
[4.0s] 這時候再掛上一個元件
送出一次請求
看的人 1 個 · 還是新的 · 資料還在
第四秒的時候,那一格同時是「已經舊了」與「還在」:新鮮度的計時器到期了,回收的倒數還沒。而且沒有任何東西去重抓它,它就維持在舊的狀態。直到一個元件掛上來讀,那一次請求才送出。
把這些畫成圖的話,是兩條各走各的線:
新鮮度(由 staleTime 決定)
還是新的 ──→ 已經舊了
觀察狀態(由 observers 與 gcTime 決定)
有人在看 ──→ 沒有人在看 ──→ 刪掉
上面那條,從資料回來的那一刻開始倒數,與有沒有人在看無關;下面那條,由看的人數決定,與資料是新是舊無關。
兩條線交叉起來,一格資料一共會落在四個位置:
| 有人在看 | 沒有人在看 | |
|---|---|---|
| 還是新的 | 什麼也不會發生 | gcTime 到期就刪掉 |
| 已經舊了 | 留著,等下一個來讀的人重抓 | gcTime 到期就刪掉 |
我原本以為這兩件事是一條鏈:資料過期了,所以它被回收。但圖上那兩條線之間沒有先後,它們只是恰好作用在同一格資料上。左右兩欄決定這一格還在不在,上下兩排決定裡面那份資料還準不準,而一邊換了位置,另一邊不會跟著動。
SWR 這一側,這兩條線都沒有出現在文件裡。
它的快取預設是一個全域 Map,key 對到值。官方文件沒有描述任何自動回收或過期機制,要整個清掉的話,得自己呼叫 unload()(2.5.0 之後才有)。那份資料活多久,大致上等於這個應用開著多久。
「何時算舊」在那邊也不是一格資料身上的狀態。它有 revalidateIfStale(預設 true)、dedupingInterval(預設 2000 毫秒)、focusThrottleInterval(預設 5000 毫秒)這些設定,但它們回答的是「什麼時候再抓一次」,而不是「這一格現在算不算舊」。
所以它回答的是另一個問題:怎麼讓要同一個 key 的人拿到同一份資料。至於那份資料要活多久、什麼時候該重新拿,決定權留在應用端。
那兩條線沒有被畫出來,看起來比較像是那個位置上,它選擇不放東西。
那兩條線都由時間推動:一條數到就算舊,一條數到就刪掉。它們共同的前提是,我事先知道這份資料大概多久會不準。
而剛才那組實驗裡還有一件事:資料被標記成舊之後,它就停在那裡,沒有去重抓。它等的是下一個來讀的人。
可是有時候,我不是等它變舊的。比如我剛剛送出了一筆新的待辦,那一刻我就知道手上這份清單不對了。
過期是被動的,等的是下一次讀取。那麼,當我主動說「它已經不對了」的時候呢?
💡 本文是以
@tanstack/react-query5.102.8、swr2.5.1,為基礎撰寫,於 2026-08-31 檢閱;staleTime、gcTime與 SWR 各項設定的預設值複查於 2026-09-19。