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.debounce 或 useDeferredValue 做防抖,减少请求频率,效果更佳。

实操代码:完整搜索组件(含防抖 + 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 取数依然广泛存在,务必掌握
相关推荐
卸任5 小时前
Vibe coding工具使用心得
前端·ai编程
brien96 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance6 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M6 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写6 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李6 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易6 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马7 小时前
前沿视界
前端
kyriewen7 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易7 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员