主題: Web platform

AbortController:取消的是過期工作,不是把錯誤吞掉

AbortController 能停止使用者已替換的工作,但取消、逾時、HTTP failure 與後端副作用各有不同責任。先把 controller 的所有權放對位置。

動態迷因(展開/收合)
搜尋字或 route 已換掉時,舊 request 不該繼續讓人等,也不該晚到後改寫新畫面。 · 來源:GIPHY

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 計時器。

動態迷因(展開/收合)
把 timeout、HTTP failure 與主動取消都折成空陣列,畫面看似安靜,卻讓使用者與維運都不知道實際發生了什麼。 · 來源:GIPHY

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 與可恢復設計。

外部參考資料