本文 AI 產出,尚未審核

JavaScript 課程 – Fetch 與網路請求(Networking)

主題:AbortController(取消請求)


簡介

在前端開發中,fetch 已成為與伺服器溝通的主要手段。它的非同步特性讓 UI 能保持流暢,但同時也帶來一個問題:當使用者在請求尚未完成時切換頁面、點擊其他按鈕或是條件改變,我們往往需要主動取消這些尚在進行的請求,以避免不必要的流量、競爭條件(race condition)或是錯誤訊息干擾。

AbortController 正是為了解決這類需求而設計的 API。它提供了一個 「取消信號」(AbortSignal),讓開發者可以在任何時刻通知 fetch(或其他支援 AbortSignal 的 API)立即中止請求。掌握這個工具,不僅能提升使用者體驗,還能減少資源浪費與潛在的程式錯誤。

本篇文章將從概念、語法、實作範例到最佳實踐,完整說明如何在 JavaScript 中使用 AbortController 來管理與取消網路請求,適合 初學者到中階開發者 閱讀與實作。


核心概念

1. AbortController 與 AbortSignal

  • AbortController:一個控制器,用來產生與管理 取消訊號。它本身不會直接中止請求,而是提供一個 signal 屬性(AbortSignal)給其他 API 使用。
  • AbortSignal:一個只讀的物件,代表「是否已被取消」的狀態。當 AbortController.abort() 被呼叫時,signal.aborted 會變成 true,同時會觸發 signal 上的 abort 事件。
// 建立一個 AbortController
const controller = new AbortController();

// 取得對應的 AbortSignal
const signal = controller.signal;

// 監聽 abort 事件(可選)
signal.addEventListener('abort', () => {
  console.log('請求已被取消');
});

2. 在 fetch 中使用 AbortSignal

fetch 的第二個參數(init 物件)接受 signal 屬性。只要把 controller.signal 傳入,fetch 就會在 controller.abort() 被呼叫時自動中止。

fetch(url, { signal })
  .then(response => {
    // 正常處理回應
  })
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Fetch 被取消');
    } else {
      console.error('其他錯誤', err);
    }
  });

3. 為何需要手動取消?

情境 若不取消會發生什麼?
使用者快速切換搜尋關鍵字 先前的請求仍會回傳,可能覆蓋最新結果,導致 UI 顯示過時資料
表單提交重複點擊 多個相同請求同時發送,浪費頻寬與伺服器資源
長時間的下載/上傳 使用者離開頁面或取消操作,仍會持續佔用網路與記憶體
需要超時機制 fetch 本身沒有內建 timeout,必須自行結合 AbortController 實作

程式碼範例

以下示範 3~5 個實用範例,涵蓋基本取消、搜尋防抖、超時、串流取消與多請求協調。

範例 1️⃣:基本取消請求

// 取得資料的函式
function loadData(url) {
  const controller = new AbortController();
  const signal = controller.signal;

  // 5 秒後自動取消(示範用)
  const timeoutId = setTimeout(() => controller.abort(), 5000);

  fetch(url, { signal })
    .then(res => {
      clearTimeout(timeoutId); // 成功回應就清除 timeout
      if (!res.ok) throw new Error('Network response was not ok');
      return res.json();
    })
    .then(data => console.log('取得資料:', data))
    .catch(err => {
      if (err.name === 'AbortError') {
        console.warn('請求被使用者或 timeout 取消');
      } else {
        console.error('其他錯誤:', err);
      }
    });

  // 回傳 controller,讓外部可以自行 abort
  return controller;
}

// 使用
const ctrl = loadData('https://api.example.com/items');
// 例如使用者點了「取消」按鈕
document.getElementById('cancelBtn').addEventListener('click', () => ctrl.abort());

重點:controller.abort() 只需要呼叫一次,即可讓所有綁定的 fetch 立即中止。

