iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
JavaScript

30 天新世代 JavaScript 自我學習指南系列 第 29 篇

Day 29|誰該擁有 AbortController?從 TanStack Query 看非同步工作的生命週期

  • 分享至 

  • xImage
  •  

摘要

取消要分兩個角色:判斷「還要不要」的一方持有 AbortController,負責喊停;做事的一方只拿 signal,交給 fetch 這類會停下來的 API。用 TanStack Query 時,函式庫負責判斷,每輪讀取準備新的 controller、不需要時喊停;queryFn 只要把 signal 一路傳下去,沒傳到底,請求就停不下來。

https://ithelp.ithome.com.tw/upload/images/20261003/20145251UwNf6rSzm2.png


今日學習目標

  1. 判斷取消權該放在哪一層:誰持有 AbortController,誰只接收 signal。
  2. 分清 Query、fetch flow 與 queryFn 三種生命週期,說明為什麼同一輪 retry 共用同一個 signal。
  3. 說明 TanStack Query 怎麼透過 Retryer 與 onCancel 把取消通知送到 queryFn,以及 signal 沒傳到底時請求為什麼停不下來。
  4. 判斷常見情境會不會取消:元件卸載、query key 改變、多個元件共用同一個 Query、資料變 stale。
  5. 分清 debounce、deduplication 與 cancellation 各自解決的問題。

我們已經看過 AbortController 發出取消通知、signal 把通知傳給執行工作的 API;也學到用 AbortSignal.timeout()、AbortSignal.any() 組合停止條件,並處理取消後的錯誤。

但到目前為止,controller 都是我們自己建立的,放進實際專案後,會遇到一個更偏架構的問題:

當使用者切換畫面、改變搜尋條件,或重新整理資料時,究竟誰應該判斷舊工作已經不需要?

今天以 TanStack Query 為例,看看「取消權」如何從管理資料生命週期的上層,一路傳到真正執行 request 的地方。


一、把取消權與非同步工作本身分開

假設我們把需要非同步讀取資料的工作寫成一個函式:

async function getTodos(signal: AbortSignal) {
  const response = await fetch('/api/todos', { signal });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

const controller = new AbortController();
const task = getTodos(controller.signal);

// 示範取消通知。
// 實務上可能由按鈕、畫面清理或其他 lifecycle 觸發。
controller.abort();

task.catch((error) => {
  console.log('工作未完成:', error);
});

這裡的責任分工其實很清楚。

呼叫端知道「這個工作現在還需不需要」,所以它持有 AbortController。

getTodos() 不需要知道為什麼取消,它只負責:

接收 signal
    ↓
把 signal 傳給真正執行工作的 fetch

因此可以得到本文第一個核心原則:

讓管理工作生命週期的一方持有取消權;讓工作函式接收取消訊號。

真正負責工作函式通常不需要拿到整個 controller,拿到 controller 就等於拿到「替別人決定停止」的能力,而這不是它該負的責任,也是之前學到 AbortController 的使用方式。


二、TanStack Query 幫我們接上這個分工

使用 TanStack Query 時,可以繼續沿用剛才的模式:

const todosQuery = useQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) => getTodos(signal),
});

這次我們不用自己手動建立 new AbortController(),而是由 TanStack Query 提供 signal,再把它繼續往下傳。

這就帶出一個問題:controller 改由函式庫建立,那它是在什麼時候建立的?

手寫模式的答案很單純:呼叫一次 getTodos() 就是一份工作,呼叫前 new 一個 controller 就好。

但 TanStack Query 管理的單位不是「一次呼叫」,而是「一份資料」,也就是 ['todos'] 這個 Query。

這份資料可能在畫面上存在很久,期間可能會被重新讀取好幾次:

  • 第一次載入
  • 切回視窗時自動更新
  • 使用者手動重新整理……

每一次真正去拿資料,Query 都會開始一輪讀取工作,也有不同狀況需要考量:開始執行,失敗了可能重試,最後成功、失敗或被取消。

