React篇 | 请求竞态处理
前言
你一定在项目中见过这种 Bug:用户在搜索框快速输入 “abc”,先请求了 “a”,再请求了 “ab”,最后请求了 “abc”。结果 “abc” 的响应先回来了,页面显示正确。但紧接着 “a” 的响应也回来了(它最慢),页面被覆盖成了 “a” 的结果——用户明明输入的是 “abc”,看到的却是 “a” 的搜索结果。
这就是竞态条件(Race Condition)。
这个问题在面试中出现频率很高,因为它同时考察了你对异步编程、React 生命周期、网络请求的理解。面试官常见的问法:
- 什么是请求竞态?在哪些场景下会出现?
- 你知道几种解决方案?各有什么优缺点?
- 在 useEffect 里发请求,怎么处理竞态?
- AbortController 是什么?怎么用?
- React Query / SWR 是怎么处理竞态的?
本章就来把这些问题一网打尽。
诊断自测
Q1:下面的代码存在竞态问题吗?如果存在,在什么场景下会出 Bug?
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
return <List items={results} />;
}
点击查看答案
存在竞态问题。当 query 快速变化时(比如用户连续输入),会同时发出多个请求。如果后发的请求先返回,再被先发的请求覆盖,就会出现 UI 与用户输入不一致的 Bug。
比如:query 从 “a” → “ab” → “abc” 变化,发出三个请求。如果 “abc” 的响应先回来,“a” 的响应后回来,最终 results 会是 “a” 的结果,而不是用户期望的 “abc”。
Q2:AbortController 和”忽略过期响应”这两种方案有什么本质区别?
点击查看答案
AbortController 会取消请求本身——浏览器不会等待服务端的响应,TCP 连接可能被关闭,节省了网络资源和浏览器处理响应的开销。
忽略过期响应只是在响应回来后不去更新状态——请求本身仍然完成了,浏览器仍然接收并解析了响应数据,只是前端代码选择不用它。
简单说:AbortController 是”不要了”,忽略过期响应是”收到了但不用”。在性能和资源利用上,AbortController 更优。
Q3:React 的 useEffect 清理函数在竞态处理中扮演什么角色?
点击查看答案
useEffect 的清理函数在下次 effect 执行前和组件卸载时自动运行。这正好对应了”上一次请求应该被取消/忽略”的时机。利用清理函数,我们可以:
- 在清理函数中调用
abort()取消上一次请求 - 或者设置一个标志位
ignore = true,让上一次请求的回调不再更新状态
这是 React 中处理异步竞态最惯用的模式。
一、什么是竞态条件?
1.1 定义
竞态条件(Race Condition)是指:程序的行为依赖于多个异步操作的完成顺序,而这个顺序是不可预测的。
在前端的语境下,最常见的竞态条件是:多个请求按顺序发出,但响应的返回顺序不确定,导致 UI 显示了错误的(过期的)数据。
1.2 常见场景
场景一:搜索框快速输入
这是最经典的场景。用户每输入一个字就触发一次搜索请求,快速输入时多个请求并行,响应顺序不确定。
// 用户输入 "react" → 依次发出 "r", "re", "rea", "reac", "react" 五个请求
// 如果 "r" 的请求最慢(服务端匹配范围最大),它最后返回
// 页面最终显示的是 "r" 的搜索结果,而不是 "react"
场景二:Tab/页签快速切换
用户快速在多个 Tab 之间切换,每个 Tab 都会请求数据。如果用户最终停在 Tab C,但 Tab A 的请求最后才返回,页面可能显示 Tab A 的数据。
场景三:分页快速翻页
用户连续点击”下一页”,从第 1 页翻到第 5 页。如果第 1 页的请求最后返回,列表会显示第 1 页的数据。
场景四:路由快速切换
用户快速点击不同的导航链接,每次路由切换都触发数据加载。如果上一个路由的请求在新路由渲染后才返回,并尝试 setState,就会出问题。
二、方案一:AbortController
AbortController 是浏览器原生的 API,用于取消 fetch 请求。它是处理请求竞态最推荐的方案。
2.1 基本用法
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`/api/search?q=${query}`, {
signal: controller.signal, // 把 signal 传给 fetch
})
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
// AbortError 不是真正的错误,不需要处理
if (err.name !== 'AbortError') {
console.error(err);
setLoading(false);
}
});
// 清理函数:取消上一次请求
return () => {
controller.abort();
};
}, [query]);
return (
<>
{loading && <Spinner />}
<List items={results} />
</>
);
}
2.2 工作原理
- 每次
query变化时,useEffect先执行上一次的清理函数(调用controller.abort()) abort()会让上一个fetch的 promise 被 reject,错误类型是AbortError- 然后执行新的 effect,创建新的
AbortController,发起新的请求 - 组件卸载时,同样会执行清理函数取消未完成的请求
2.3 封装成自定义 Hook
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, error, loading };
}
// 使用
function SearchResults({ query }) {
const { data, loading } = useFetch(`/api/search?q=${query}`);
// ...
}
2.4 配合 axios 使用
axios 同样支持 AbortController(v0.22.0+):
useEffect(() => {
const controller = new AbortController();
axios.get(`/api/search?q=${query}`, {
signal: controller.signal,
})
.then(res => setResults(res.data))
.catch(err => {
if (!axios.isCancel(err)) {
console.error(err);
}
});
return () => controller.abort();
}, [query]);
三、方案二:忽略过期响应
如果你不想取消请求(比如服务端计算量大,取消后下次还得重新算),可以让请求正常完成,但忽略过期的响应。
3.1 布尔标志位法
这是 React 官方文档推荐的方式:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let ignore = false; // 标志位
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) { // 只有标志位为 false 时才更新
setResults(data);
}
});
return () => {
ignore = true; // 清理时把标志位设为 true
};
}, [query]);
return <List items={results} />;
}
3.2 原理解析
这里利用了 JavaScript 的闭包特性:
- 每次 effect 执行时,创建一个局部变量
ignore = false .then回调通过闭包引用这个ignore- 当 query 变化时,React 先执行上一次 effect 的清理函数,把上一次的
ignore设为true - 上一次请求的响应回来后,检查自己的
ignore——已经是true了,不更新状态
每次 effect 都有自己的 ignore,互不干扰。这其实就是每个 effect 有自己的闭包这一特性的经典应用。
3.3 版本号法
另一种类似的思路是用版本号/请求 ID:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const latestRequestRef = useRef(0);
useEffect(() => {
const requestId = ++latestRequestRef.current;
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
// 只有最新的请求才更新
if (requestId === latestRequestRef.current) {
setResults(data);
}
});
}, [query]);
return <List items={results} />;
}
每次发请求前递增版本号,响应回来后检查当前版本号是否和发请求时一致。如果不一致,说明又有新请求发出了,当前响应已过期。
布尔标志位 vs 版本号: 功能上等价。布尔标志位更直观,是 React 官方文档推荐的写法。版本号的优势是不依赖 useEffect 的清理函数,在某些非 Hook 场景(如 class 组件)中更方便。
四、方案三:使用数据请求库
在实际项目中,直接手写 fetch + 竞态处理的代码虽然可行,但容易遗漏。更推荐使用成熟的数据请求库,它们已经内置了竞态处理。
4.1 React Query(TanStack Query)
import { useQuery } from '@tanstack/react-query';
function SearchResults({ query }) {
const { data, isLoading, error } = useQuery({
queryKey: ['search', query],
queryFn: async ({ signal }) => {
// React Query 自动传入 AbortSignal
const res = await fetch(`/api/search?q=${query}`, { signal });
return res.json();
},
enabled: !!query, // query 为空时不请求
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <List items={data} />;
}
React Query 自动处理竞态的方式:
- 当
queryKey变化时,自动取消上一次请求(通过 AbortSignal) - 内置去重:相同 queryKey 的并发请求只发一次
- 自动缓存:相同 queryKey 的数据不重复请求
4.2 SWR
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(res => res.json());
function SearchResults({ query }) {
const { data, error, isLoading } = useSWR(
query ? `/api/search?q=${query}` : null,
fetcher
);
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <List items={data} />;
}
SWR 处理竞态的方式:内部维护一个请求计数器,只有最后一次请求的结果才会被采用(类似版本号法)。
4.3 为什么推荐用库?
手写竞态处理代码要考虑很多细节:
- 取消请求 / 忽略过期响应
- loading 状态管理
- 错误处理
- 重试逻辑
- 缓存
- 去重
- 组件卸载时的清理
这些逻辑每次手写都容易遗漏一两个。React Query 和 SWR 把这些都封装好了,你只需要关注业务逻辑。
五、useEffect 中的竞态处理最佳实践
5.1 React 官方推荐的模式
React 官方文档给出的最佳实践是布尔标志位 + async/await:
useEffect(() => {
let ignore = false;
async function fetchData() {
try {
const response = await fetch(`/api/data/${id}`);
const data = await response.json();
if (!ignore) {
setData(data);
}
} catch (err) {
if (!ignore) {
setError(err);
}
}
}
fetchData();
return () => {
ignore = true;
};
}, [id]);
注意:不要把 async 函数直接传给 useEffect,因为 useEffect 期望返回的是清理函数或 undefined,而 async 函数返回的是 Promise:
// ❌ 错误:不要这样写
useEffect(async () => {
const data = await fetch(...);
}, []);
// ✅ 正确:在 effect 内部定义 async 函数
useEffect(() => {
async function fetchData() { /* ... */ }
fetchData();
}, []);
5.2 同时使用 AbortController + 标志位
在生产环境中,最稳妥的方案是同时使用两者:
useEffect(() => {
let ignore = false;
const controller = new AbortController();
async function fetchData() {
try {
const res = await fetch(`/api/data/${id}`, {
signal: controller.signal,
});
const data = await res.json();
if (!ignore) {
setData(data);
}
} catch (err) {
if (err.name === 'AbortError') return; // 正常取消,不处理
if (!ignore) {
setError(err);
}
}
}
fetchData();
return () => {
ignore = true;
controller.abort();
};
}, [id]);
AbortController 负责节省网络资源(取消已发出的请求),标志位负责兜底(万一 abort 没生效或请求已经完成了,确保不更新状态)。
5.3 防抖 + 竞态处理的组合
在搜索场景中,通常还会加上防抖,减少请求次数:
function useSearch(query) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
let ignore = false;
const controller = new AbortController();
// 防抖:延迟 300ms 再发请求
const timer = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await res.json();
if (!ignore) {
setResults(data);
setLoading(false);
}
} catch (err) {
if (err.name !== 'AbortError' && !ignore) {
setLoading(false);
}
}
}, 300);
return () => {
ignore = true;
controller.abort();
clearTimeout(timer); // 清除未执行的定时器
};
}, [query]);
return { results, loading };
}
这里的清理函数做了三件事:
- 设置
ignore = true(兜底) controller.abort()(取消已发出的请求)clearTimeout(timer)(取消还没发出的请求)
三者配合,完整地处理了竞态和资源清理。
常见误区
误区一:“用了防抖就不需要处理竞态了”
防抖只是减少了请求次数,不能消除竞态。即使加了 300ms 防抖,两次防抖后的请求仍然可能并行,响应顺序仍然不确定。防抖解决的是”太频繁”的问题,竞态解决的是”乱序”的问题,两者是互补的,不是替代的。
误区二:“AbortController 会导致服务端取消处理”
AbortController 取消的是浏览器端的请求。调用 abort() 后,浏览器会关闭 TCP 连接(或者停止处理响应),但服务端可能已经在处理请求了——abort 不会通知服务端”不要继续了”。服务端可能仍然完成了计算、写入了数据库,只是客户端不再接收响应。如果你需要服务端也取消,需要额外的机制(比如取消令牌)。
误区三:“在 Promise.then 里面 setState 不会有问题,因为 React 会 batch”
React 的 batch 机制确保多个 setState 合并为一次渲染,但这和竞态是两回事。竞态问题是用了过期数据来 setState,不管 batch 不 batch,结果都是错的。batch 解决的是渲染效率问题,竞态解决的是数据正确性问题。
误区四:“组件卸载后 setState 会报错,所以不处理也没关系”
在 React 18 中,组件卸载后 setState 不再打印警告(React 团队认为这个警告造成了过多误解)。但这不意味着不需要清理——未清理的请求仍然会消耗资源、占用网络带宽。此外,竞态问题不仅发生在组件卸载时,更常见的是组件仍在挂载但 props 变化了的场景。
小结
本章我们从搜索框的经典 Bug 出发,系统讲解了请求竞态的成因和三种解决方案。
核心要点
- 竞态条件:多个请求并行,响应顺序不确定,过期响应覆盖了最新数据
- AbortController:取消请求本身,节省网络资源。配合 useEffect 清理函数使用
- 忽略过期响应:布尔标志位或版本号法,让过期的回调不更新状态
- 数据请求库:React Query / SWR 内置竞态处理,推荐在生产项目中使用
- 最佳实践:AbortController + 标志位双重保险,配合防抖减少请求次数
- useEffect 清理函数是处理竞态的关键时机——在依赖变化时自动清理上一次请求
本章思维导图
- 什么是竞态条件
- 多个异步操作,完成顺序不可预测
- 过期响应覆盖最新数据
- 常见场景
- 搜索框快速输入
- Tab 切换
- 分页翻页
- 路由切换
- 方案一:AbortController
- fetch + signal 取消请求
- useEffect 清理函数中调用 abort()
- 错误处理:过滤 AbortError
- axios 也支持
- 方案二:忽略过期响应
- 布尔标志位法(闭包 + let ignore)
- 版本号法(useRef 计数)
- 请求仍完成,只是不用结果
- 方案三:数据请求库
- React Query:自动 abort + 缓存 + 去重
- SWR:内部计数器 + stale-while-revalidate
- 最佳实践
- AbortController + 标志位双重保险
- 防抖 + 竞态处理配合使用
- async 函数定义在 effect 内部
练习挑战
第一题 ⭐(基础):找出竞态 Bug
下面的代码有竞态问题,请指出问题所在并修复:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return user ? <div>{user.name}</div> : <Loading />;
}
点击查看答案与解析
问题:当 userId 快速变化时,多个请求并行发出,可能后发先至,导致显示了错误用户的信息。
修复方案(AbortController):
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, [userId]);
return user ? <div>{user.name}</div> : <Loading />;
}
关键改动两处:1) 创建 AbortController 并把 signal 传给 fetch;2) 在清理函数中 abort。
第二题 ⭐⭐(进阶):实现一个带竞态保护的 useAsync Hook
实现一个通用的 useAsync Hook,支持:
- 自动处理竞态(使用 AbortController)
- 返回 data / error / loading 三个状态
- 依赖变化时自动重新请求
// 期望的使用方式
const { data, error, loading } = useAsync(
(signal) => fetch(`/api/users/${id}`, { signal }).then(r => r.json()),
[id]
);
点击查看答案与解析
function useAsync(asyncFn, deps) {
const [state, setState] = useState({
data: null,
error: null,
loading: true,
});
useEffect(() => {
let ignore = false;
const controller = new AbortController();
setState(prev => ({ ...prev, loading: true, error: null }));
asyncFn(controller.signal)
.then(data => {
if (!ignore) {
setState({ data, error: null, loading: false });
}
})
.catch(err => {
if (err.name === 'AbortError') return;
if (!ignore) {
setState({ data: null, error: err, loading: false });
}
});
return () => {
ignore = true;
controller.abort();
};
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
return state;
}
这个 Hook 同时使用了 AbortController 和布尔标志位做双重保护。调用者通过 asyncFn 的参数拿到 signal,自己决定如何使用(传给 fetch 或其他支持 abort 的 API)。
第三题 ⭐⭐⭐(综合):处理依赖请求的竞态
现在有一个更复杂的场景:先根据 userId 获取用户信息,再根据用户信息中的 teamId 获取团队信息。两步请求都可能存在竞态。请实现这个逻辑,确保没有竞态问题。
// 期望效果:
// 1. userId 变化 → 取消之前的所有请求(用户请求 + 团队请求)
// 2. 始终显示最新 userId 对应的用户和团队信息
点击查看参考实现
function UserTeamInfo({ userId }) {
const [user, setUser] = useState(null);
const [team, setTeam] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
const controller = new AbortController();
const { signal } = controller;
async function fetchUserAndTeam() {
setLoading(true);
setError(null);
try {
// 第一步:获取用户信息
const userRes = await fetch(`/api/users/${userId}`, { signal });
const userData = await userRes.json();
if (ignore) return; // 检查是否已过期
setUser(userData);
// 第二步:用用户的 teamId 获取团队信息
const teamRes = await fetch(`/api/teams/${userData.teamId}`, { signal });
const teamData = await teamRes.json();
if (ignore) return; // 再次检查
setTeam(teamData);
setLoading(false);
} catch (err) {
if (err.name === 'AbortError') return;
if (!ignore) {
setError(err);
setLoading(false);
}
}
}
fetchUserAndTeam();
return () => {
ignore = true;
controller.abort(); // 同一个 controller 取消所有请求
};
}, [userId]);
// ...
}
核心要点:
- 同一个
AbortController的signal传给两个fetch,abort()时两个请求都会被取消 - 在每步异步操作之后都检查
ignore,避免在过期的 effect 中继续执行后续请求 - 使用
async/await让链式请求的逻辑更清晰
自我检测
读完本章后,对照下面的清单检验一下自己的掌握程度。
- 能解释什么是请求竞态,并举出至少三个常见场景
- 能手写使用 AbortController 处理竞态的 useEffect 代码
- 能手写使用布尔标志位处理竞态的 useEffect 代码
- 能解释 AbortController 和”忽略过期响应”的区别和适用场景
- 能说出为什么防抖不能替代竞态处理
- 能描述 React Query / SWR 处理竞态的方式
- 能在实际项目中正确组合防抖 + AbortController + 标志位来处理搜索场景
- 知道 useEffect 的 async 函数不能直接作为回调,以及正确的写法
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90