AbortController 实战:竞态取消、超时兜底与请求生命周期管理

15 阅读8分钟

AbortController 实战:竞态取消、超时兜底与请求生命周期管理

项目越大,请求越多,Bug 越诡异。 你一定见过这些场景:

  • 搜索结果偶尔“闪一下又变回旧数据”
  • 提交按钮点快了,后台多出几条脏数据
  • 页面都切走了,接口还在跑,甚至回来还触发 setState warning

这些问题看起来毫无规律,但本质上只是一件事:

请求没有被正确“结束”。

大部分团队会优化接口、加缓存、做防抖,却很少有人认真思考:

一个请求,什么时候应该继续?什么时候必须终止?

AbortController 应该在项目初期就被当作基础设施来搭建,而不是等线上出了问题才到处打补丁。这篇文章是我踩完所有坑之后的经验沉淀,把竞态取消、超时控制、组件卸载清理这几个场景串起来,给出一套在 React 和 Vue 中都能落地的防御性编排方案。

竞态取消:搜索场景的三种方案对比

竞态问题是异步请求里最常见也最容易被忽视的坑。回到搜索框的例子,用户快速输入,多个请求并发,我们只关心最后一次的结果。怎么确保展示的一定是最新请求的数据?

方案一:标记法(能用,但粗糙)

最朴素的思路——给每次请求打一个版本号,回调里检查是不是最新版本。

let currentRequestId = 0

async function search(keyword: string) {
  const requestId = ++currentRequestId
  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  if (requestId === currentRequestId) {
    setResults(data) // 版本号匹配才更新 UI
  }
}

实现简单、零依赖,但有一个明显的问题:请求并没有被真正取消。"杭"的请求还是跑完了全部流程,占了带宽和连接池,只是回调里没处理结果而已。我们项目初期就是用的这个方案,当时觉得"能用就行"。后来在性能分析里发现,搜索页面在快速输入时,Network 面板里密密麻麻全是 pending 请求,Chrome 的同域 6 连接上限直接被打满,导致其他关键请求(比如用户鉴权、埋点上报)被阻塞。

方案二:纯 AbortController(真正取消请求)

标记法的核心缺陷是请求仍然在跑,只是忽略了结果。AbortController 可以从网络层真正中断请求,释放连接。

let currentController: AbortController | null = null

async function search(keyword: string) {
  currentController?.abort() // 取消上一个请求
  const controller = new AbortController()
  currentController = controller

  try {
    const res = await fetch(`/api/search?q=${keyword}`, {
      signal: controller.signal
    })
    const data = await res.json()
    setResults(data)
  } catch (err) {
    if ((err as DOMException).name !== 'AbortError') throw err
    // AbortError 说明是我们主动取消的,静默忽略
  }
}

相比标记法,abort() 调用后浏览器会立即中断 TCP 连接(或阻止请求发出),被取消的请求在 Network 面板中会显示为 (canceled) 状态,不再占用同域的 6 个并发连接。缺点是在高频输入场景下,每次按键都会发出一个请求然后立即取消上一个,虽然连接被释放了,但请求的绝对数量仍然很多,服务端压力并没有减轻。所以对于搜索框这类场景,还需要配合防抖进一步优化。

方案三:防抖 + AbortController(生产环境的选择)

单纯的 AbortController 取消还不够,还需要配合防抖来减少请求频率。这里有个容易搞错的地方:防抖和取消的职责不一样,不能互相替代。防抖解决的是"减少发出的请求数量",取消解决的是"已经发出的请求不再需要了"。两个要一起用。

function useDebouncedSearch(delay = 300) {
  const controllerRef = useRef<AbortController | null>(null)
  const timerRef = useRef<number>()
  const [results, setResults] = useState([])

  const search = useCallback((keyword: string) => {
    clearTimeout(timerRef.current) // 清掉防抖定时器

    timerRef.current = window.setTimeout(async () => {
      controllerRef.current?.abort() // 取消上一个还在飞的请求
      const controller = new AbortController()
      controllerRef.current = controller

      try {
        const res = await fetch(`/api/search?q=${keyword}`, {
          signal: controller.signal
        })
        setResults(await res.json())
      } catch (err) {
        if ((err as DOMException).name !== 'AbortError') throw err
      }
    }, delay)
  }, [delay])

  useEffect(() => () => {
    clearTimeout(timerRef.current)
    controllerRef.current?.abort()
  }, [])

  return { results, search }
}