可以這樣想像:TanStack Query 判斷「這份資料需要重新讀取」之後,並不是直接呼叫 queryFn。中間還有一段準備工作:先建立這一輪要用的 AbortController,再交給 Retryer 負責執行、失敗重試與取消,最後才由 Retryer 呼叫 queryFn。

這一整輪讀取工作流程稱為 fetch flow:

從 Query.fetch() 開始準備,經過一次或多次 queryFn,到這輪工作成功、失敗或被取消為止,controller 就是在這段準備工作裡建立的:每一輪 fetch flow 一個。

controller 就是在這裡建立的:每一輪 fetch flow 一個。

有了 fetch flow 這一層概念,就可以把兩種寫法的分工對應起來:

責任 手寫模式 TanStack Query
判斷資料工作何時需要開始 呼叫端 Query/Observer 等管理流程
管理一輪資料取得工作 呼叫端自行處理 Query 的 fetch flow
管理 retry/cancel 自行實作 Retryer
持有取消入口 呼叫端 fetch flow 中建立的 AbortController
執行實際資料取得 getTodos() queryFn/service
真正停止 HTTP request fetch fetch

整理起來,會得到一個很重要的分層: 生命週期比較長的 Query 在上面,每次讀取才出現的 fetch flow 在中間,真正執行讀取的 queryFn 在下面。

Query
  ↓
fetch flow
  ↓
queryFn
  ↓
network request

三、Query lifecycle 是誰在管理?

剛開始接觸 Tanstack Query 時的角度會為說只是 「一份資料」 ,但它其實 Query 做的事情滿多的,不只是一個存放 data 的變數。

以 ['todos'] 為例,Query 除了資料本身,還會記錄這份資料的各種狀態:

記錄的事 例子 用來判斷什麼
資料與錯誤 data、error、status 畫面要顯示資料、載入中,還是錯誤
資料新不新 dataUpdatedAt 搭配 staleTime 設定 需不需要重新讀取
現在有沒有在讀 fetchStatus 能不能共用進行中的工作
誰在使用 observers 最後一個使用者離開時要不要取消
沒人用之後留多久 gcTime(預設 5 分鐘) 什麼時候從 cache 移除

所以 Query 本身也有一段生命週期:

  • 第一次被使用時建立並放進 cache;
  • 有 component 訂閱時,資料會隨著一輪輪 fetch flow 更新;
  • 所有 component 都離開後,再等 gcTime 過去,才從 cache 移除。

這麼多事情,是誰在管理?

TanStack Query 裡並沒有一個單獨叫做 LifecycleManager 的東西。我們看到的 lifecycle,其實是幾個角色合作的結果。

角色 主要責任
QueryCache 保存與查找 Query
Query 保存資料、錯誤與 fetch state,協調 fetch 流程
QueryObserver 訂閱 Query,把狀態整理成 component 使用的結果
Retryer 管理一輪 fetch flow 的執行、retry、pause 與 cancel
AbortController 提供這一輪工作的取消入口

接著要先分清楚兩個生命週期。

Query 的生命週期

Query 代表的是「這一份資料」,例如 ['todos']。

它可以在一次 request 結束後繼續存在於 cache,所以「request 結束」不等於「Query 立即消失」;
開頭表格裡的 資料、成功失敗等狀態與 observers,也都跟著 Query 一起留下來。

一次資料取得工作的生命週期

真正需要重新取得資料時,Query 才會開始一輪 fetch flow:

Query
│
├─ cached data
├─ state
├─ observers
│
└─ 需要取得資料
      ↓
   fetch flow

所以在 Tankstack Query 就能夠區分兩個不同維度的流程:

Query 是較長生命週期的資料管理單位;fetch flow 則是一輪資料取得工作的生命週期。


四、從 fetch flow 角度出發:開始、重試到取消

TanStack Query 不會直接呼叫 queryFn,中間還有一輪 fetch flow,打開來看裡面有三個角色:

