iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
JavaScript

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

Day 28|AbortSignal 逾時、組合與錯誤判斷:取消之後,catch 收到什麼?

  • 分享至 

  • xImage
  •  

摘要

AbortSignal.timeout() 讓「等太久」也能變成一個 signal,AbortSignal.any() 則把好幾個停止條件合成一個,誰先發生就停。停下來之後,fetch 和 Axios 告訴你「這是取消」的方式不一樣;而且就算已經呼叫 abort(),.catch() 和 await 後面的程式也要等眼前的同步程式跑完才會執行。

  • 前置知識:理解 Day 27 的 AbortController、AbortSignal、fetch() 與 try...catch。

  • 學習路線:延續 Day 27 的單一取消來源,加入「太久就停止」和「多個來源任一個先發生就結束」;接著看停止之後,原生 fetch 與 Axios 各自怎麼回報錯誤。

  • 標籤:Web API DOM Standard AbortSignal.timeout AbortSignal.any Fetch Axios DOMException

https://ithelp.ithome.com.tw/upload/images/20261001/20145251kTA0z6QYbq.png


今日學習目標

  1. 用 AbortSignal.timeout() 表達請求的等待期限。
  2. 用 AbortSignal.any() 組合多個停止條件,並理解為何只保留第一個勝出的 reason。
  3. 分清原生 fetch 的 AbortError、TimeoutError 與自訂 reason。
  4. 分清 Axios 的 signal cancellation 與 Axios 自己的 timeout,並在 catch 中分類 HTTP 與網路錯誤。
  5. 說明為什麼 abort() 之後,同步程式會先跑完,Promise 的 .catch() 才執行。

Day 27 的搜尋範例解決了 「使用者輸入新關鍵字時,取消舊請求」,但真實世界往往還有其他停止條件:

  • 使用者按下取消
  • 使用者離開這個畫面(screen)
  • 後端 API 因為某些原因等太久了,該算逾時嗎?

如果每一個條件各自寫一套取消邏輯,程式會逐漸變成一團 timer、event listener 與 cleanup。這篇要把它們收斂成同一個問題:

哪一個 signal 先 aborted,這份工作就該停止。

停止之後,再回答另一個問題:程式怎麼知道是誰讓它停下來的?


一、AbortSignal.timeout():把期限直接做成 signal

先看一下寫法:AbortSignal.timeout() 和後面的 AbortSignal.any(),都是直接寫在 AbortSignal 這個名字後面呼叫,就像 Promise.all()、Array.from() 一樣。

這種方法叫 static method(靜態方法),意思是「不用先拿到一個 signal,直接請 AbortSignal 幫你做一個新的」。所以不會寫成 signal.timeout()。

用 Day 27 學到的 AbortController,想加上五秒期限可以這樣寫:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);

try {
  const response = await fetch('/api/search?q=java', {
    signal: controller.signal,
  });
  return response.json();
} finally {
  clearTimeout(timer);
}

能做到,但要自己記得清掉 timer。如果需求只是「超過五秒就停止」,其實不需要 controller:

const response = await fetch('/api/search?q=java', {
  signal: AbortSignal.timeout(5_000),
});

AbortSignal.timeout(5_000) 會建立一個新的 signal,時間到時自己變成 aborted。它沒有對外提供 controller,因為呼叫端只需要「五秒後停止」,不需要提早手動取消。

兩種做法放在一起比較:

new AbortController() AbortSignal.timeout(ms)
怎麼建立 new 一個 controller,再拿它的 .signal 直接呼叫,拿到的就是 signal
誰決定何時停止 你的程式呼叫 controller.abort() 時間到了自己停止
能不能提早手動停止 可以,隨時呼叫 abort() 不行,沒有 controller 可以呼叫
要不要自己清 timer 自己搭配 setTimeout 時要記得 clearTimeout() 不用
預設 reason DOMException,name 是 'AbortError' DOMException,name 是 'TimeoutError'
適合情境 使用者按取消、離開畫面、換新關鍵字 單純「等太久就放棄」