防抖定时器和 AbortController 各管各的:防抖控制"什么时候发请求",AbortController 控制"已经发出的请求要不要保留"。组件卸载时两个都要清理,缺一不可。我们项目最终落地的就是这个方案。改完之后,搜索页面的无效请求从平均每次搜索 5-6 个降到了 0-1 个,Network 面板终于清爽了。

三种方案放在一起对比:

方案请求真正取消实现复杂度适用场景
标记法否,幽灵请求仍占连接小项目、低频请求
AbortController是,网络层中断大多数场景
防抖 + AbortController是,且减少请求频次搜索、筛选等高频输入场景

React 中的异步副作用编排

useEffect 中的正确姿势

function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState(null)

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

    async function loadProfile() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })
        setProfile(await res.json())
      } catch (err) {
        if ((err as DOMException).name === 'AbortError') return
        console.error('加载用户信息失败:', err)
      }
    }
    loadProfile()

    return () => controller.abort()
  }, [userId])

  return <div>{profile?.name}</div>
}

这段代码看起来简单,有两个细节容易踩坑。

AbortController 的创建必须在 useEffect 内部。因为每次 effect 执行需要一个独立的 controller 实例,放外面会被多个 effect 共享,取消逻辑就乱了。

async 函数不能直接作为 useEffect 的回调——effect 要求返回 cleanup 函数或 undefined,不能返回 Promise。所以需要在内部定义一个 async 函数再调用。这个限制看起来别扭,但它强制你把"发请求"和"清理"分开思考,反而减少了遗漏 cleanup 的概率。

封装通用的 useAbortableFetch

当团队有十几个页面都需要这种模式时,重复写 AbortController 的样板代码就不合适了。我们封装了一个自定义 Hook:

function useAbortableFetch<T>(url: string | null, options?: { timeout?: number }) {
  const [state, setState] = useState<{
    data: T | null; loading: boolean; error: Error | null
  }>({ data: null, loading: false, error: null })

  useEffect(() => {
    if (!url) return

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}
    const signal = AbortSignal.any
      ? AbortSignal.any([controller.signal, AbortSignal.timeout(timeout)])
      : controller.signal

    setState(prev => ({ ...prev, loading: true, error: null }))

    fetch(url, { signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name === 'AbortError' || err.name === 'TimeoutError') return
        setState({ data: null, loading: false, error: err })
      })

    return () => controller.abort()
  }, [url])

  return state
}

url 为 null 时不发请求,方便做条件请求;url 变了自动重新请求,旧请求自动取消;超时和手动取消合并在一个信号里处理。用起来非常干净:

function SearchPage() {
  const [keyword, setKeyword] = useState('')
  const debouncedKeyword = useDebounce(keyword, 300)

  const { data, loading, error } = useAbortableFetch<SearchResult[]>(
    debouncedKeyword ? `/api/search?q=${debouncedKeyword}` : null
  )

  return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}

手动触发场景的处理

表单提交、批量操作这类请求的特殊之处在于:取消的触发时机不是依赖变化,而是"重复操作"或"组件卸载"。

function useAbortableAction<T>() {
  const controllerRef = useRef<AbortController | null>(null)

  const execute = useCallback(async (
    asyncFn: (signal: AbortSignal) => Promise<T>
  ): Promise<T | undefined> => {
    controllerRef.current?.abort() // 新操作来了,取消上一个(防连点)
    const controller = new AbortController()
    controllerRef.current = controller

    try {
      return await asyncFn(controller.signal)
    } catch (err) {
      if ((err as DOMException).name === 'AbortError') return undefined
      throw err
    }
  }, [])

  useEffect(() => () => { controllerRef.current?.abort() }, [])
  return execute
}

