iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Modern Web

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

【 Day 05 】這份資料什麼時候算舊,什麼時候該消失?

  • 分享至 

  • xImage
  •  

昨天我們把遠端資料搬到了元件外面的一個 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);
    },
  };
}

💡 ensurecancelGcrefetch 因為篇幅考量,這裡沒有實作出來,它們處理的事情如備註所述。

其實,STALE_TIMEGC_TIME 的概念分別來自 TanStack Query 的 staleTimegcTime。在該函式庫中,它們的預設值分別是 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 的另一種答案

SWR 這一側,這兩條線都沒有出現在文件裡。

它的快取預設是一個全域 Map,key 對到值。官方文件沒有描述任何自動回收或過期機制,要整個清掉的話,得自己呼叫 unload()(2.5.0 之後才有)。那份資料活多久,大致上等於這個應用開著多久。

「何時算舊」在那邊也不是一格資料身上的狀態。它有 revalidateIfStale(預設 true)、dedupingInterval(預設 2000 毫秒)、focusThrottleInterval(預設 5000 毫秒)這些設定,但它們回答的是「什麼時候再抓一次」,而不是「這一格現在算不算舊」。

所以它回答的是另一個問題:怎麼讓要同一個 key 的人拿到同一份資料。至於那份資料要活多久、什麼時候該重新拿,決定權留在應用端。

那兩條線沒有被畫出來,看起來比較像是那個位置上,它選擇不放東西。

時間推不動的那一種舊

那兩條線都由時間推動:一條數到就算舊,一條數到就刪掉。它們共同的前提是,我事先知道這份資料大概多久會不準。

而剛才那組實驗裡還有一件事:資料被標記成舊之後,它就停在那裡,沒有去重抓。它等的是下一個來讀的人。

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

過期是被動的,等的是下一次讀取。那麼,當我主動說「它已經不對了」的時候呢?

💡 本文是以 @tanstack/react-query 5.102.8、swr 2.5.1,為基礎撰寫,於 2026-08-31 檢閱;staleTimegcTime 與 SWR 各項設定的預設值複查於 2026-09-19。


上一篇
【 Day 04 】兩個元件要同一份遠端資料,這份資料是誰的?
下一篇
【 Day 06 】寫入之後,誰負責讓其他人知道?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言