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,零依赖
  • axios 的 CancelToken 已被废弃,官方推荐用 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() 干净利落。

相关推荐
Initialize-le22 分钟前
systemd 服务起不来?五步排查法
前端·chrome
OpenTiny社区26 分钟前
多会话 + 流式输出怎么落地?TinyRobot Kit 对话数据层深度解析
前端·vue.js
派小心.1 小时前
网站统计采样数据的误差核对
大数据·服务器·前端·数据库·人工智能·小程序·数据分析
变与不变8062 小时前
防抖节流详解
开发语言·前端·javascript
用户72526496629692 小时前
浏览器为什么加载不了超大图片
前端·javascript
酷炫码神2 小时前
Day 2|CSS 盒模型:把昨天的骨架,变成能看的页面
前端·css
Amos_Web2 小时前
我做了一个 Chrome 插件,专治“这篇文章我明明看过,但重点去哪了?”
前端·浏览器
abcyuu2 小时前
用友 U8 Cloud 生产制造模块深度解析:从 BOM 建立到成本核算的技术实现
前端·数据库·制造
零基础1232 小时前
为什么 Agent 用 SQLite 数据库?
前端·人工智能·sqlite·开源
xuyanzhuqing3 小时前
如何在一周内将一个巨石工程实现国际化
前端