使用时,把 signal 透传给请求函数即可:

const executeAction = useAbortableAction()

const handleSubmit = async (formData: OrderData) => {
  const result = await executeAction(signal =>
    fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(formData),
      signal
    }).then(r => r.json())
  )
  if (result) navigate(`/orders/${result.id}`)
}

这里有个需要权衡的地方:POST 请求真的应该取消吗?连点两次提交按钮,取消第一个 POST 请求——网络层面是中断了,但后端可能已经处理了一半。所以对于写操作,AbortController 更多是解决"组件卸载后不再处理回调"的问题,而不是真的指望后端能回滚。防重复提交还是要靠按钮 loading 状态锁定 + 后端幂等校验。

Vue 中的 Composable 实现

在 Vue 中,watchEffect 提供的 onCleanup 回调天然适合管理请求生命周期,写起来比 React 的 useEffect 更直观。下面是与前文 useAbortableFetch 对等的 Vue Composable 实现:

import { ref, watchEffect, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

function useFetchData<T>(url: MaybeRefOrGetter<string | null>, options?: { timeout?: number }) {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<Error | null>(null)

  watchEffect((onCleanup) => {
    const resolvedUrl = toValue(url)
    if (!resolvedUrl) {
      data.value = null
      loading.value = false
      return
    }

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}

    // 注册清理函数:依赖变化或组件卸载时自动调用
    onCleanup(() => controller.abort())

    loading.value = true
    error.value = null

    const timeoutId = setTimeout(() => controller.abort(), timeout)

    fetch(resolvedUrl, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(json => {
        data.value = json
        loading.value = false
      })
      .catch(err => {
        if (err.name === 'AbortError') return // 主动取消,静默忽略
        error.value = err
        loading.value = false
      })
      .finally(() => clearTimeout(timeoutId))
  })

  return { data, loading, error }
}

使用方式同样干净,响应式的 URL 变化会自动触发重新请求并取消旧请求:

<script setup lang="ts">
import { ref, computed } from 'vue'

const keyword = ref('')
const debouncedKeyword = useDebouncedRef(keyword, 300) // 假设已有防抖 ref 工具
const apiUrl = computed(() =>
  debouncedKeyword.value ? `/api/search?q=${debouncedKeyword.value}` : null
)

const { data: results, loading, error } = useFetchData<SearchResult[]>(apiUrl)
</script>

<template>
  <input v-model="keyword" />
  <div v-if="loading">搜索中...</div>
  <ul v-else-if="results">
    <li v-for="item in results" :key="item.id">{{ item.title }}</li>
  </ul>
</template>

Vue 的 onCleanup 和 React 的 useEffect return 做的是同一件事,但 Vue 的写法有个优势:onCleanup 在 effect 函数体内调用,和创建 AbortController 的代码紧挨着,不容易遗漏。React 里 cleanup 写在函数末尾的 return 里,和请求代码隔得比较远,review 时容易看漏。

边界场景与防御性思维

并发请求的批量取消

页面初始化时可能要同时发五六个请求,用户切走了要一次性全取消。核心技巧是共享一个 signal,配合 Promise.allSettled 处理结果:

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

  Promise.allSettled([
    fetch('/api/user/info', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/user/permissions', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/dashboard/stats', { signal: controller.signal }).then(r => r.json()),
  ]).then(results => {
    const [userResult, permResult, statsResult] = results

    if (userResult.status === 'fulfilled') {
      setUserInfo(userResult.value)
    }
    if (permResult.status === 'fulfilled') {
      setPermissions(permResult.value.permissions)
    }
    if (statsResult.status === 'fulfilled') {
      setDashboardStats(statsResult.value)
    }
  })

  return () => controller.abort()
}, [])