Query.fetch()
  ↓
一輪 fetch flow
  ├─ AbortController:這一輪的取消入口,產生 signal
  ├─ Retryer:執行 queryFn,失敗就重試,需要時取消
  └─ queryFn({ signal }):真正去拿資料
        ↓
      fetch()

可以把它想成一個小團隊:

  • Retryer 是現場負責人,決定什麼時候做、失敗要不要再做一次;
  • controller 是負責人手上的停止按鈕;
  • queryFn 是實際動手的人,也通常是我們自己定義資料怎麼拉取的這一段,只管「這一次怎麼拿到資料」。

controller 屬於這一輪,不是整個 Query 共用。

Query 可能存在很久,但 signal 一旦 abort 就無法恢復。如果整個 Query 共用一個 controller,取消過一次之後,往後每次讀取都會一開始就失敗。所以每一輪 fetch flow 各自建立一個。

重試不會開新的一輪。

設定 retry: 2 時,queryFn 最多會被呼叫三次,但三次都在同一輪裡,由同一個 Retryer 重試,拿到的也是同一個 signal。原始碼裡,Query.fetch() 只建立一次 AbortController,重試只是重新呼叫交給 Retryer 的那個函式。

要停的時候,沿著登記好的入口喊停。

Query.fetch() 建立 Retryer 時,傳入一個 onCancel callback,裡面呼叫的正是這一輪的 abortController.abort()。整條取消路徑是:

Query 判斷這輪工作不需要了
  ↓
Retryer.cancel():先讓這一輪的 Promise 以 CancelledError 結束
  ↓
onCancel() → abortController.abort()
  ↓
signal 變成 aborted
  ↓
queryFn → service → fetch:一路收到通知,停止

所以取消不是「新的 queryFn 去找舊的 queryFn」,而是 Query 像查工作登記表一樣,照著當初登記的入口停掉目前那一輪。

最後一段要靠我們接上。 前面幾步都由 TanStack Query 完成;fetch 會不會真的停,要看 signal 有沒有傳到它手上:

// ✗ 讀到了 signal,卻沒交給 fetch:Query 喊停了,請求照樣跑完
queryFn: ({ signal }) => fetch('/api/todos')

// ✓ signal 經過 getTodos(),一路傳到 fetch
queryFn: ({ signal }) => getTodos(signal)

中間如果有 getTodos() 這類 service,也要繼續往下傳,直到真正支援 AbortSignal 的 API。原生 Promise 沒有通用的 cancel(),這裡的取消能力,來自函式庫的管理流程,加上底層 API 對 AbortSignal 的支援。


五、TanStack Query 如何通知 queryFn 要停止?

取消路徑有個前提:TanStack Query 得先決定「要不要喊停」。

最常見的時機,是 Query 已經沒人用了:component 卸載,或搜尋關鍵字改變、畫面換去訂閱另一個 Query。這時舊的那輪讀取可能還在跑,TanStack Query 面對一個兩難:

  • 喊停:如果 queryFn 有把 signal 傳下去,request 真的會停,省下網路與伺服器的資源。
  • 不喊停:如果 queryFn 根本不支援取消,喊停也停不了 request;不如讓它跑完,結果存進 cache,下次回到這個畫面還能直接用。

TanStack Query 看不到 queryFn 裡面寫了什麼,只能靠一個線索來判斷:你有沒有讀取 signal。

它怎麼知道 signal 被讀取?

TanStack Query 傳給 queryFn 的參數物件上,signal 不是普通屬性,而是用 Object.defineProperty 定義的 getter。概念上像這樣:

const context = { queryKey, meta };

Object.defineProperty(context, 'signal', {
  get() {
    abortSignalConsumed = true; // 記下:有人拿走了 signal
    return abortController.signal;
  },
});

queryFn(context);

