返回首页

AbortController:取消 fetch 请求与解决竞态的标准姿势

用户快速切换 tab,旧请求的响应后回来覆盖了新数据——这种竞态 bug 很隐蔽。AbortController 是 Web 平台取消异步操作的标准方案。这篇讲清它怎么取消 fetch、处理竞态、配合超时,以及框架里的用法。

14 约 4 分钟 · 5373 字 前端

有一类 bug 很隐蔽:用户在两个 tab 间快速切换,或者在搜索框里快速改关键词,最后界面显示的数据对不上当前的选择

原因是请求竞态(race condition):先发的请求后回来,把后发请求的结果覆盖了。要根治它,需要能"取消不再需要的请求"。AbortController 就是 Web 平台为此提供的标准方案。

AbortController 是什么

AbortController 是一个用来"发取消信号"的对象。它配一个 signal,把 signal 交给支持中断的异步 API(最典型的是 fetch),之后调用 controller.abort() 就能中断它。

const controller = new AbortController()

fetch('/api/data', { signal: controller.signal })
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((err) => {
    if (err.name === 'AbortError') {
      console.log('请求被取消了')
    } else {
      console.error('请求出错', err)
    }
  })

// 在需要的时候取消
controller.abort()

几个要点:

  • controller.signal 是一个 AbortSignal,传给 fetchsignal 选项。
  • 调用 controller.abort() 后,正在进行的 fetch 会 reject,错误的 name'AbortError'
  • 一个 controller 用一次:abort 之后这个 signal 就永久处于 aborted 状态,再用它发请求会立刻被取消。要发新请求得 new 一个新的 controller。

场景一:搜索联想中的竞态

经典竞态场景:用户输入"a"、"ab"、"abc",发了三个请求。如果"ab"的响应比"abc"晚到,界面最后显示的就是"ab"的结果——和输入框里的"abc"对不上。

解决办法:发新请求前,先取消上一个

let controller = null

async function search(keyword) {
  // 取消上一次还没回来的请求
  if (controller) controller.abort()
  controller = new AbortController()

  try {
    const res = await fetch(`/api/search?q=${keyword}`, { signal: controller.signal })
    const data = await res.json()
    render(data) // 只有最新的请求能走到这里
  } catch (err) {
    if (err.name !== 'AbortError') throw err // 取消是预期行为,忽略;其它错误照常抛
  }
}

这样无论响应顺序如何,旧请求都被取消了,只有最新的请求结果会渲染。这比"用时间戳判断哪个是最新响应"更彻底——旧请求直接不占用网络和处理。

场景二:配合超时自动取消

fetch 本身没有超时机制。用 AbortControllersetTimeout 就能实现"超过 N 秒自动取消":

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

  return fetch(url, { ...options, signal: controller.signal })
    .finally(() => clearTimeout(timer)) // 请求结束(成功/失败)都清掉定时器
}

现代浏览器还提供了更简洁的 AbortSignal.timeout(),专门用于超时:

fetch('/api/data', { signal: AbortSignal.timeout(8000) })

AbortSignal.timeout(8000) 返回一个 8 秒后自动 abort 的 signal,不用自己管定时器。

场景三:同时取消多个请求

一个 controller 的 signal 可以传给多个 fetch,一次 abort() 全部取消。适合"离开页面时取消该页所有在途请求":

const controller = new AbortController()
const { signal } = controller

Promise.all([
  fetch('/api/user', { signal }),
  fetch('/api/posts', { signal }),
  fetch('/api/stats', { signal }),
])

// 一次性取消上面三个
controller.abort()

反过来,AbortSignal.any([...]) 可以把多个 signal 合并,任意一个触发就取消(比如"超时"或"用户手动取消"任一发生):

const userCancel = new AbortController()
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(10000)])
fetch('/api/data', { signal }) // 超时 10s 或用户点取消,都会中断

不只 fetch:监听 abort 事件

AbortSignal 不是 fetch 专属的。任何异步操作都能监听它的 abort 事件来响应取消,所以你可以用它给自己的异步逻辑加"可取消"能力:

function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms)

    signal?.addEventListener('abort', () => {
      clearTimeout(timer)
      reject(new DOMException('Aborted', 'AbortError'))
    })
  })
}

addEventListener 也支持 signal 选项,用来自动移除监听器——这是个很优雅的清理技巧:

const controller = new AbortController()

window.addEventListener('resize', onResize, { signal: controller.signal })
window.addEventListener('scroll', onScroll, { signal: controller.signal })

// 一次 abort,上面两个监听器全部自动移除,不用逐个 removeEventListener
controller.abort()

框架里的用法

Vue 3 组件卸载时取消在途请求,避免对已销毁组件 setState / 赋值:

<script setup>
import { ref, onUnmounted } from 'vue'

const data = ref(null)
const controller = new AbortController()

async function load() {
  try {
    const res = await fetch('/api/data', { signal: controller.signal })
    data.value = await res.json()
  } catch (err) {
    if (err.name !== 'AbortError') console.error(err)
  }
}
load()

onUnmounted(() => controller.abort()) // 组件销毁,取消请求
</script>

React 里在 useEffect 的清理函数里 abort,是处理"组件卸载/依赖变化"竞态的标准写法:

useEffect(() => {
  const controller = new AbortController()

  fetch(`/api/user/${id}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== 'AbortError') console.error(err)
    })

  return () => controller.abort() // 清理:id 变化或卸载时取消上一个请求
}, [id])

常见坑

第一,把 AbortError 当成真错误。 abort 是你主动触发的预期行为,catch 里要先判断 err.name === 'AbortError' 并忽略它,否则会弹出一堆"请求失败"的误报。

第二,复用已经 abort 的 controller。 controller 是一次性的,abort 后必须 new 一个新的。常见错误是把 new AbortController() 放在了循环外只创建一次。

第三,只取消了请求,没处理"取消后还要不要更新状态"。 取消请求只是不再等它的结果,但如果你的代码里 abort 后又走到了赋值逻辑,仍可能出问题。竞态场景下,确保被取消的分支不会再改 UI。

第四,以为 abort 能停止服务器处理。 abort 只是客户端不再接收响应,服务器可能已经处理完了(比如已经写了数据库)。它解决的是"前端别用过期响应",不是"撤销服务端操作"。

总结

AbortController 是 Web 平台取消异步操作的统一标准,核心用法就一套:

const controller = new AbortController()
fetch(url, { signal: controller.signal })
controller.abort()

记住几个要点:

  • 竞态:发新请求前 abort 旧请求(搜索联想、tab 切换)。
  • 超时:AbortSignal.timeout(ms) 或自己配 setTimeout
  • 批量:一个 signal 传给多个 fetch,一次取消;AbortSignal.any 合并多个信号。
  • 清理:addEventListenersignal 选项,自动移除监听器。
  • catch 里放过 AbortError,它是预期行为不是错误。

把它用顺手,那些"数据对不上"的竞态 bug 就能从根上消失。