如果兩種需求都有——既要能手動取消,又要有期限——下一節的 AbortSignal.any() 就是把它們接在一起的工具。

表格裡最實用的是「預設 reason」那一列:靠它,程式可以分辨「被取消」與「等太久」:

try {
  await fetch('/api/search?q=java', {
    signal: AbortSignal.timeout(5_000),
  });
} catch (error) {
  // timeout 到期時,error 是 DOMException
  // error.name === 'TimeoutError'
  if (error?.name === 'TimeoutError') {
    showMessage('搜尋逾時,請稍後再試');
    return;
  }

  throw error;
}

小提醒:它不是精密碼錶。 AbortSignal.timeout() 只計算頁面「真的在運作」的時間(規範稱為 active time)。頁面被暫停的那段時間,不會算進五秒。

不算進時間的情況,實際運作上會有延遲:

  • 使用者按「上一頁」離開,頁面被瀏覽器暫存起來(back-forward cache,簡稱 bfcache),之後再按「下一頁」回來。
  • 程式跑在 Worker 裡,而這個 Worker 被瀏覽器暫停(suspended)。

例如:正常運作 2 秒 → 進入 bfcache 10 秒 → 回來後再過 3 秒才觸發。牆上時鐘已經過了 15 秒,但 timeout 只算了 5 秒。

所以把它理解成「大約允許五秒的有效等待時間」就好。如果是 token 有效期限這類真正的截止時間,仍應以伺服器時間為準。


二、AbortSignal.any():任一條件發生就停止

上面有提到,如果一項任務終止的條件包含超時或需要手動取消停止時:

  • 使用者按取消 → 立即停
  • 等待五秒 → 自動停

可以各自建立 signal,再用 AbortSignal.any() 合併:

const userController = new AbortController();

const signal = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(5_000),
]);

const request = fetch('/api/search?q=java', { signal });

cancelButton.addEventListener('click', () => {
  userController.abort();
}, { once: true });

規則很單純:任一個輸入 signal 被取消,合成後的 signal 也會取消。

userController.signal ──┐
                        ├── AbortSignal.any(...) ──→ fetch
timeout signal ─────────┘

要注意的是,any() 只會「往下」傳,不會「橫向」傳:

  • 往下傳:任何一個來源停止,合成的 signal 就跟著停止。
  • 不會橫向傳:其中一個來源停止,不會順便讓其他來源也停止。

用上面的例子來看:使用者按了取消,userController.signal 停止,合成的 signal 也停止,fetch 跟著中斷。但 timeout 那個 signal 完全不受影響,它還是照樣倒數,五秒到了才自己變成 aborted。

userController.abort();

userController.signal.aborted; // true:使用者取消了
signal.aborted;                // true:合成的 signal 跟著停止
timeoutSignal.aborted;         // false:timeout 不知道也不在乎,繼續倒數

(這裡的 timeoutSignal 指的是傳進 any() 的那個 AbortSignal.timeout(5_000)。)

所以可以把 any() 想成一個「只負責看」的觀察者:

A ─┐
B ─┼─→ 有沒有任何一個停止了? ─→ 合成的 signal
C ─┘

合成的 signal 停止時,真正被停下來的,只有綁定在它身上的工作——例如傳了這個 signal 的 fetch,或用它註冊的事件監聽。A、B、C 這些來源 signal 則各自獨立,不會因為合成的 signal 停止而受影響。


誰先 abort,誰的 reason 就留下來

合成 signal 會保留第一個使它 aborted 的來源之 reason:

userController.abort();
console.log(signal.reason.name); // 'AbortError'

// 如果是 timeout 先到:
// signal.reason.name === 'TimeoutError'

