Fetch 请求竞态终结者:AbortController 不只是用来"取消"的

问题场景

搜索框输入"深圳天气",快速删除再输入"北京天气",结果页面先显示北京、再闪变成深圳------请求竞态污染,老 Bug 了。

setTimeout 做防抖能缓解,但用户快速切换时依然复现。核心原因:后发的请求先回来,先发的请求后回来,状态被覆盖。

原因分析

js 复制代码
// 经典错误写法
async function search(keyword) {
  const res = await fetch(`/api/search?q=${keyword}`)
  setResults(await res.json())
}
  • 网络包顺序不可控,请求 B 先返回 → 覆盖请求 A 的结果
  • 组件卸载后继续 setState → 控制台报 "Can't perform a React state update"
  • 防抖只是降低概率,不解决根本问题

解决方案

AbortController 不止能做"取消",还能做请求序列化------每次新请求发出前,自动 abort 上一次未完成的请求。

js 复制代码
// 终极方案:用 AbortController 做"请求串联"
let currentController = null

async function search(keyword) {
  // 1. 中断上一次请求
  if (currentController) {
    currentController.abort()
  }

  // 2. 创建新控制器
  const controller = new AbortController()
  currentController = controller

  try {
    const res = await fetch(`/api/search?q=${keyword}`, {
      signal: controller.signal
    })
    const data = await res.json()

    // 3. 检查是否还是当前请求(避免 abort 后误更新)
    if (controller === currentController) {
      setResults(data)
    }
  } catch (err) {
    // AbortError 是正常行为,不需要报错
    if (err.name === 'AbortError') return
    handleError(err)
  }
}

实操代码:封装成通用 Hook

js 复制代码
// useLatestRequest.js --- 一行接入,杜绝竞态
import { useRef, useCallback } from 'react'

export function useLatestRequest() {
  const controllerRef = useRef(null)

  const run = useCallback(async (url, options = {}) => {
    controllerRef.current?.abort()
    const controller = new AbortController()
    controllerRef.current = controller
    
    const res = await fetch(url, {
      ...options,
      signal: controller.signal
    })
    const data = await res.json()
    
    if (controller !== controllerRef.current) return
    return data
  }, [])

  const cancel = useCallback(() => {
    controllerRef.current?.abort()
  }, [])

  return { run, cancel }
}

使用:

jsx 复制代码
function SearchBox() {
  const { run } = useLatestRequest()
  const [results, setResults] = useState([])

  const handleSearch = async (kw) => {
    const data = await run(`/api/search?q=${kw}`)
    if (data) setResults(data)
  }

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

要点总结

场景 旧方案 推荐方案
搜索框 防抖 + flag AbortController 串联
分页切换 loading + 忽略旧结果 同上
组件卸载 isUnmounted ref 在 useEffect cleanup 中 abort
详情页跳转 无处理 跳转时 abort 前页请求

值得注意:

  • fetch 原生支持 signal零依赖
  • axiosCancelToken 已被废弃,官方推荐用 AbortController(v0.22+ 支持 signal
  • aborted 的请求不会收到响应体,节省带宽
  • 配合 AbortSignal.timeout(5000) 可以同时做超时处理
js 复制代码
// 超时 + 竞态双杀
const { run } = useLatestRequest()

// 5秒超时
const data = await run(url, {
  signal: AbortSignal.timeout(5000)
})

下次遇到请求竞态,别再往 useEffect 里塞 flag 了------AbortController 一行 controller.abort() 干净利落。

相关推荐
阡陌Jony1 小时前
关于前端路由中的参数问题的学习(一): params,query, hash(#)
前端
阡陌Jony1 小时前
缓存相关学习笔记(一):Service Worker 缓存
前端
假如让我当三天老蒯1 小时前
前端跨域解决方案(学习用)
前端·javascript·面试
阡陌Jony1 小时前
关于前端路由中的参数问题的学习(二)
前端
IT_陈寒2 小时前
SpringBoot自动配置这个坑,我踩进去又爬出来了
前端·人工智能·后端
runnerdancer11 小时前
LLM是怎么处理messages数组的,提示词缓存又是什么
前端·agent
陈随易12 小时前
VSCode的Copilot扩展支持接入DeepSeek,Kimi了!
前端·后端·程序员
我不是外星人13 小时前
有了 Harness Engineering ,真的还需要研发工程师吗?
前端·后端·ai编程
IT_陈寒16 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端