所以 ({ signal }) => ... 這種解構寫法,函式一開始就會觸發 getter;沒用到 signal 的 () => fetch(...) 或 ({ queryKey }) => ... 則不會。

沒人用的時候,靠這個線索決定

判斷時機是最後一個 observer 離開:最後一個在看這份資料的 component 卸載了,或換去看別的 Query,這時 Query.removeObserver() 會幫助我們多排查一件事:

這一輪的 queryFn 有沒有拿過 signal?

拿過 signal:喊停 沒拿 signal:讓它跑完
判斷理由 你應該停得下來 喊了你也停不下來
原始碼 retryer.cancel({ revert: true }) retryer.cancelRetry()
request 收到 abort,停止 繼續跑
Query 的狀態 退回這一輪開始之前,就像這一輪沒發生過 跑完後照常更新,結果存進 cache
如果這次失敗 不適用 不再重試,因為已經沒人在看了

它只知道你有沒有拿 signal,看不到你有沒有交給 fetch。所以讀了 signal 卻沒傳下去,Query 照樣喊停、丟掉結果,request 卻停不下來。


前後兩個 Query,是誰發現要取消?

關鍵字從 react 改成 vue,['search', 'react'] 和 ['search', 'vue'] 是兩個不同的 Query。新 Query 完全不知道舊的存在,是舊 Query 自己發現要停:

observer 退訂舊 Query,改訂閱新 Query
  ↓
舊 Query 的訂閱名單空了 → 拿過 signal?
  ↓ 拿過
舊 Query 對自己這一輪的 Retryer 呼叫 cancel()
  ↓
onCancel() → 這一輪的 abortController.abort()
  ↓
signal 往下傳:queryFn → service → fetch,request 停止

(同一時間)新 Query 用自己的新 controller 開始新的一輪

三個角色各管一件事:

  1. observer 決定「還要不要」
  2. Query 決定「喊停」
  3. signal 負責「把通知送到工作那裡」。

只有同一個 Query 重新讀取時,才是新的一輪觸發取消:

呼叫 refetch()、已經有資料,且 cancelRefetch 為 true(預設值)時,Query.fetch() 會先停掉目前那一輪,再開始新的。


六、組合加上自己的停止條件

TanStack Query 給我們一個 lifecycle signal,我們還可以再加入 Day 28 學到的 timeout:

queryFn: ({ signal }) => {
  const combinedSignal = AbortSignal.any([
    signal,
    AbortSignal.timeout(5_000),
  ]);

  return getTodos(combinedSignal);
}

這時停止條件變成:

TanStack Query 覺得工作不需要 ──→ signal ──┐
                                           ▼
timeout 5 秒 ─────────────────────→ AbortSignal.any()
                                           ↓
                                         fetch

任何一個條件先 abort,整個 combined signal 就進入 aborted 狀態。

TanStack Query 仍然持有自己的 controller;我們只是把「Query lifecycle cancellation」和「timeout cancellation」組合成同一個 signal 再往下傳。

兩種停止在 TanStack Query 眼中並不一樣:

  • 由 Query 發起的取消,Retryer 已經先以 CancelledError 結束這輪工作,不會 retry。
  • 由 timeout 觸發的停止,對 Retryer 來說只是 queryFn 丟出了一個 TimeoutError,和其他失敗一樣,可能依 retry 設定再試一次。

因為 AbortSignal.timeout() 寫在 queryFn 裡,每次 attempt 都會建立新的 timeout signal,所以每次 retry 都重新計時;外層的 lifecycle signal 則仍是同一輪 fetch flow 共用的那一個。


七、搜尋條件改變,也不等於新 request 一定取消舊 request

再看常使用的搜尋案例,是不同 queryKey的組合,會依照使用者輸入不同關鍵字觸發 API :

const result = useQuery({
  queryKey: ['search', keyword],
  queryFn: ({ signal }) =>
    fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal }).then(
      (response) => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        return response.json();
      },
    ),
});