因此 UI 可以給出不同訊息:使用者主動取消通常不必提示,timeout 則可能需要提示或提供重試。

但 any() 只保留勝出的 reason,不會記錄是陣列裡第幾個 signal。

若使用者取消與面板關閉都用預設的 AbortError,事後就分不出來。需要精確辨識時,讓不同來源使用可辨識的 reason,例如 new DOMException('面板已關閉', 'AbortError'),或在各來源自己的 abort listener 記錄狀態。

const userController = new AbortController();
const panelController = new AbortController();

const signal = AbortSignal.any([
  userController.signal,
  panelController.signal,
]);

// ❌ 用預設 reason:事後分不出是誰
panelController.abort();
signal.reason.name; // 'AbortError'(使用者取消也是這個)

改成各自給可辨識的 reason(signal 只能 abort 一次,所以這是另一次獨立的情境):

// ✅ 關閉面板時說明原因
panelController.abort(new DOMException('面板已關閉', 'AbortError'));

signal.reason.name;    // 'AbortError':仍然會被當成一般取消
signal.reason.message; // '面板已關閉':可以知道是誰

name 保留 'AbortError',原本「是不是取消」的判斷就不用改;要分辨來源時,再看 message。


三、完整案例:一個面板,一份共用生命週期

現在把 Day 27 的事件監聽器也納入,實際以一個搜尋面板開啟時會需要考量哪些取消情境:

  • 監聽 Escape 鍵。
  • 發出搜尋請求。
  • 使用者按取消。
  • 等太久時停止請求。
  • 面板關閉時,一次清理所有事情。
function openSearchPanel(keyword) {
  const panelController = new AbortController();
  const userController = new AbortController();

  const requestSignal = AbortSignal.any([
    panelController.signal, // 關掉時訊號
    userController.signal,  // 使用者手動取消
    AbortSignal.timeout(5_000), // 等太久時訊號
  ]);
  
  // 監聽鍵盤事件
  document.addEventListener(
    'keydown',
    (event) => {
      if (event.key === 'Escape') {
        userController.abort(
          new DOMException('使用者取消', 'AbortError'),
        );
      }
    },
    { signal: panelController.signal },
  );

  const request = fetch(
    `/api/search?q=${encodeURIComponent(keyword)}`,
    { signal: requestSignal }, // AbortSignal.any() 複合情境停止條件
  );

  return {
    request,
    close() {
      panelController.abort(
        new DOMException('面板已關閉', 'AbortError'),
      );
    },
  };
}

這裡有兩個不同用途的 signal:

signal 負責什麼
panelController.signal 面板關閉時移除鍵盤事件,也參與取消 request
requestSignal 合併面板關閉、使用者取消與 timeout 後,專門交給 fetch

注意 controller 放在哪裡:面板知道自己什麼時候關閉,所以面板持有 panelController;fetch 只拿到 signal。

openSearchPanel() 自己不知道面板什麼時候會關,所以只回傳 close(),由管理面板的那一層在適當時機呼叫。

例如在 React 裡,元件 unmount 或 keyword 改變時,需要記得手動清理:

useEffect(() => {
  const panel = openSearchPanel(keyword);
  return () => panel.close();
}, [keyword]);

只要 document 還在(關閉面板、SPA 換路由),就要記得呼叫 close(),否則鍵盤監聽會一直留著;若是關掉分頁或整頁跳轉,瀏覽器會把請求和監聽一起清掉,不需要另外處理。


四、原生 fetch:catch 收到的是 signal 的 reason

在 fetch 出現以前,XMLHttpRequest 也有 xhr.abort() 與 xhr.timeout,但它靠事件分流:

  • 主動取消觸發 onabort,逾時觸發 ontimeout。
  • fetch 把結果改成 Promise 後,所有失敗都進入同一個 catch,所以要看捕捉到的 error 才知道原因。

規則其實只有一條:fetch 因 signal 停止時,會以 signal.reason rejected。

