React useEffect 异步竞态:90% 的人都踩过的坑

问题场景

你在做一个搜索下拉组件,用户输入关键词后自动请求后端接口:

tsx 复制代码
// ❌ 常见写法,埋了竞态地雷
const SearchSuggest = ({ query }: { query: string }) => {
  const [suggestions, setSuggestions] = useState<string[]>([])

  useEffect(() => {
    fetch(`/api/suggest?q=${query}`)
      .then(res => res.json())
      .then(data => setSuggestions(data))
  }, [query])

  return <ul>{suggestions.map(s => <li key={s}>{s}</li>)}</ul>
}

看起来很对?测试一下:

用户输入 "rea" → 请求 A 发出(耗时 300ms) 用户继续输入 "react" → 请求 B 发出(耗时 100ms) 用户又输入 "react h" → 请求 C 发出(耗时 200ms)

结果:

css 复制代码
请求 B 先回来(100ms) → setSuggestions(["react"])
请求 A 才回来(300ms) → setSuggestions(["rea"])   ← ❌ 把正确的数据覆盖了!
请求 C 回来(200ms) → setSuggestions(["react hook"])

用户明明搜的是 "react hook",结果 2 秒内页面闪现三组数据后,最终显示了根本不完整的 ["rea"]。这对用户体验是灾难性的。

原因分析

问题的本质是 异步请求的响应顺序不可控

  • 请求 B(最新)先返回 → 更新状态 ✅
  • 请求 A(过期)后返回 → 覆盖状态 ❌

React 不会替你判断"哪个响应是新哪个响应旧"。setSuggestions 是个纯函数,谁来调用它,它就被谁覆盖。

这种现象叫做 Race Condition(竞态条件)------多个异步操作竞争同一个状态写入权,后发先至造成数据不一致。

不止是搜索场景,分页列表切换、Tab 切换加载、详情页路由跳转......只要组件 Props/State 变化触发新请求,而旧请求还在 pending,就会出现竞态。

解决方案(3 种,由简到优)

方案一:useEffect Cleanup + 布尔守卫(最基础)

tsx 复制代码
useEffect(() => {
  let ignore = false  // 👈 关键守卫变量

  fetch(`/api/suggest?q=${query}`)
    .then(res => res.json())
    .then(data => {
      if (!ignore) {  // 👈 组件未卸载/query未变化才写入
        setSuggestions(data)
      }
    })

  return () => {
    ignore = true     // 👈 组件卸载或 query 变化时标记过期
  }
}, [query])

原理: 每次 query 变化时,上一次的 cleanup 先执行,把 ignore 置为 true。旧请求的 .then 即便执行了也不会修改状态。

⚠️ 但这个方案只能阻止卸载后 的回调,如果旧请求超慢、新请求更快,旧请求仍然可能在新请求之前执行 .then 并写入。单纯 ignore 不够可靠。


方案二:AbortController 取消请求(推荐生产使用)

tsx 复制代码
useEffect(() => {
  const controller = new AbortController()   // 👈 创建取消控制器

  fetch(`/api/suggest?q=${query}`, {
    signal: controller.signal                // 👈 绑定 signal
  })
    .then(res => res.json())
    .then(data => setSuggestions(data))
    .catch(err => {
      if (err.name !== 'AbortError') {       // 👈 忽略主动取消的错误
        console.error('请求失败:', err)
      }
    })

  return () => {
    controller.abort()                       // 👈 组件卸载/query变化时取消上次请求
  }
}, [query])

为什么更好?

方案 优点 缺点
ignore 守卫 简单易懂 请求仍在背后运行,浪费带宽
AbortController 实际取消 HTTP 请求,节省资源 需要浏览器支持(>Chrome 66,无需担心)

同时 AbortController 是 100% 可靠的 ------旧请求的 promise 被 reject,永远不可能再调用 setSuggestions

fetch 默认支持,不需要额外库。如果你用 axios,等价的是 CancelToken(v0.22+ 用 AbortSignal)。


方案三:自定义 Hook 封装(一劳永逸)

把竞态处理封装进 useFetch,全项目通用:

tsx 复制代码
// useFetch.ts
import { useEffect, useState, useRef } from 'react'