keyword 從 'react' 變成 'vue',query key 就從 ['search', 'react'] 變成 ['search', 'vue']。這兩個是不同的 Query identity,會分開管理 cache。

keyword = react

Observer
   ↓
Query ['search', 'react']
   ↓
fetch flow A

接著:

keyword = vue

Observer ──────────────────────────┐
                                   ▼
Query ['search', 'react']    Query ['search', 'vue']
       舊 Query                     ↓
                               fetch flow B

Observer 做的是 切換到另一個 Query:它從舊 Query 移除自己,再訂閱新 Query。並不是 fetch B 直接找到 fetch A 並把它 abort。

如果舊 Query 因此失去最後一個 observer,而且 signal 被讀取過,會走到第五節的取消路徑,雖然 fetch A 被取消,表面上看起來像「新請求取消了舊請求」,但真正的原因是舊 Query 已經沒有人需要,然後透過 Abort signal 去讓另一個請求動作取消掉 。

所以舊 Query 的 fetch 是否取消,要看:

還有沒有其他 observer
signal 是否被讀取
目前 fetch 狀態

這裡也要記得把 signal 傳到 fetch。如果只讀取、沒傳下去,fetch A 不會停,舊 request 會繼續跑完,白白占用網路與伺服器資源。

不過即使沒傳,在 TanStack Query 裡也不會出現手寫搜尋常見的 race condition:

也就是「react 的結果比較晚回來,蓋掉 vue 的結果」。原因在於 TanStack Query 是依照 query key,決定結果要存到哪裡、畫面要從哪裡讀:

queryKey ['search', 'react']
  ↓ hashKey():轉成穩定的字串(物件屬性會先排序)
queryHash '["search","react"]'
  ↓ QueryCache 用這個字串當 Map 的 key
Query ['search', 'react']:只存 react 的結果
  1. 一個 query key 對應一個 Query。 QueryCache 內部是一個 Map<queryHash, Query>。['search', 'react'] 和 ['search', 'vue'] 轉出不同的 queryHash,所以是兩個各自獨立的 Query。
  2. 結果只寫回發出請求的那個 Query。 fetch A 是由 ['search', 'react'] 這個 Query 開始的,回來的資料只會更新這個 Query 的 state,碰不到 ['search', 'vue']。
  3. 畫面只讀目前 key 對應的 Query。 keyword 變成 vue 時,observer 用新的 key 從 QueryCache 找到(或建立)['search', 'vue'],改成訂閱它;之後畫面拿到的資料,只從這個 Query 計算。舊 Query 更新時,也只會通知仍然訂閱它的 observer。

所以 fetch A 就算比較晚回來,也只是把 react 的結果存進 ['search', 'react'];使用者之後切回 react,還可能直接用到這份資料,是我當初整理閱讀時容易思考忽略的細節~😊

兩件事各有負責的機制:避免舊結果蓋掉新結果,靠的是 query key 對應到不同的 Query;signal 負責的,是讓不需要的 request 真的停下來。

query key 改變,不應簡化成「新 request 一定取消舊 request」。


八、新的 controller,到底以什麼為單位?

一輪讀取一個 controller。不是整個 Query 共用一個;同一輪裡的 retry,則共用同一個 controller,不會每次重試就換新的。

為什麼不能整個 Query 共用一個?

signal 被 abort 之後,就像貼上一張撕不掉的「已取消」貼紙:signal.aborted 會永遠是 true,沒辦法重設。

如果整個 Query 只用一個 controller,第一輪被取消後,這個 signal 就一直是取消狀態。下一輪再把它交給 fetch(),fetch 一看到 signal 已經取消,會立刻失敗,request 根本不會送出去。

所以每開始新的一輪讀取,都要準備一個全新的 controller。

為什麼 retry 共用同一個?

因為 retry 是同一輪工作的一部分,不是新的一輪。原始碼裡,Query.fetch() 每一輪只建立一次 AbortController;Retryer 每次重試,只是重新呼叫同一個函式,讀到的都是這個 controller 的 signal。