停止來源 catch 收到的 error
controller.abort() 未傳 reason DOMException,name === 'AbortError'
AbortSignal.timeout() 到期 DOMException,name === 'TimeoutError'
controller.abort(customReason) 就是 custom reason 本身

DOMException 是許多 Web API 共用的錯誤型別,AbortError、TimeoutError 只是它的 name。

第三列最容易踩到:自己傳入的 reason 會原封不動變成 error,不會被換成 AbortError。所以如果專案有用 custom reason,只靠 error.name 分流並不可靠。比較穩的做法是直接問 signal:

try {
  await fetch('/api/search?q=java', { signal });
} catch (error) {
  if (signal.aborted) {
    // 預期中的停止;原因在 signal.reason
    if (signal.reason?.name === 'TimeoutError') {
      showMessage('連線等待太久');
    }
    return;
  }

  throw error; // 真正的網路錯誤或程式錯誤
}

五、Axios:同一個 signal,不同的錯誤契約

Axios 同樣接受標準的 signal,建立 controller、傳入 signal、呼叫 abort() 的方式都沒變。改變的是 catch 收到的錯誤。

原因在於 Axios 本身不直接發送請求,而是交給底層的 adapter(轉接器):

瀏覽器預設用 xhr,Node.js 用 http,也可以設定成 fetch。不同 adapter 失敗時的原始錯誤長得都不一樣——XHR 是事件、Node.js 是 socket 錯誤、fetch 是 DOMException。

Axios 會在 adapter 外面包一層,把這些錯誤統一整理成自己的 AxiosError,讓你不管底層用哪個 adapter,都用同一套方式判斷:

xhr adapter   ─┐
http adapter  ─┼─→ Axios 統一包裝 ─→ AxiosError
fetch adapter ─┘                     └─ signal 取消:CanceledError

signal 造成的取消會變成 CanceledError,它是 AxiosError 的子類別,code 是 'ERR_CANCELED'。所以就算 Axios 底層用的是 fetch adapter,catch 也收不到原生的 AbortError,

記得要改用 Axios 提供的判斷方法 😉😉😉

原生 fetch 取消
→ 判斷 error.name 或 signal.aborted

Axios 取消
→ 判斷 axios.isCancel(error)

AbortSignal.timeout() 不等於 Axios 的 timeout

這兩段看起來都是「五秒後停止」,但走的是不同機制:

// A:標準 signal 到期
axios.get('/api/search', { signal: AbortSignal.timeout(5_000) });

// B:Axios 自己的 timeout 設定
axios.get('/api/search', { timeout: 5_000 });
  • A 對 Axios 而言只是「signal 被 abort 了」,所以 catch 收到的是 CanceledError,axios.isCancel(error) 為 true——即使 signal 的 reason 是 TimeoutError。

  • B 才是 Axios 自己判定逾時,error.code 會是 ECONNABORTED 或 ETIMEDOUT。

AbortSignal.timeout(5000) → signal cancellation → axios.isCancel()
Axios timeout: 5000       → Axios timeout error → error.code

兩者的 error.code 常見值:

設定方式 error 類型 error.code
signal: AbortSignal.timeout(5_000) CanceledError 'ERR_CANCELED'
timeout: 5_000(xhr/http adapter,預設) AxiosError 'ECONNABORTED'
timeout: 5_000(開啟 transitional.clarifyTimeoutError) AxiosError 'ETIMEDOUT'
timeout: 5_000(fetch adapter) AxiosError 'ETIMEDOUT'

要小心 ECONNABORTED 不一定是逾時:在 xhr adapter 裡,請求還在跑時使用者按了「停止」、重新整理或跳頁,瀏覽器中止請求時也會是這個 code。若想讓逾時有自己專屬的 code,可以設定 transitional: { clarifyTimeoutError: true },逾時就會改成 ETIMEDOUT。