範例 2️⃣:搜尋防抖(Debounce)+ 取消前一次請求

let lastController = null;

function search(query) {
  // 若前一次請求仍在執行,先取消
  if (lastController) lastController.abort();

  const controller = new AbortController();
  lastController = controller;

  fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`, {
    signal: controller.signal,
  })
    .then(res => res.json())
    .then(results => {
      // 只在最新的請求成功時更新 UI
      renderResults(results);
    })
    .catch(err => {
      if (err.name === 'AbortError') return; // 前一次請求被取消,無需顯示錯誤
      console.error(err);
    });
}

// 防抖函式(簡易版)
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

// 綁定輸入框
const input = document.getElementById('searchBox');
input.addEventListener('input', debounce(e => search(e.target.value), 300));

技巧:把 controller 存在變數中,讓每次呼叫 search 前先取消前一次的請求,避免競爭條件。

範例 3️⃣:實作 fetch 超時(Timeout)機制

function fetchWithTimeout(url, options = {}, timeout = 8000) {
  const controller = new AbortController();
  const { signal } = controller;
  const timer = setTimeout(() => controller.abort(), timeout);

  // 合併使用者傳入的 signal(如果有的話)
  const combinedSignal = signal;
  const fetchOptions = { ...options, signal: combinedSignal };

  return fetch(url, fetchOptions)
    .finally(() => clearTimeout(timer)) // 不管成功或失敗,都清除計時器
    .catch(err => {
      if (err.name === 'AbortError') {
        throw new Error(`請求逾時(超過 ${timeout}ms)`);
      }
      throw err;
    });
}

// 使用
fetchWithTimeout('https://api.example.com/slow-endpoint', {}, 3000)
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err.message));

說明:AbortController 可與 setTimeout 結合,實作出 自訂的 timeout,因為原生 fetch 沒有內建 timeout 功能。

範例 4️⃣:取消可讀取的流(ReadableStream)

async function streamLargeFile(url) {
  const controller = new AbortController();
  const signal = controller.signal;

  // 讓使用者可以透過按鈕取消下載
  document.getElementById('stopBtn').addEventListener('click', () => controller.abort());

  try {
    const response = await fetch(url, { signal });
    if (!response.body) throw new Error('此瀏覽器不支援 streaming');

    const reader = response.body.getReader();
    let received = 0;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      received += value.length;
      console.log(`已接收 ${received} 位元組`);
    }
    console.log('下載完成');
  } catch (err) {
    if (err.name === 'AbortError') {
      console.warn('下載被使用者取消');
    } else {
      console.error('下載錯誤', err);
    }
  }
}

// 執行
streamLargeFile('https://example.com/large-video.mp4');

重點:即使在 stream 讀取階段,只要 controller.abort() 被呼叫,reader.read() 會拋出 AbortError,讓程式能即時停止讀取。

範例 5️⃣:多個請求共享同一個 AbortSignal(一次取消多個請求)

function fetchMultiple(urls) {
  const controller = new AbortController();
  const signal = controller.signal;

  const promises = urls.map(u =>
    fetch(u, { signal })
      .then(r => r.json())
      .catch(err => {
        if (err.name === 'AbortError') {
          console.warn(`URL ${u} 被取消`);
        } else {
          console.error(`URL ${u} 錯誤`, err);
        }
      })
  );

  // 10 秒後全部取消
  setTimeout(() => controller.abort(), 10000);

  return Promise.allSettled(promises);
}

// 呼叫
fetchMultiple([
  'https://api.example.com/a',
  'https://api.example.com/b',
  'https://api.example.com/c',
]).then(results => console.log(results));

應用:在需要同時發送多筆請求(例如平行載入多個資源)時,只要一次 abort() 即可同時終止所有請求,省去逐一管理的麻煩。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記傳入 signal 建立 AbortController 後未把 signal 加到 fetch,導致 abort() 沒有效果。 確認 fetch(url, { signal }) 中的 signal 正確傳遞。
多次呼叫 abort() 重複呼叫不會產生錯誤,但可能在錯誤處理上產生多餘的訊息。 在呼叫前檢查 controller.signal.aborted,或只在需要時呼叫一次。
與其他庫衝突 某些第三方 HTTP 客戶端(如 Axios)在舊版中不支援 AbortSignal。 使用支援 AbortController 的版本,或自行在庫中加入 signal 參數。
忘記清除 setTimeout 若使用 timeout,未在成功回應時清除計時器,會在之後不必要觸發 abort()。 在 then 或 finally 中 clearTimeout(timer)。
在舊版瀏覽器無法使用 IE、舊版 Safari 不支援 AbortController。 透過 polyfill(如 abortcontroller-polyfill)或回退到 XMLHttpRequest。
錯誤判斷 只捕捉 err,未檢查 err.name === 'AbortError',導致取消請求被誤當成失敗。 在 catch 中先判斷 err.name 再決定處理方式。

最佳實踐:

  1. 一個請求一個 Controller:除非需要一次取消多個請求,否則每個 fetch 建議使用獨立的 AbortController,避免不小心取消到其他請求。
  2. 在 UI 元件生命周期內管理:例如在 React 的 useEffect 清除函式、Vue 的 beforeUnmount 中呼叫 abort(),確保元件卸載時不會留下懸掛的請求。
  3. 結合防抖 / 防重複提交:如搜尋框或表單送出,使用 AbortController 搭配防抖(debounce)或節流(throttle)可以大幅降低不必要的網路流量。
  4. 自訂 Timeout:將 AbortController 包裝成 fetchWithTimeout,在專案中統一使用,提升可維護性。
  5. 記得清理資源:clearTimeout、移除事件監聽器(signal.removeEventListener)都是防止記憶體洩漏的好習慣。

實際應用場景

場景 為什麼需要 AbortController 典型實作
即時搜尋(Autocomplete) 使用者快速輸入,舊的搜尋請求若完成會覆寫最新結果。 防抖 + 取消前一次請求(範例 2)
大檔案下載/串流 使用者可能在下載途中點「取消」或離開頁面。 取消 ReadableStream(範例 4)
表單重複送出 防止使用者因網路慢而多次點擊「送出」按鈕。 按鈕點擊時先 abort() 前一次請求
API 超時保護 某些 API 回應時間不穩定,需在限定時間內失敗。 fetchWithTimeout(範例 3)
多資源平行載入 首頁需要一次載入多張圖片、JSON、CSS 等。若使用者離開頁面,全部請求都應立即停止。 共享同一 AbortSignal(範例 5)
單頁應用(SPA)路由切換 路由變換時舊頁面的資料請求仍在進行,可能導致記憶體佔用或錯誤 UI 更新。 在路由守衛或 useEffect 清除函式中 abort()

總結

AbortController 為 JavaScript 非同步網路請求 提供了簡潔且強大的取消機制。透過以下步驟,你可以輕鬆將它納入日常開發:

  1. 建立 AbortController,取得 signal。
  2. 將 signal 傳入 fetch(或其他支援的 API)。
  3. 在適當時機呼叫 controller.abort()(使用者操作、超時、元件卸載等)。
  4. 在 catch 中辨識 AbortError,避免將取消當成真正錯誤顯示。

配合 防抖、節流、timeout 等技巧,AbortController 能有效降低不必要的流量、避免 UI 被過時資料污染,並提升使用者體驗。
在現代前端框架(React、Vue、Angular)中,將它與生命週期或 Hook 結合,更能確保資源在元件銷毀時即時釋放,避免記憶體洩漏。

掌握這項工具,你的前端程式碼將會更 健壯、可維護且具備彈性,在面對日益複雜的網路互動需求時,亦能從容應對。祝開發順利,玩得開心! 🎉