什么是异步竞态条件
当多个异步操作并发执行,而完成顺序与启动顺序不一致时,就会出现竞态条件。晚到的结果会覆盖正确的、更新的结果。
典型场景:用户先输入 abc(触发请求 1),随后输入 abcd(触发请求 2)。如果请求 2 的网络延迟恰好低于请求 1,那么 abcd 的响应先到达,随后请求 1 的 abc 响应到达并覆盖正确状态。
症状
- 搜索框结果先短暂显示正确内容,随后回退到旧结果
- 页面展示的数据与最新输入不一致
- React 控制台出现 “Can’t perform state update on unmounted component” 警告
- 发起新查询后,数据表仍显示上一次查询的行
诊断步骤
1. 在 Network 面板观察请求与响应时序
打开 Chrome DevTools → Network → 按 Waterfall 列排序。如果后发出的请求 B 在响应时间线上早于先发出的请求 A,就存在竞态。注意响应体大小:小响应常常先于大响应到达,即使发送顺序更晚。
2. 为请求添加 ID 以追踪状态更新
为每次状态更新附带时间戳或递增计数器,并在 setState 旁记录请求 ID。如果看到 setState(5) 之后又出现 setState(3),说明旧响应覆盖了新响应。
3. 限速网络以放大竞态
在 Network 面板选择 “Slow 3G”,或在服务端加入人为延迟。慢网络更容易复现竞态;如果问题只在慢连接出现,它一直存在,只是被本地快速网络掩盖了。
4. 检查是否缺少 AbortController
审查所有 fetch 调用是否传入 signal。没有 AbortController 时,触发条件(搜索词、页码)改变后旧请求仍会继续,并可能用旧数据更新状态。
修复 1:AbortController(推荐)
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err; // 忽略主动取消
});
return () => controller.abort(); // 新查询或卸载时取消
}, [query]);
AbortController.abort() 会让 fetch Promise 以 AbortError 拒绝。必须把它与其他错误分开处理,避免错误监控被取消噪声污染。
修复 2:请求 ID 令牌
useEffect(() => {
const requestId = ++requestIdRef.current;
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(data => {
// 只有仍然是最新请求时才应用结果
if (requestId === requestIdRef.current) {
setResults(data);
}
});
}, [query]);
修复 3:SWR / TanStack Query(生产环境)
import { useQuery } from '@tanstack/react-query';
function SearchResults({ query }) {
const { data } = useQuery({
queryKey: ['search', query],
queryFn: () => fetch(`/api/search?q=${query}`).then(r => r.json()),
// 内置去重、取消陈旧请求并忽略旧响应
});
return <ResultsList data={data} />;
}
常见陷阱
未捕获 AbortError
// ❌ AbortError 污染错误追踪
fetch(url, { signal }).then(setData).catch(setError);
// ✅ 过滤取消错误
fetch(url, { signal }).then(setData).catch(err => {
if (err.name !== 'AbortError') setError(err);
});
共享可变引用
// ❌ 共享数组引用——并发 push 会破坏状态
const shared = [];
await Promise.all(urls.map(url =>
fetch(url).then(r => r.json()).then(d => shared.push(...d))
));
测试竞态条件
// 用人为延迟模拟竞态
const mockFetch = (delay, data) =>
new Promise(resolve => setTimeout(() => resolve(data), delay));
// 请求 A(慢):200ms 后返回
// 请求 B(快):50ms 后返回
// 未取消:先显示 B 的数据,随后被 A 覆盖
// 已取消:显示 B 的数据,A 的请求被中止
框架模式
- React:在
useEffect清理函数中使用AbortController(见上) - Svelte:使用带取消令牌的响应式 store
- Vue:在 watcher 中使用
AbortController
性能影响
正确取消请求可以减少带宽消耗。HTTP 协议不支持在 TCP 层真正中止服务端处理,服务端可能仍会继续执行,但客户端不会再等待或解析响应。
验证修复
实现请求取消后,在搜索框中快速连续输入,同时观察 Network 面板。先前按键发出的请求应在 Status 列显示为 “(cancelled)”。
来源
- MDN:https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- AbortController 支持范围:Chrome 66+、Firefox 57+、Safari 12.1+