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 取数依然广泛存在,务必掌握
相关推荐
如果超人不会飞1 小时前
用TinyRobot Bubble组件打造灵活强大的AI对话气泡
前端·vue.js
橘子星1 小时前
打破串行枷锁:深入理解 JS 同步、异步与 Promise 实战
前端·javascript
用户059540174461 小时前
LangChain 记忆模块踩坑实录:靠自动化测试,我把上下文丢失率从 30% 降到 0
前端·css
kismet7871 小时前
fetch 正常,页面却 404?Nuxt 3 + CDN 跨域下的 preload CORS 陷阱
前端·产品
如果超人不会飞1 小时前
新手避坑:使用 TinyRobot 入门阶段常见误区总结
前端·vue.js
嘟嘟07171 小时前
二叉树从入门到实战:四大遍历 + 递归思想详解
前端
渣波1 小时前
全栈开发的“影分身”之术(mock):别再手动造数据了,你的 CRUD 不配让我等!
前端·javascript
亿元程序员1 小时前
小伙伴说这个撕胶带游戏很火很解压,于是我连夜做了一个Cocos教程...
前端
如果超人不会飞1 小时前
一文读懂 TinyRobot:前端 AI 组件库定位、价值与适用场景
前端·vue.js