幾次嘗試要拿的是同一份資料、服務的是同一批使用者;不需要的時候,也應該一起停。

共用同一個 signal,Query 只要喊停一次:正在跑的那次嘗試會收到 abort,後面排隊的重試也不會再開始。

對照:兩輪讀取

第一輪(例如第一次載入)
  controller A → signal A
  ├─ queryFn 第 1 次 → 失敗
  └─ queryFn 第 2 次 → 成功        ← 兩次都拿到 signal A

第二輪(例如切回視窗、自動重新讀取)
  controller B → signal B
  └─ queryFn 第 1 次 → 成功        ← 換成全新的 signal B

就算 signal A 曾經被 abort,也不會影響第二輪,因為第二輪拿到的是全新的 signal B。

一輪讀取一個 controller:重試共用,新的一輪換新的。


九、Debounce、Deduplication、Cancellation 解決不同問題

最後把幾個容易混在一起的機制分開:

機制 解決的問題 搜尋框例子
Debounce 觸發太密集 停止輸入一段時間後才開始搜尋
Deduplication 相同工作重複開始 相同 Query 已經在讀取,其他使用端共用結果
Cancellation 已開始的工作後來不需要 離開畫面後停止未完成的搜尋

最簡單可以記成:

Debounce      = 先不要開始
Deduplication = 已經有人在做,就不要重複做一份
Cancellation  = 已經開始,但現在不要了

這三者不是同一個功能,也不是三個一一對應的 TanStack Query API(TanStack Query 本身不做 debounce,通常是在傳入 queryKey 之前先 debounce 輸入值)。它們可以一起存在:

使用者輸入
   ↓
Debounce:降低開始工作的頻率
   ↓
Query / Cache:判斷資料與工作是否可共用
   ↓
fetch flow:真正開始一輪資料取得
   ↓
AbortSignal:必要時取消已開始的工作

今日總結

今天其實是沿著一條線走:誰喊停 → 通知怎麼送到 → 底下有沒有配合。

Query(一份資料,但裡面處理很多看不到的事情...)
  │  最後一個 observer 離開,或同一個 Query 重新讀取
  ▼
一輪 fetch flow:AbortController + Retryer
  │  abort() → signal
  ▼
queryFn → service → fetch:一路把 signal 傳下去

1. 誰喊停:知道「還要不要」的那一層

知道工作什麼時候不再需要的一方持有 controller,真正做事的一方只接收 signal。在 TanStack Query 裡,這個角色由 Query 擔任:最後一個 observer 離開,或同一個 Query 重新讀取時,由它決定要不要喊停。新的 Query 不會去停舊的。

2. controller 的單位:一輪讀取一個

Query 可能活很久,但 signal 被 abort 之後無法恢復,所以每一輪 fetch flow 各自建立一個 controller。同一輪的 retry 共用同一個 signal,新的一輪換新的。

3. 底下有沒有配合:signal 要傳到底

queryFn 的寫法 Query 的判斷 request
讀取 signal,並傳到 fetch 喊停 真的停止
沒讀取 signal 不喊停,讓它跑完、結果進 cache 跑完
讀取了卻沒傳下去 喊停,結果丟掉 照樣跑完

另外要分清楚:舊結果不會蓋掉新結果,靠的是 query key 對應到不同的 Query;signal 負責的,是讓不需要的 request 真的停下來。

其實看到這裡應該得到結論很簡單,我們自己定義的 query function 只定義需不需要接收取消這個訊號:

誰知道這份工作什麼時候不再需要,誰就持有 controller;真正做事的函式,只要接收 signal 並一路傳下去。


參考資料


上一篇
Day 28|AbortSignal 逾時、組合與錯誤判斷:取消之後,catch 收到什麼?
下一篇
Day 30|重新學一次 JavaScript 之後:如何面對新的 API 與 AI?
系列文
30 天新世代 JavaScript 自我學習指南 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言