function useFetch<T>(url: string): { data: T | null; loading: boolean; error: Error | null } {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    let ignore = false
    const controller = new AbortController()
    setLoading(true)

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json() as Promise<T>
      })
      .then(res => {
        if (!ignore) {
          setData(res as T)
          setError(null)
        }
      })
      .catch(err => {
        if (err.name === 'AbortError') return  // 静默忽略
        if (!ignore) {
          setError(err)
          setData(null)
        }
      })
      .finally(() => {
        if (!ignore) setLoading(false)
      })

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

  return { data, loading, error }
}

使用:

tsx 复制代码
const SearchSuggest = ({ query }: { query: string }) => {
  const { data, loading } = useFetch<string[]>(`/api/suggest?q=${query}`)

  if (loading) return <p>加载中...</p>
  return <ul>{data?.map(s => <li key={s}>{s}</li>)}</ul>
}

进一步优化: 配合 lodash.debounceuseDeferredValue 做防抖,减少请求频率,效果更佳。

实操代码:完整搜索组件(含防抖 + AbortController)

tsx 复制代码
import { useState, useEffect, useRef, useMemo } from 'react'
import { debounce } from 'lodash-es'

const SearchPage = () => {
  const [input, setInput] = useState('')
  const [query, setQuery] = useState('')
  const [suggestions, setSuggestions] = useState<string[]>([])
  const [loading, setLoading] = useState(false)

  // 防抖:用户停止输入 300ms 后设为正式查询词
  const debouncedSetQuery = useMemo(
    () => debounce((val: string) => setQuery(val), 300),
    []
  )

  useEffect(() => {
    debouncedSetQuery(input)
    return () => debouncedSetQuery.cancel()
  }, [input])

  // 请求竞态保护
  useEffect(() => {
    if (!query) {
      setSuggestions([])
      return
    }

    const controller = new AbortController()
    setLoading(true)

    fetch(`/api/suggest?q=${encodeURIComponent(query)}`, {
      signal: controller.signal
    })
      .then(res => res.json())
      .then(data => {
        setSuggestions(data)
        setLoading(false)
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error(err)
          setLoading(false)
        }
      })

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

  return (
    <div>
      <input
        value={input}
        onChange={e => setInput(e.target.value)}
        placeholder="输入搜索关键词..."
      />
      {loading && <span>搜索中...</span>}
      <ul>
        {suggestions.map((s, i) => (
          <li key={i}>{s}</li>
        ))}
      </ul>
    </div>
  )
}

要点总结

  • 竞态的本质:异步请求后发先至,旧请求覆盖新响应
  • ignore 守卫 最低成本,适合简单场景;AbortController 才是生产级方案,能真正取消网络请求
  • 自定义 useFetch Hook 将竞态处理集中封装,团队统一使用避免重复踩坑
  • 配合 防抖(Debounce) 可以减少无效请求,请求量降低 80%+
  • 搜索/分页/Tab 切换/路由跳转------所有"频繁变化参数 + 异步请求"的场景都需要考虑竞态处理
  • React 18 的 Suspense + useTransition 未来可能改变这个模式,但传统 useEffect 取数依然广泛存在,务必掌握
相关推荐
kyriewen1 天前
Anthropic 估值逼近万亿美元,Claude Sonnet 5 + Claude Science 一天两连发
前端·ai编程·claude
小徐_23331 天前
Wot UI 2.2.0 发布:Button 新增 subtle,VideoPreview 预览体验继续增强
前端·微信小程序·uni-app
天蓝色的鱼鱼1 天前
关于 CSS 你可能不知道的属性,但关键时刻很有用
前端·css
泯泷1 天前
第 2 篇:设计第一套字节码:Opcode、Instruction 与 Constant Pool
前端·javascript·安全
妙码生花1 天前
从 PHP 到 AI + Golang,程序员自救转型手记(十五):优化细节、网络请求封装
前端·后端·ai编程
泯泷1 天前
第 1 篇:从 1 + 2 开始:亲手写出第一台 JSVM
前端·javascript·安全
团团崽_七分甜1 天前
Spring Boot 核心知识点总结
前端
lichenyang4531 天前
从一个按钮开始,理解 ASCF 框架到底在做什么
前端
古夕1 天前
第三方 SSO 接入实践:redirect_uri 编码、回调一致性与跨项目联调
前端·vue.js