为什么用 Promise.allSettled 而不是 Promise.all?因为 Promise.all 在任何一个请求 reject 时就会整体 reject,而 abort 会导致所有请求同时 reject,你拿不到任何有用信息。allSettled 等所有请求都有结果后才 resolve,让你能精细地处理每个请求——哪些成功了用数据,哪些被取消了忽略,哪些真正失败了需要报错。

SSR 和 Node.js 环境

如果你的项目有 SSR(Next.js、Nuxt),请求取消在服务端同样重要。Node.js 18+ 的 fetch 原生支持 AbortController,但服务端的超时策略需要比客户端更激进——SSR 请求阻塞的是页面渲染,用户在白屏面前的耐心远低于面对 loading 动画:

async function getServerSideProps() {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 3000) // SSR 超时建议 3-5 秒

  try {
    const res = await fetch('http://internal-api/data', {
      signal: controller.signal
    })
    clearTimeout(timer)
    return { props: { data: await res.json() } }
  } catch {
    clearTimeout(timer)
    return { props: { data: null } } // 超时降级,先渲染页面骨架
  }
}

在 Node.js 环境还要注意一点:没有浏览器的 6 连接上限,但有内存泄漏风险。如果请求没有超时控制,在高并发时挂起的请求会持续占用内存,最终可能 OOM。

不适用的场景

AbortController 不是银弹,有几种场景不适合或者需要特殊处理。

WebSocket 连接有自己的生命周期管理(close()),不需要也不能用 AbortController。写操作的取消要谨慎——POST/PUT/DELETE 请求,前端取消了但后端可能已经处理了,关键写操作的幂等性要在后端保证。**流式响应(SSE / ReadableStream)**虽然技术上可以用 abort() 中断,但要区分场景:AI 对话场景下用户点"停止生成",abort() 是合理的;大文件分片上传中途取消,断点续传的状态恢复逻辑需要额外处理,单靠 abort() 解决不了。

从取消到编排:一个通用模型

回顾全文的内容,请求取消只是一个切入点,背后的通用模型是异步操作的生命周期管理。任何异步操作——请求、定时器、动画、Web Worker 通信——都应该具备三个能力:启动、取消、超时。缺了任何一个,在项目规模变大后都会出问题。这个模型可以这样理解:

异步操作生命周期:

  启动 ──→ 运行中 ──→ 成功 / 失败
    │          │
    │          ├── 手动取消(用户操作 / 依赖变化 / 组件卸载)
    │          │
    │          └── 超时取消(兜底机制)
    │
    └── 创建即取消(signal 已 aborted,立即中止)

在 React 中,这个生命周期对应的是 useEffect 的"执行-清理"周期;在 Vue 中,是 watchEffect 的"执行-onCleanup"周期。框架不同,模型一致。

如果你正在做的项目还没有统一的请求生命周期管理,我的建议是分三步推进。第一步,在请求层封装一个带超时和取消能力的基础函数(类似前面的 createManagedSignal)。第二步,在框架层封装对应的 Hook / Composable(类似 useAbortableFetchuseFetchData),让业务代码不需要直接接触 AbortController。第三步,在 code review 和 CI 中把"有没有处理取消"作为一个检查项。

我们团队落地这套方案之后,做了一次前后数据对比:

指标改造前改造后
搜索场景无效请求数(每次搜索)5-6 个0-1 个
超时相关客服工单(每周)10+1-2
页面切换后的 setState warning频繁出现完全消除
请求层代码重复率每个页面各写一套统一收口到 2 个 Hook/Composable
CI 自定义 lint 规则上线首周拦截的遗漏14 处

现在我们的标准是:每个 useEffect / watchEffect 里如果有 fetch 调用,必须在 cleanup 里调用 abort(),否则 CI 的自定义 lint 规则会报错。这条规则的 ROI 极高——写规则花了半天,上线一周就拦住了 14 处遗漏,每一处都是潜在的线上 bug。

回头看,请求生命周期管理这件事并不复杂。AbortController 的 API 就那么几个,封装成 Hook / Composable 也不超过 30 行代码。真正难的是在项目早期就意识到它的重要性,把它作为基础设施搭好,而不是等线上出了问题才到处救火。