iT邦幫忙

2026 iThome 鐵人賽

DAY 27
1
JavaScript

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

Day 27|Promise 不能取消,那 AbortController 到底取消了什麼?

  • 分享至 

  • xImage
  •  

摘要

AbortController 不會把 Promise 強制關掉;它透過 AbortSignal 通知願意配合的工作停止,fetch()、事件監聽器與自訂非同步流程都能採用這個協定。

  • 前置知識:理解 Promise、async/await、try...catch 與 fetch(); 可以先讀讀 Day 10–19 非同步資料工作流。

  • 學習路線:Day 19 已經區分 Promise 的結果與底層工作的生命週期;今天正式回答當資料已經不需要時,誰能通知工作停止。

  • 標籤:Web API DOM Standard AbortController AbortSignal Fetch EventTarget

https://ithelp.ithome.com.tw/upload/images/20260930/20145251ZK7NlmIuUC.png


今日學習目標

  1. 說明 Promise 為什麼沒有通用的 cancel()。
  2. 分清 AbortController 與 AbortSignal 各自的責任。
  3. 使用 signal 取消不再需要的 fetch() 請求。
  4. 理解 abort 是一次性的協作式通知,不是遠端工作的保證回滾。
  5. 用同一個 signal 管理 DOM 事件監聽器的生命週期。

從「資料長什麼樣子」回到「工作還要不要做」

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 要處理的事。


一、Promise 代表結果,卻不擁有停止工作的權力

先看一段很常見的程式:

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 可以任意停止背後工作,就必須先回答:

  • 要停止哪一個資源?
  • 已經送出的 HTTP request 怎麼辦?
  • 伺服器已經開始寫入資料時,是否要回滾?

似乎超出了 Promise 這個狀態接收容器自己能決定的事~🫪。

AbortSignal 採用更務實的模型:

有人發出「請停止」的通知
        ↓
接受 signal 的 API 自己決定如何收尾

所以它是協作式取消。fetch() 理解 signal,會終止本地請求;自訂函式若完全不讀 signal,就不會自動停下來。


二、Controller 負責停止,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 的基本心智模型:

取消權留在掌握生命週期的一方,往下只傳遞「是否已取消的」的狀態信號。

  • controller 是發出端:只有它能呼叫 abort(),決定「什麼時候、因為什麼原因」停止。
  • signal 是唯讀的觀察端:拿到它的函式只能讀 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;
  }
}
  • 輸入 "jav" → 建立 controller A → 發出 request A
  • 輸入 "java" → abort controller A → 建立 controller B → 發出 request B
  1. controller.abort() 會讓 signal 進入 aborted 狀態;
  2. 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 是一次性的:取消後不能重設

一個 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 如何加入這套協定。


六、abort 也可以知道「為什麼取消」的原因

可以把 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():已取消就丟出 reason

fetch() 會自己盯著 signal,但自己寫的長時間工作(例如一筆一筆處理大量資料)不會,得自己在中途問一聲「還要繼續嗎?」。最直接的寫法是:

if (signal.aborted) {
  throw signal.reason;
}

throwIfAborted() 就是這三行的簡寫,名字可以直接翻成:如果已經 aborted,就把原因 throw 出去。

什麼時候需要它?當你自己把好幾個步驟組裝成一個流程的時候。

例如「匯入使用者」這個功能,其實是兩個步驟接起來的:

① 下載使用者清單(fetch)
② 一筆一筆寫進瀏覽器的資料庫(IndexedDB)

使用者只看到一顆「取消」按鈕,期待的是:不管現在跑到哪一步,按一次就全部停下來。

問題是這兩步對 signal 的反應不一樣:

  • fetch() 是現成的 API,本來就懂 signal,交給它就會自己停。
  • 寫入 IndexedDB 的迴圈是我們自己寫的,而且 IndexedDB 不接受 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

有兩點要注意:

  1. 不是按下就停,而是「下一次檢查」才停。 abort() 只是把通知單改成「已取消」,不會去拉住正在跑的程式。第 3 筆已經在寫了,就會寫完;等到第 4 輪開頭檢查,才發現該停了。想停得更快,就多放幾個檢查點;如果某一步本身就懂 signal(例如改用 fetch() 上傳),直接把 signal 傳給它,連正在進行的那一筆都能中斷。
  2. 迴圈裡要有 await,取消才插得進來。 JavaScript 一次只能做一件事。如果迴圈裡全是同步程式,它會一口氣跑完,這段期間使用者按的取消根本排不上隊,輪到它時工作早就做完了。await 就像每一輪之間喘一口氣(Day 11),按鈕的點擊才有機會先執行。

