AbortController 不會把 Promise 強制關掉;它透過 AbortSignal 通知願意配合的工作停止,fetch()、事件監聽器與自訂非同步流程都能採用這個協定。
前置知識:理解 Promise、async/await、try...catch 與 fetch(); 可以先讀讀 Day 10–19 非同步資料工作流。
學習路線:Day 19 已經區分 Promise 的結果與底層工作的生命週期;今天正式回答當資料已經不需要時,誰能通知工作停止。
標籤:Web API DOM Standard AbortController AbortSignal Fetch EventTarget

cancel()。AbortController 與 AbortSignal 各自的責任。fetch() 請求。Day 24–26 一直圍著同一個編輯頁打轉:使用者選了新的大頭貼,我們把圖片讀成 Uint8Array(Day 25),也知道如果要放進 JSON,得先轉成 Base64(Day 26)。資料準備好了,下一步就是用 fetch() 把它送出去。
但實務上,使用者不一定會乖乖等儲存完成。按下「儲存」後畫面遲遲沒反應,手速一快就連點了三次,等於連續呼叫了三次儲存 API:
點「儲存」→ PUT /api/profile 送出中…
點「儲存」→ PUT /api/profile 送出中…
點「儲存」→ PUT /api/profile 送出中…
三次送的都是同一份資料,真正需要的只有一次。多出來的兩次請求就算成功回來,結果也沒有用,還白白占著網路頻寬,讓伺服器重複處理同一張大頭貼。
你可能會想:把按鈕 disable 不就好了?disable 確實能擋住重複點擊,但它只管「別再送新的」,管不到「已經送出去的那一次」。如果使用者按完儲存就改變主意、直接離開頁面,那個還在跑的請求,還是得靠別的方法叫它停下來。
前三篇關心的是資料本身:怎麼複製、怎麼移交、怎麼寫成文字。
今天問題換成處理資料的工作:送出去之後,如果已經不需要了,還能不能叫它停下來?
這其實是 Day 19 留下的問題:Promise 只能告訴我們結果是 fulfilled 還是 rejected,卻不自動擁有停止底層工作的權力。現實中前端非同步工作不一定值得等到結束,也需要考量後端 API 資源很昂貴情形~
儲存牽涉到伺服器端是否已經寫入,我們可以先用一個更單純、也更常見的情境來看同一件事:
搜尋框,使用者打字很快,每打一個字就呼叫一次搜尋 API:
j → GET /api/search?q=j
ja → GET /api/search?q=ja
jav → GET /api/search?q=jav
java → GET /api/search?q=java
第一個 j 的請求即使成功回來,通常也已經沒有價值。這時候真正的問題不是「怎麼等待 Promise」,而是:
這份工作已經不需要了,如何通知它停止?
這就是 AbortController 與 AbortSignal 要處理的事。
先看一段很常見的程式:
const response = await fetch('/api/search?q=java');
const results = await response.json();
fetch() 回傳 Promise。Promise 解決的是「結果何時成功或失敗」:
pending
├─ fulfilled:拿到結果
└─ rejected:發生錯誤
但 Promise 不知道它背後是網路請求、計時器、讀檔,還是其他工作。因此 JavaScript 沒有這種通用 API:
promise.cancel(); // 不存在
如果 Promise 可以任意停止背後工作,就必須先回答:
似乎超出了 Promise 這個狀態接收容器自己能決定的事~。
AbortSignal 採用更務實的模型:
有人發出「請停止」的通知
↓
接受 signal 的 API 自己決定如何收尾
所以它是協作式取消。fetch() 理解 signal,會終止本地請求;自訂函式若完全不讀 signal,就不會自動停下來。
建立 controller 時,會同時得到一個 signal:
const controller = new AbortController();
console.log(controller.signal.aborted);
// false
兩者的責任刻意不同:
| 物件 | 能做什麼 | 應交給誰 |
|---|---|---|
AbortController |
呼叫 abort(),發出停止通知 |
擁有取消權的一方 |
AbortSignal |
觀察是否停止、接收 abort event |
正在執行工作的 API |
畫面/呼叫端 非同步工作
AbortController ── signal ───→ fetch、事件監聽器、自訂函式
│
└── abort()
例如函式只需要知道「是否該停止」,不該有權停止其他人的工作:
function loadProfile(signal) {
return fetch('/api/profile', { signal });
}
這個分工就是使用 AbortController 的基本心智模型:
取消權留在掌握生命週期的一方,往下只傳遞「是否已取消的」的狀態信號。
abort(),決定「什麼時候、因為什麼原因」停止。aborted、監聽 abort event,沒辦法反過來取消任何工作。若把 controller 直接交進去,等於連取消權一起下放:任何內部函式、甚至第三方套件,都可能呼叫 abort(),連帶停掉其他共用同一個 controller 的工作。之後某個請求莫名被取消,就得翻遍所有拿過 controller 的地方,才知道是誰發出的。
每次開始新的搜尋前,先取消上一個 controller:
let currentController;
async function search(keyword) {
currentController?.abort();
currentController = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(keyword)}`,
{ signal: currentController.signal },
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
} catch (error) {
if (error?.name === 'AbortError') {
return;
}
throw error;
}
}
controller.abort() 會讓 signal 進入 aborted 狀態;fetch() 看見後中止本地請求,對應的 Promise 以名稱為 AbortError 的 DOMException rejected。這裡的 catch 只忽略「使用者操作造成、預期中的取消」。HTTP 404、500 不會讓 fetch() 自動 rejected;它們仍會得到 Response,所以需要檢查 response.ok。取消與 HTTP 狀態錯誤是兩件不同的事。
如果你比較常用 Axios,錯誤處理就會不一樣:
fetch() |
Axios | |
|---|---|---|
| HTTP 404、500 | 不會 rejected,要自己檢查 response.ok |
預設直接 rejected,進 catch |
| signal 取消 | DOMException,name 是 'AbortError' |
CanceledError,用 axios.isCancel() 判斷 |
一個 signal 只有一條單向的狀態路徑:
尚未取消 → 已取消(不能回頭)
所以 controller 不能重用。abort 之後再拿同一個 signal 發新請求,只會立刻失敗:
const controller = new AbortController();
controller.abort(new Error('第一次取消'));
controller.abort(new Error('第二次取消')); // 無效,reason 不會被換掉
console.log(controller.signal.reason.message);
// '第一次取消'
await fetch('/api/search?q=java', { signal: controller.signal });
// 立刻 rejected:這個 signal 一開始就是已取消
第二次 fetch() 不是因為網路慢才失敗,而是一開始拿到的就是已取消的 signal;再呼叫一次 abort() 也無法把它變回可用狀態。
正確做法是:每一次獨立的工作週期,都建立新的 controller。 第三節的 search() 每次都先 new AbortController(),就是這個原因。
此外,若 signal 已經 aborted,之後才加上的 abort listener 不會補收到舊事件。自訂 API 必須先檢查狀態,再監聽事件,第七節會實際寫一次。
AbortSignal 同時被 DOM 的 addEventListener() 支援:
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('resize', handleResize, { signal });
document.addEventListener('keydown', handleKeydown, { signal });
controller.abort();
最後一行會移除使用這個 signal 註冊的兩個 listener。這特別適合管理「某個畫面、元件或互動存在期間才需要」的一組事件。
例如拖曳開始後才監聽移動與放開:
function startDrag() {
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('pointermove', moveCard, { signal });
window.addEventListener(
'pointerup',
() => controller.abort(),
{ signal, once: true },
);
}
pointerup 發生後,controller abort;pointermove 和這次互動建立的 listener 都會一起清除。
這和 XHR 的 xhr.onabort 範圍不一樣:
xhr.onabort |
addEventListener(..., { signal }) |
|
|---|---|---|
| 是什麼 | abort 事件的處理函式 |
註冊 listener 時的選項 |
| 什麼時候作用 | 這一個 XHR 被 xhr.abort() 取消時觸發 |
signal 被取消時,移除這個 listener |
| 管理範圍 | 單一請求 | 同一個 signal 註冊的所有 listener |
| 角色 | 回報取消結果 | 可共享的生命週期管理工具 |
前者是單一請求的事件;後者是可共享的生命週期管理工具。
今天的基礎使用知識已經完整:同一套 signal 模型可以管理 fetch,也可以管理 DOM event。接下來兩節再補充停止原因,以及自己的 API 如何加入這套協定。
可以把 signal 想成一張通知單,上面只有兩個欄位:
signal
├─ aborted:取消了沒?(true/false)
└─ reason :為什麼取消?
實際看一次:
const controller = new AbortController();
console.log(controller.signal.aborted); // false
controller.abort();
console.log(controller.signal.aborted); // true
console.log(controller.signal.reason.name); // 'AbortError'
呼叫 abort() 時沒寫原因,瀏覽器會自動補上一個名稱為 AbortError 的錯誤,意思就只是「被取消了」,沒有更多細節。
想留下更清楚的原因,就把它傳進 abort():
const controller = new AbortController();
controller.abort(new Error('搜尋面板已關閉'));
console.log(controller.signal.reason.message); // '搜尋面板已關閉'
reason 技術上可以是任何值,但建議用 Error 或 DOMException,之後在 catch 裡比較好辨識。
catch 收得到這個 reason?catch 並沒有在監聽 signal。reason 比較像一個包裹,從 abort() 出發,一站一站被轉交到 catch:
controller.abort(reason) 把原因放進 signal
↓
fetch 發現 signal 被取消 用這個原因 reject Promise
↓
await 遇到 rejected 把原因當成錯誤 throw 出來
↓
catch (error) 接到的就是同一個 reason
用 === 驗證,拿到的真的是同一個物件:
const controller = new AbortController();
const reason = new Error('搜尋面板已關閉');
const request = fetch('/api/search', { signal: controller.signal });
controller.abort(reason);
try {
await request;
} catch (error) {
console.log(error === reason); // true
}
前提是中間的 API 願意幫忙轉交。fetch() 有做;如果某個 API 完全不理 signal,這條路在第二站就斷了,catch 什麼也收不到。自己寫的 Promise API 想支援 signal,就得在 abort 時自己呼叫 reject(signal.reason)。
throwIfAborted():已取消就丟出 reasonfetch() 會自己盯著 signal,但自己寫的長時間工作(例如一筆一筆處理大量資料)不會,得自己在中途問一聲「還要繼續嗎?」。最直接的寫法是:
if (signal.aborted) {
throw signal.reason;
}
throwIfAborted() 就是這三行的簡寫,名字可以直接翻成:如果已經 aborted,就把原因 throw 出去。
什麼時候需要它?當你自己把好幾個步驟組裝成一個流程的時候。
例如「匯入使用者」這個功能,其實是兩個步驟接起來的:
① 下載使用者清單(fetch)
② 一筆一筆寫進瀏覽器的資料庫(IndexedDB)
使用者只看到一顆「取消」按鈕,期待的是:不管現在跑到哪一步,按一次就全部停下來。
問題是這兩步對 signal 的反應不一樣:
fetch() 是現成的 API,本來就懂 signal,交給它就會自己停。所以做法是:把同一個 signal 一路傳下去。交給 fetch() 的同時,也在自己的迴圈裡用 throwIfAborted() 檢查。一旦 throw,程式就直接跳出迴圈,後面的資料都不會處理:
async function importUsers(signal) {
// 第一段:fetch 自己會盯著 signal
const response = await fetch('/api/users', { signal });
// 第三節:404、500 不會讓 fetch reject,要自己檢查
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const users = await response.json();
// 第二段:自己的迴圈,用同一個 signal 檢查
for (const user of users) {
signal.throwIfAborted(); // 檢查點:已取消就 throw
await saveToIndexedDB(user);
}
}
let controller;
startButton.addEventListener('click', async () => {
controller = new AbortController();
try {
await importUsers(controller.signal);
console.log('匯入完成');
} catch (error) {
// 第六節:catch 收到的就是 abort 時傳入的同一個 reason
if (error === controller.signal.reason) {
console.log(error.message); // '使用者取消匯入'
return;
}
throw error;
}
});
cancelButton.addEventListener('click', () => {
controller?.abort(new Error('使用者取消匯入'));
});
按下取消時,不管流程跑到哪一步,都是同一個 signal 讓它停下來:
| 按下取消時正在… | 誰讓它停下來 |
|---|---|
| ① 下載清單 | fetch() 自己中止請求,用 reason reject |
| ② 寫入第 3 筆 | 第 3 筆照常寫完,第 4 輪開頭的 throwIfAborted() throw |
第二種情況一步一步看:
第 1 筆:檢查 → 沒取消 → 寫入
第 2 筆:檢查 → 沒取消 → 寫入
第 3 筆:檢查 → 沒取消 → 寫入中…(這時使用者按下取消,abort())
第 4 筆:檢查 → 已取消 → throw!
↓
跳出 for 迴圈,剩下的資料都不會寫入
↓
importUsers() 回傳的 Promise 被 reject,click handler 的 catch 收到 reason
有兩點要注意:
abort() 只是把通知單改成「已取消」,不會去拉住正在跑的程式。第 3 筆已經在寫了,就會寫完;等到第 4 輪開頭檢查,才發現該停了。想停得更快,就多放幾個檢查點;如果某一步本身就懂 signal(例如改用 fetch() 上傳),直接把 signal 傳給它,連正在進行的那一筆都能中斷。await,取消才插得進來。 JavaScript 一次只能做一件事。如果迴圈裡全是同步程式,它會一口氣跑完,這段期間使用者按的取消根本排不上隊,輪到它時工作早就做完了。await 就像每一輪之間喘一口氣(Day 11),按鈕的點擊才有機會先執行。throwIfAborted() 要等到檢查點才停。如果某一步是自己包裝的底層 API(例如把 setTimeout 包成 Promise),想在按下取消的當下就停,可以改成監聽 signal 的 abort event,在 listener 裡停止工作並 reject(signal.reason)。這種寫法要記得三件事:
signal.aborted:已經取消的 signal 不會再補發 abort event(第四節)。abort 時先收尾再 reject:例如先 clearTimeout(),再 reject(signal.reason)。reject 只會改變 Promise 狀態,不會自動停掉 timer 或請求。這多半是寫函式庫或包裝底層 API 時才需要;一般應用程式把 signal 交給 fetch(),再加上 throwIfAborted() 檢查點,通常就夠了。
先講結論:前端 abort 的意思是「我不等了」,不是「請當作沒發生過」。
回到開頭連點「儲存」的例子。按下取消的那一刻,請求可能停在三種位置:
| abort 時請求在哪裡 | 後端的狀況 |
|---|---|
| 還沒送出去 | 後端什麼都沒收到 |
| 已送達,後端還在處理 | 後端可能發現「連線斷了」,但要不要停,由後端自己決定 |
| 後端已處理完、寫進資料庫 | 資料已經存下來,abort 撤銷不了 |
前端沒辦法知道自己是哪一種。所以 abort 之後唯一能確定的是「前端不再等結果」,不能確定「伺服器沒有做」。
AbortSignal 是瀏覽器裡的 JavaScript 物件,不會被送到後端;HTTP 也沒有一個標準訊息叫「請回滾剛才那筆操作」。Fetch Standard 規範的只有前端這一次 fetch 怎麼結束。整條路是這樣:
瀏覽器 abort()
後端怎麼發現 client 斷線,要看 runtime:
close 事件與 destroyed 狀態判斷連線是否提早結束;舊的 aborted event/property 已標示為 deprecated。後端發現 client 離開後,可以把這個訊號往下傳,停掉還來得及停的工作:
建立訂單、扣款這類操作,不能把「前端取消」當成「撤銷」。常見做法有三種:
這些是後端的架構設計,不是 AbortController 規範替後端規定的行為;它們存在的原因,正是連線中斷後,前端無法確定操作到底有沒有被執行。
Promise
→ 表示未來結果;沒有通用 cancel
AbortController
→ 擁有發出停止通知的權力
AbortSignal
→ 交給願意配合停止的 API
abort()
→ 改變 signal 狀態、保存 reason、送出 abort event
當下一次看到:
fetch(url, { signal });
可以不用只理解成它只是「取消 HTTP」,而是更明確的說:這項工作屬於某個生命週期;生命週期結束時,請停止並清理。
DOM Standard — Aborting ongoing activitiesAbortController、AbortSignal、reason 與自訂 API 的規範說明。
DOM Standard — addEventListener() 的 signal 選項
signal abort 時,事件監聽器如何被移除。
Fetch Standard — Abort a fetch call
定義 fetch 在 client 端收到 abort 時,以 signal reason 結束 Promise 的行為。
ECMAScript Language Specification — Awaitawait 如何處理 fulfilled 與 rejected Promise;rejection 會成為目前 async 流程中的 throw。
Node.js HTTP — IncomingMessage 與 close
Node.js HTTP request 的完成與連線提早中止訊號;aborted event/property 在新版本已 deprecated。
RFC 9110 §9.2.2 — Idempotent Methods
連線在 client 收到 response 前中斷時,client 無法確定原操作是否已套用;這是有副作用操作需要 idempotency/交易設計的 HTTP 層依據。
MDN — AbortController
實用語法與瀏覽器支援資訊。