
一句话:竞态(race condition)指「多个并发请求,后发起的先返回,导致旧数据覆盖新数据」;解法是「取消(
AbortController)或忽略(标记位)」,并且StrictMode 下的双执行会暴露这个问题。
一、竞态是怎么产生的
// ❌ 典型的竞态 bug
function Search({ keyword }: { keyword: string }) {
const [results, setResults] = useState<Item[]>([]);
useEffect(() => {
fetchSearch(keyword).then(setResults); // ⚠️ 没有取消也没有依赖校验
}, [keyword]);
return <List items={results} />;
}
用户在搜索框输入「react」:
时序:
t=0ms 输入 'r' → 发起请求 A('r')
t=50ms 输入 're' → 发起请求 B('re')
t=100ms 输入 'rea'→ 发起请求 C('rea')
网络可能这样返回:
t=300ms B 返回('re' 的结果)→ setResults(B)
t=400ms C 返回('rea' 的结果)→ setResults(C)
t=500ms A 返回('r' 的结果) → setResults(A) ⚠️ 旧数据覆盖了最新结果!
用户看到的是 'r' 的结果,而不是 'rea' 的 —— 这就是竞态。
⚠️ 竞态不只出现在搜索:
① 路由参数变化(用户快速切换用户详情页)
② Tab 切换(快速点不同 Tab)
③ 分页快速翻页
④ 筛选条件快速变化
⑤ 重试/刷新按钮连点
⑥ StrictMode 下的「双执行」(同一请求发两次,返回顺序不定)
本质:并发请求的「完成顺序」与「发起顺序」不一致。
二、解法一:AbortController(真正取消)
function Search({ keyword }: { keyword: string }) {
const [results, setResults] = useState<Item[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController(); // ⭐ 每次 effect 一个 controller
setError(null);
fetchSearch(keyword, { signal: controller.signal }) // ⭐ 把 signal 传给请求层
.then(setResults)
.catch((err) => {
if (err.name === 'AbortError') return; // ⭐ 主动取消 → 静默忽略
setError(err.message);
});
return () => controller.abort(); // ⭐ 依赖变化/卸载时取消
}, [keyword]);
return <List items={results} error={error} />;
}
为什么 AbortController 是最好的解法?
① 「真正取消」——请求不会再消耗网络与带宽(浏览器会中断连接)
② 「不会有后续副作用」——被取消的 Promise 会 reject(AbortError),
所以不会走到「setResults」那一步
③ 它是标准 API(fetch 与新版 axios 都支持)
⚠️ 前提:请求层必须把 signal 透传给 fetch:
function fetchSearch(keyword, { signal }) {
return fetch(`/api/search?q=${keyword}`, { signal }).then((r) => r.json());
}
三、解法二:忽略标记(无法取消时)
// ✅ 适用于「底层不支持取消」的请求(某些 SDK、GraphQL 客户端、老封装)
function Search({ keyword }: { keyword: string }) {
const [results, setResults] = useState<Item[]>([]);
useEffect(() => {
let cancelled = false; // ⭐ 本次 effect 的标记
fetchSearch(keyword).then((data) => {
if (cancelled) return; // ⭐ 已过期 → 忽略
setResults(data);
});
return () => {
cancelled = true; // ⭐ 依赖变化/卸载时置为 true
};
}, [keyword]);
return <List items={results} />;
}
两种解法的对比:
| 维度 | AbortController | 忽略标记 |
| 是否真正取消 | ✅(断开请求) | ❌(请求照常完成) |
| 是否避免过期数据 | ✅ | ✅ |
| 是否节省带宽 | ✅ | ❌ |
| 是否依赖底层支持 | ✅(需要 signal 透传) | ❌(任何 Promise 都能用) |
结论:优先 AbortController;底层不支持时用标记位。
四、解法三:用「请求序号」判断(也可用于并发去重)
function useLatestRequest<T>(fetcher: (signal: AbortSignal) => Promise<T>) {
const seqRef = useRef(0);
return useCallback(async () => {
const seq = ++seqRef.current; // ⭐ 本次请求的序号
const data = await fetcher(new AbortController().signal);
if (seq !== seqRef.current) return null; // ⭐ 不是最新请求 → 丢弃结果
return data;
}, [fetcher]);
}
// 使用
function Search({ keyword }: { keyword: string }) {
const [results, setResults] = useState<Item[]>([]);
const fetchLatest = useLatestRequest((signal) => fetchSearch(keyword, { signal }));
useEffect(() => {
fetchLatest().then((data) => { if (data) setResults(data); });
}, [fetchLatest]);
return <List items={results} />;
}
序号模式的特点:
① 与「取消」解耦(用序号判断「谁是最后一次」)
② 适合「不想取消请求,但要保证只用最新结果」的场景
③ 也可以用来「丢弃过期响应」(如输入法联想、实时协作)
五、StrictMode 下的双执行
// React 18 的 StrictMode 在开发环境会「挂载 → 卸载 → 再挂载」
function Comments({ postId }: { postId: string }) {
const [comments, setComments] = useState<Comment[]>([]);
useEffect(() => {
fetchComments(postId).then(setComments); // ⚠️ 没有取消 → 发两次请求
}, [postId]);
return <List items={comments} />;
}
// → 控制台会看到两次请求(这正是 StrictMode 「故意的」)
// ✅ 加上取消后,双执行不再是问题(第一次请求会被立刻取消)
useEffect(() => {
const controller = new AbortController();
fetchComments(postId, { signal: controller.signal })
.then(setComments)
.catch((err) => { if (err.name !== 'AbortError') setError(err); });
return () => controller.abort(); // ⭐ StrictMode 下会立刻取消第一个
}, [postId]);
// → 生产环境(无 StrictMode)只发一次;开发环境发的第一次会被取消
StrictMode 的价值:它【故意】制造「effect 被执行两次」的场景,
从而把「没做取消/没写清理」的问题暴露出来。
—— 如果你看到「请求发两次」,说明你的 effect 缺少取消逻辑
(而这在生产环境的「快速切换参数」场景下会造成竞态)。
⚠️ 不要用「ref 标志位」绕过 StrictMode 的双调用——
那只是掩盖问题,真正的竞态依然存在。
六、封装一个稳健的请求 Hook
// hooks/useQueryData.ts —— 一个简化版的「带取消与状态管理」的请求 Hook
type State<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
export function useQueryData<T>(
fetcher: (signal: AbortSignal) => Promise<T>,
deps: unknown[]
): State<T> & { refetch: () => void } {
const [state, setState] = useState<State<T>>({ status: 'idle' });
const [nonce, setNonce] = useState(0);
// 用 ref 保存最新的 fetcher(避免把它放进依赖导致死循环)
const fetcherRef = useRef(fetcher);
useEffect(() => {
fetcherRef.current = fetcher;
});
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
fetcherRef.current(controller.signal)
.then((data) => setState({ status: 'success', data }))
.catch((err: Error) => {
if (err.name === 'AbortError') return; // ⭐ 取消 → 不改状态
setState({ status: 'error', error: err });
});
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
return { ...state, refetch };
}
// 使用
function UserDetail({ id }: { id: string }) {
const state = useQueryData((signal) => fetchUser(id, { signal }), [id]);
if (state.status === 'loading') return <Skeleton />;
if (state.status === 'error') return <ErrorView message={state.error.message} />;
if (state.status === 'success') return <div>{state.data.name}</div>;
return null;
}
⚠️ 这只是「教学版」。生产上建议直接用 TanStack Query / SWR:
① 自动取消(或至少不会造成竞态)
② 缓存与去重(同一 key 只发一次)
③ 状态机(isLoading / isError / data)
④ 重试、窗口聚焦重验证、分页
⑤ 更好的 devtools
七、常见坑
// ① effect 里请求但没写清理
useEffect(() => { fetchData().then(setData); }, [id]); // ⚠️ 有竞态
// ② 有清理但不判 AbortError
fetchData(...).catch(setError); // ⚠️ 取消也会设置错误 → 用户看到假错误
// ③ 用「一个全局 loading」管理多个请求
const [loading, setLoading] = useState(false);
// ⚠️ 请求 A 完成后把 loading 置 false,但请求 B 还在进行 → UI 状态错误
// ④ 在 effect 里「请求 + 依赖里放了不稳定的对象」→ 无限循环
useEffect(() => { fetchData(options); }, [options]); // ⚠️ options 每次都是新对象
// ⑤ StrictMode 下「请求两次」却用 ref 绕过(掩盖竞态问题)
// ⑥ 只用了「忽略标记」但请求仍在消耗带宽(大文件下载场景)
// ⑦ 在「已经卸载的组件」里 setState(React 18 不再警告,但仍是无用功)
// ⑧ 用「取消」但请求层没透传 signal(取消无效)
function fetchData() { return fetch(url); } // ❌ 没用 signal
function fetchData(o) { return fetch(url, { signal: o.signal }); } // ✅
// ⑨ 认为「加了取消就不需要依赖数组写对」→ 依赖写错会「不重新请求」
// ⑩ 多个请求「共享一个 controller」(取消了一个会影响其它)
// ⑪ 把「竞态」误认为「后端数据错误」(排查方向错)
// ⑫ 在 catch 里判断 err.name 但错误被包装过(如 axios 的 CanceledError)
axios 用 `axios.isCancel(err)` 或 `err.code === 'ERR_CANCELED'` 判断
// ③ 的修复:每个请求独立状态(或用「状态机」)
function useData<T>(fetcher: () => Promise<T>, deps: unknown[]) {
const [state, setState] = useState<State<T>>({ status: 'idle' });
// ⭐ 每个 hook 实例有自己的状态 → 不会互相干扰
}
// 或者用「请求计数」
const pendingRef = useRef(0);
const start = () => { pendingRef.current++; setLoading(true); };
const end = () => { pendingRef.current--; if (pendingRef.current === 0) setLoading(false); };
八、面试延伸
- 「什么是请求竞态?怎么产生的?」
竞态(race condition)指「多个并发请求,后发起的先返回,导致旧数据覆盖新数据」。产生原因是「请求的完成顺序与发起顺序不一致」——比如用户在搜索框快速输入r→re→rea,三个请求并发,如果r的响应最后才返回,就会把rea的结果覆盖掉。典型场景包括:搜索联想、路由参数快速切换、Tab 切换、分页连点、筛选条件变化,以及 StrictMode 下的双执行。 -
「怎么解决竞态?」
两种主流方案:①AbortController(推荐)——在useEffect里为每次请求创建 controller,把signal传给请求层,并在清理函数里abort()。这样依赖变化时上一个请求会被真正取消(其 Promise 会 rejectAbortError)。② 忽略标记——let cancelled = false;+ 在清理函数里置true,拿到响应后先判断cancelled再setState。适用于「底层不支持取消」的场景。此外还有「请求序号」方案(用 ref 记录最新序号,过期响应直接丢弃)。 -
「为什么推荐
AbortController而不是忽略标记?」
因为AbortController是「真正取消」——它会中断网络请求(节省带宽与连接资源),而被取消的 Promise 会 reject,因此不会走到setState那一步。而「忽略标记」只是「忽略结果」——请求仍然会完整执行、消耗带宽与服务器资源(对大文件下载或高成本接口很不友好)。例外:如果底层请求库不支持signal,那只能用标记位(或改造成本太高)。 -
「为什么 StrictMode 会让请求发两次?」
因为 React 18 的 StrictMode 在开发环境会故意把组件「挂载 → 卸载 → 再挂载」,以暴露「Effect 缺少清理」的问题——如果你的 effect 没有取消/清理逻辑,就会看到「请求发了两次」。这不是 bug,而是「压力测试」:它模拟「组件被快速卸载重挂载」的场景(这正是竞态的高发场景)。正确应对是「补齐取消逻辑」(加了AbortController后,第一次请求会被立刻取消),而不是用ref标志位绕过(那只是掩盖问题)。 -
「怎么判断一个错误是不是『取消导致的』?」
fetch被abort()后抛出的错误名是AbortError(err.name === 'AbortError');axios新版会抛CanceledError,可以用axios.isCancel(err)或判断err.code === 'ERR_CANCELED'(旧版用axios.Cancel)。关键是:取消不应该被当作「错误」展示给用户——它只是「我们不关心这个响应了」,所以catch里要return而不是setError。否则用户会看到「请求已取消」这类无意义的提示。 -
「竞态和『loading 状态管理』有什么关系?」
两者常常一起出问题:如果用一个全局loading状态管理多个并发请求,就会出现「请求 A 完成了把 loading 置为 false,但请求 B 还在进行」——UI 会显示「已加载完成」但数据缺失。解法:① 每个请求独立状态(用独立的 hook 实例或状态机{ status, data, error });② 用请求计数(pendingCount,只有归零才取消 loading);③ 用「状态机」建模避免「loading 与 data 同时存在」的非法组合。 -
「路由参数快速切换时的竞态怎么处理?」
在 React 里,路由参数变化会触发组件重渲染但不重新挂载(见 6.4),所以useEffect([id])会重新执行——这正好是「竞态」的温床。处理方式是「依赖参数 + 取消」:useEffect(() => { const c = new AbortController(); fetch(id, { signal: c.signal }).then(...); return () => c.abort(); }, [id])。如果用 TanStack Query,只要把id放进queryKey,库内部就会处理「过期响应的丢弃」与「去重」,不用自己写取消逻辑。 -
「生产环境下要自己处理竞态吗?」
取决于「数据层」。如果用了 TanStack Query / SWR,它们内部已经处理了「同一queryKey的并发请求去重」与「过期结果丢弃」——通常不需要自己写取消逻辑(但「组件卸载后忽略响应」仍由库处理)。如果自己写useEffect请求,就必须自己处理:① 取消(AbortController);② 清理;③ 状态机建模。判断标准是「有没有一个统一的请求层帮你兜底」——没有就自己写,有就依赖它(并理解它的行为)。
一句话速记
竞态 = 「后发起的请求先返回,旧数据覆盖新数据」(搜索、路由参数、Tab、分页、筛选、StrictMode 双执行都是高发场景);解法优先级是「
AbortController(真正取消,在useEffect清理里abort())→ 忽略标记(let cancelled = false,底层不支持取消时用)→ 请求序号(丢弃过期结果)」;catch里必须区分「取消」(AbortError/axios.isCancel)——不要给取消弹提示;StrictMode 的双执行是压力测试(暴露「没写清理」),要补取消而不是用 ref 绕过;不要用一个全局loading管多个并发请求(用独立状态或计数);生产上优先用 TanStack Query(自动去重与丢弃过期结果)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!