補充:想讓進行中的那一步也立刻停?

throwIfAborted() 要等到檢查點才停。如果某一步是自己包裝的底層 API(例如把 setTimeout 包成 Promise),想在按下取消的當下就停,可以改成監聽 signal 的 abort event,在 listener 裡停止工作並 reject(signal.reason)。這種寫法要記得三件事:

  1. 開始前先檢查 signal.aborted:已經取消的 signal 不會再補發 abort event(第四節)。
  2. 收到 abort 時先收尾再 reject:例如先 clearTimeout(),再 reject(signal.reason)。reject 只會改變 Promise 狀態,不會自動停掉 timer 或請求。
  3. 不管成功或取消,最後都移除 listener,避免 signal 一直抓著這個工作不放。

這多半是寫函式庫或包裝底層 API 時才需要;一般應用程式把 signal 交給 fetch(),再加上 throwIfAborted() 檢查點,通常就夠了。


七、進階補充:前端 abort,後端會停止嗎?

先講結論:前端 abort 的意思是「我不等了」,不是「請當作沒發生過」。

回到開頭連點「儲存」的例子。按下取消的那一刻,請求可能停在三種位置:

abort 時請求在哪裡 後端的狀況
還沒送出去 後端什麼都沒收到
已送達,後端還在處理 後端可能發現「連線斷了」,但要不要停,由後端自己決定
後端已處理完、寫進資料庫 資料已經存下來,abort 撤銷不了

前端沒辦法知道自己是哪一種。所以 abort 之後唯一能確定的是「前端不再等結果」,不能確定「伺服器沒有做」。


後端收到的不是 signal,而是「連線斷了」

AbortSignal 是瀏覽器裡的 JavaScript 物件,不會被送到後端;HTTP 也沒有一個標準訊息叫「請回滾剛才那筆操作」。Fetch Standard 規範的只有前端這一次 fetch 怎麼結束。整條路是這樣:

瀏覽器 abort()

  1. 停止本地 fetch
  2. 中止連線或目前的 stream(怎麼中止,取決於 HTTP 版本與實作)
  3. 後端「可能」發現 client 斷線
  4. 後端自己決定要不要停止後續工作

後端怎麼發現 client 斷線,要看 runtime:

  • Node.js HTTP server:從 request 的 close 事件與 destroyed 狀態判斷連線是否提早結束;舊的 aborted event/property 已標示為 deprecated。
  • 採用 Fetch 風格介面的 server runtime:可能直接提供 request 自己的 signal。

後端能停的,和停不了的

後端發現 client 離開後,可以把這個訊號往下傳,停掉還來得及停的工作:

  • 能停:長時間的查詢、呼叫其他服務、串流回應(反正已經沒人在收)。
  • 停不了:已經 commit 的資料庫寫入。使用者關掉頁面,不會讓已存的資料自動消失。

有副作用的 API,要靠後端設計來保護

建立訂單、扣款這類操作,不能把「前端取消」當成「撤銷」。常見做法有三種:

  • transaction:一組寫入要嘛全部成功、要嘛全部不算,不會因為中途斷線留下做一半的資料。
  • idempotency key:同一個操作帶同一把 key,重送幾次都只算一次。開頭連點三次「儲存」,只要這三次帶的是同一把 key(例如同一份表單內容共用一把),就算三個請求都送到,後端也只會處理一次。
  • 明確的取消/補償 API:真的要撤銷,就呼叫「取消訂單」、「退款」這類專門的 API,而不是靠中斷連線。

這些是後端的架構設計,不是 AbortController 規範替後端規定的行為;它們存在的原因,正是連線中斷後,前端無法確定操作到底有沒有被執行。


今日總結

Promise
→ 表示未來結果;沒有通用 cancel

AbortController
→ 擁有發出停止通知的權力

AbortSignal
→ 交給願意配合停止的 API

abort()
→ 改變 signal 狀態、保存 reason、送出 abort event

當下一次看到:

fetch(url, { signal });

可以不用只理解成它只是「取消 HTTP」,而是更明確的說:這項工作屬於某個生命週期;生命週期結束時,請停止並清理。


參考資料


上一篇
Day 26|btoa('中文') 為什麼會壞?UTF-8、Base64 與 Hex 的分工
系列文
30 天新世代 JavaScript 自我學習指南 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言