问题场景
你在做一个搜索下拉组件,用户输入关键词后自动请求后端接口:
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才是生产级方案,能真正取消网络请求- 自定义
useFetchHook 将竞态处理集中封装,团队统一使用避免重复踩坑 - 配合 防抖(Debounce) 可以减少无效请求,请求量降低 80%+
- 搜索/分页/Tab 切换/路由跳转------所有"频繁变化参数 + 异步请求"的场景都需要考虑竞态处理
- React 18 的 Suspense + useTransition 未来可能改变这个模式,但传统 useEffect 取数依然广泛存在,务必掌握