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

AbortSignal.timeout() 表達請求的等待期限。AbortSignal.any() 組合多個停止條件,並理解為何只保留第一個勝出的 reason。AbortError、TimeoutError 與自訂 reason。timeout,並在 catch 中分類 HTTP 與網路錯誤。abort() 之後,同步程式會先跑完,Promise 的 .catch() 才執行。Day 27 的搜尋範例解決了 「使用者輸入新關鍵字時,取消舊請求」,但真實世界往往還有其他停止條件:
如果每一個條件各自寫一套取消邏輯,程式會逐漸變成一團 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)。頁面被暫停的那段時間,不會算進五秒。
不算進時間的情況,實際運作上會有延遲:
例如:正常運作 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() 只會「往下」傳,不會「橫向」傳:
用上面的例子來看:使用者按了取消,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 停止而受影響。
合成 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 的事件監聽器也納入,實際以一個搜尋面板開啟時會需要考量哪些取消情境:
使用者按取消。等太久時停止請求。面板關閉時,一次清理所有事情。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(),否則鍵盤監聽會一直留著;若是關掉分頁或整頁跳轉,瀏覽器會把請求和監聽一起清掉,不需要另外處理。
catch 收到的是 signal 的 reason在 fetch 出現以前,XMLHttpRequest 也有 xhr.abort() 與 xhr.timeout,但它靠事件分流:
onabort,逾時觸發 ontimeout。catch,所以要看捕捉到的 error 才知道原因。規則其實只有一條:fetch 因 signal 停止時,會以
signal.reasonrejected。
| 停止來源 | 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,建立 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 等太久
});
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(),取消會被誤當成一般請求錯誤。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也只能等它跑完。
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 決定你什麼時候收到。
timeout() 與 any():DOM Standard AbortSignal.timeout()/AbortSignal.any()(正式定義與勝出 reason)、MDN AbortSignal.timeout()(active time、bfcache 與 suspended Worker)abort()(預設 AbortError)、Signal abort(abort algorithms 先於 abort event)ECONNABORTED/ETIMEDOUT)、TypeScript 定義 v1.19.0(CanceledError、isCancel()、isAxiosError())useUnknownInCatchVariables