兩者也可以同時使用,分別表達不同的停止理由:

await axios.get('/api/search?q=java', {
  signal: controller.signal, // 外部生命週期要求停止,例如離開頁面
  timeout: 5_000,            // 這次 request 等太久
});

TypeScript:先把 unknown 縮小成 AxiosError

剛開始用 Axios 搭配 TypeScript 處理錯誤時,我也想過:為什麼不能直接把 error 標成 AxiosError?

} catch (error: AxiosError) {
//       ^^^^^ TS1196:catch 變數的型別只能標成 any 或 unknown

TypeScript 不允許這樣寫,因為 catch 會接住 try 區塊裡所有丟出來的東西,不只 Axios 的錯誤:

可能收到的值 例子
Axios 的請求錯誤 取消、逾時、HTTP 4xx/5xx、網路斷線
自己程式的 bug 讀取 undefined 的屬性造成 TypeError、拼錯變數名稱造成 ReferenceError
interceptor 丟出的錯誤 在 request/response interceptor 裡 throw 的任何值
不是 Error 的值 JavaScript 允許 throw 'oops'、throw { code: 1 },什麼都能丟

所以 TypeScript 不能保證 error 一定是 AxiosError,要先視為 unknown,再用 Axios 提供的 type guard 一步步確認型別。判斷的原則是:只處理認得的錯誤,認不得的就原封不動 throw 出去,讓程式 bug 留給上層的錯誤處理或錯誤監控,不要被當成「網路錯誤」悄悄吞掉。

import axios, { AxiosError } from 'axios';

interface ApiError {
  message: string;
}

async function loadData(url: string, signal: AbortSignal) {
  try {
    const response = await axios.get(url, { signal, timeout: 5_000 });
    return response.data;
  } catch (error: unknown) {
    // 1. signal 造成的取消
    if (axios.isCancel(error)) return;

    // 不是 AxiosError(程式 bug、interceptor 丟的值…),交回上層處理
    if (!axios.isAxiosError<ApiError>(error)) throw error;

    // 2. Axios timeout
    if (
      error.code === AxiosError.ECONNABORTED ||
      error.code === AxiosError.ETIMEDOUT
    ) {
      showMessage('請求逾時');
      return;
    }

    // 3. 伺服器有回應,但 status 不在成功範圍
    if (error.response) {
      showMessage(error.response.data.message);
      return;
    }

    // 4. request 已送出,但沒有收到 response
    if (error.request) {
      showMessage('網路錯誤或沒有收到回應');
      return;
    }

    // 5. 其他設定或建立 request 時的錯誤
    throw error;
  }
}

幾個要注意的地方:

  • axios.isCancel() 要放在最前面。CanceledError 也是 AxiosError,若先判斷 isAxiosError(),取消會被誤當成一般請求錯誤。
  • Axios 的 timeout code 依 adapter 而不同,常見 ECONNABORTED,某些設定下是 ETIMEDOUT,所以兩個都處理。實際專案以鎖定的版本與測試為準。
  • isAxiosError<ApiError> 的 generic 只是告訴 TypeScript「預期錯誤回應長這樣」,不會在 runtime 驗證伺服器資料。

同一個 signal 可以同時交給 fetch 和 Axios,但錯誤處理要尊重你直接呼叫的那個工具提供的契約。


六、取消之後,catch 什麼時候執行?

前面都在問「catch 收到什麼」。最後補一個除錯時常卡住的問題:什麼時候收到。

const controller = new AbortController();

fetch('/api/search?q=javascript', {
  signal: controller.signal,
}).catch(() => {
  console.log('4. catch:fetch 已取消');
});

controller.signal.addEventListener('abort', () => {
  console.log('2. abort event');
});

console.log('1. before abort');
controller.abort();
console.log('3. after abort');

輸出順序是 1 → 2 → 3 → 4。catch 比 after abort 晚,不代表取消失敗:

controller.abort()            ← 同步:現在就發生
  ├─ signal.aborted 變成 true,reason 被設定
  ├─ fetch Promise 被 rejected(.catch 排進 microtask,尚未執行)
  └─ 觸發 abort event(所以 2 在 3 前面)
console.log('3. after abort')  ← 目前這段同步程式繼續跑完
────────────────────────────
microtask                     ← 稍後才執行
  └─ .catch()/await 後面的程式

這就是 Day 10 介紹過 Promise 的規則:

Promise 的狀態可以「現在」改變,但通知 .catch() 的工作要等目前同步程式結束後才以 microtask 執行。Day 11 也說過,await 暫停的只是這個 async function 的後半段。

依 DOM Standard,fetch 註冊的 abort algorithm 會比 abort event 先執行,所以 Promise 其實在 event 之前就已經 rejected;

只是 .catch() 這個 reaction 要等到 microtask 才跑,輸出順序因此仍是 1 → 2 → 3 → 4。

注意這裡說的是 Promise 的 .catch() 與 await 的後續程式。一般同步的 try { throw ... } catch {} 是立即執行的。

精確的說法不是「catch 永遠比較晚」,而是 Promise reaction 要等目前同步 JavaScript 跑完,才以 microtask 執行。
AbortSignal 只能通知,不能搶走正在執行的 JavaScript 控制權。abort() 後面若接著一段很久的同步迴圈,catch 也只能等它跑完。


已經拿到 response 時,失敗的是哪一個 Promise?

fetch 的 Promise 只代表「拿到 response」,不代表 body 已經讀完:

async function loadProfile(signal) {
  const response = await fetch('/api/profile', { signal });
  // 到這裡,fetch Promise 已經 fulfilled

  return await response.json();
  // 若此時才 abort,rejected 的是 response.json()
}

已 fulfilled 的 Promise 不會回頭變成 rejected。所以要把每一個 await 都當成可能因取消而失敗的等待點,這也是第四節建議在 catch 裡用 signal.aborted 判斷的原因。


今日總結

今天其實是沿著一條線走:怎麼喊停 → 誰喊的停 → 什麼時候收到。

AbortController ─┐
timeout(ms)     ─┼─→ any() ─→ fetch/Axios ─→ catch 判斷原因
其他 signal      ─┘
  (1. 怎麼喊停)              (2. 誰喊的停)  (3. microtask 才收到)

1. 怎麼喊停

需求 工具
程式或使用者隨時手動取消 new AbortController()
等太久就放棄 AbortSignal.timeout(ms)
好幾個條件,任一個發生就停 AbortSignal.any(signals)

any() 只往下傳:來源停止,合成的 signal 跟著停;但來源之間互不影響。

2. 誰喊的停:看你直接呼叫的是哪個工具

工具 被取消 等太久
原生 fetch error.name === 'AbortError',或直接看 signal.aborted error.name === 'TimeoutError'
Axios axios.isCancel(error)(code 是 'ERR_CANCELED') Axios timeout:error.code 是 'ECONNABORTED' 或 'ETIMEDOUT'

同一個 signal 交給兩個工具,catch 收到的錯誤卻不一樣,因為 Axios 會把錯誤重新包裝成自己的 AxiosError。自訂 reason 時,fetch 會原封不動交回來,所以判斷 signal.aborted 最穩。

3. 什麼時候收到

abort() 同步通知 → 目前程式跑完 → .catch()/await 後續以 microtask 執行

abort() 呼叫當下,signal 立刻變成 aborted;但 Promise 的 .catch() 要等眼前的同步程式跑完才輪到。

signal 負責通知「這份工作不需要了」,工具決定錯誤長什麼樣子,Promise 決定你什麼時候收到。


參考資料


上一篇
Day 27|Promise 不能取消,那 AbortController 到底取消了什麼?
系列文
30 天新世代 JavaScript 自我學習指南 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言