问题场景
搜索框输入"深圳天气",快速删除再输入"北京天气",结果页面先显示北京、再闪变成深圳------请求竞态污染,老 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() 干净利落。