主題: Web platform
AbortController:取消的是過期工作,不是把錯誤吞掉
AbortController 能停止使用者已替換的工作,但取消、逾時、HTTP failure 與後端副作用各有不同責任。先把 controller 的所有權放對位置。
動態迷因(展開/收合)
AbortController 常被當成 fetch timeout 的附加零件:多寫一個 controller,過五秒呼叫 abort(),catch 裡把 AbortError 忽略。這當然能跑,但沒有回答真正的問題:是誰決定這份非同步工作已經沒有價值?
答案通常不是 fetch,而是使用者意圖或 UI 生命週期。搜尋字由 rea 改成 react、元件卸載、route 被下一次導覽取代,都是「先前工作過期」的明確時刻。controller 應由知道這件事的地方持有;下游函式只接收 signal,合作停止。
一次使用者意圖,一個 controller
typeahead 不只需要 debounce。debounce 能減少 request 數量,卻不能讓已送出的 rea 停下來;它仍可能在 react 之後回來,把畫面改回舊結果。
let activeSearch;
async function search(query) {
activeSearch?.abort(new DOMException("Superseded", "AbortError"));
const controller = new AbortController();
activeSearch = controller;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const results = await response.json();
controller.signal.throwIfAborted();
if (activeSearch !== controller) return;
renderResults(results);
} catch (error) {
if (controller.signal.aborted) return;
showSearchError(error);
} finally {
if (activeSearch === controller) activeSearch = undefined;
}
}
這段的重點不是把所有 async 都塞進 controller,而是把 controller 的範圍對齊 intent。signal 已 abort 後不能重設;下一次搜尋必須建立新的 controller。finally 也只清除自己仍擁有的 controller,否則舊 request 結束時會把較新的狀態清掉。
同一個 signal 可以傳給一整串支援它的工作。fetch、讀取 response body、某些 event listener,以及自訂 Promise API 都能依 signal 停止;純同步、長時間 CPU 運算仍要自己在可中斷邊界檢查 throwIfAborted(),或改用 Worker。AbortController 不會神奇地殺掉任意 JavaScript。
取消、逾時與失敗不是同一種結果
使用者輸入下一個字時安靜取消舊搜尋很合理;等四秒仍沒結果,可能值得顯示重試。HTTP 500 又是另一回事:fetch 會拿到 Response,因此仍要檢查 response.ok。
async function loadSuggestions(query, userSignal) {
const timeout = AbortSignal.timeout(4_000);
const signal = AbortSignal.any([userSignal, timeout]);
try {
const response = await fetch(`/api/suggestions?q=${encodeURIComponent(query)}`, { signal });
if (!response.ok) throw new Error(`Suggestions failed: ${response.status}`);
return await response.json();
} catch (error) {
if (userSignal.aborted) return;
if (timeout.aborted) {
showRetry("搜尋逾時,請再試一次。");
return;
}
throw error;
}
}
AbortSignal.any() 很適合組合「使用者取消」與「產品 deadline」,但它只保留第一個 abort 的 reason。保留原始 signal,才能在 UI 或 log 裡知道是哪一條邊界先發生。AbortSignal.timeout() 使用的是 active time;文件進入 bfcache 或 worker 暫停時,不應把它當成 server latency 計時器。
動態迷因(展開/收合)
helper 收 signal,不搶 controller
共用 helper 若自己偷建全域 controller,呼叫端就不能讓 route、component cleanup 或 query library 管理它。較小的 API 是把 signal 視為可選的合作式參數:
async function fetchJSON(url, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const data = await response.json();
signal?.throwIfAborted();
return data;
}
如果 TanStack Query、router 或其他 library 已經傳入 query 專屬 signal,直接接上就好,不要再包一層無關 controller。只有 helper 自己擁有一段可被使用者取消的獨立流程時,才有理由建立自己的 controller。
client abort 不會撤回後端副作用
取消瀏覽器等待不表示伺服器沒有收到 POST,更不表示 database transaction 已回滾。對付款、建立訂單或任何不可重複 side effect,controller 解決的是 client 端生命週期;後端仍要用 idempotency key、去重與可恢復的狀態設計處理「請求可能已送達」的事實。
這也說明何時不用 AbortController:一個短小同步 click handler 沒有可取消的後續工作,就別為它多造狀態。它不是每個 function 的儀式,而是「工作失效時應確實停止」的明確 contract。
我學到什麼
- 我讓 controller 留在知道使用者意圖或生命週期的地方,讓 helper 與 fetch 只接收 signal。
- 我把主動取消、逾時、HTTP response 與真正失敗分開處理,不再用空資料假裝一切正常。
- 我不把 client abort 當成後端已撤銷;有副作用的 request 仍需要 idempotency 與可恢復設計。