请求竞态处理:快速翻页与过期响应拦截
面试向讲清前端最真实的 bug 之一——请求竞态:为什么"后发"的请求可能"先至"、 旧响应如何覆盖新数据;三层解决方案(防抖减少 / AbortController 真取消 / 请求序号兜底); React useEffect cleanup + ignore flag 的官方写法与它为什么有效; 以及 loading 状态漏校验、ref 而非 state 存 requestId 等高频踩坑点。
一句话概括
用户手速永远比网络快。切 tab、点翻页、连打搜索词——先发出去的请求不一定先回来,慢的那次后返回,就把新数据覆盖成了旧数据。这就是请求竞态(race condition),也是前端项目里最真实、最容易上线后才发现的 bug。
一句话结论:不能让”谁最后返回谁生效”,只能让”最新发起的那次生效”。
面试里的标准结构是三层,按这个顺序答最稳:
- 减少无效请求:输入类场景加防抖,或者复用缓存 —— 从源头少发几次;
- 让旧请求真正停下:
AbortController取消,顺带省流量、减轻后端; - 让旧结果”即使回来也无效”:请求序号 / 有效性校验,丢弃过期响应。
第三层必须有,因为取消不一定来得及——响应已经在上路上(甚至已经回到 JS 引擎里排队)的时候,abort 是拦不住的。
核心知识点
1. 竞态从哪来:返回顺序不由你决定
关键认知:Promise.all 保序 ≠ 网络保序。 数组顺序按你传入的顺序排,但写状态的时机完全看谁先返回。
实测(A 先发但慢 300ms、B 后发只 50ms):
1
2
3
4
5
6
7
8
// 简化示意:无任何保护的写法
async function load(fetchFn, label) {
const data = await fetchFn();
setData(data); // 谁后返回谁写入
}
load(() => delay(300, 'page1'), 'A'); // 先发起
load(() => delay(50, 'page2'), 'B'); // 后发起
实测写入顺序是:B 写入了 page2 → A 写入了 page1,最终页面显示 page1。旧请求赢了。
标准触发场景就那几个:搜索框连打、tab / 筛选条件切换、分页快速点、详情页快速切 id、下拉选择项。
2. 第一层:防抖只是”少发几次”,不解决竞态
1
const search = debounce((kw) => fetch(`/api/search?q=${kw}`), 300);
防抖确实能砍掉大量无效请求,但它和竞态是两件事:用户打完”rea”停 300ms 发了一次,再补成”react”,第一次请求如果恰好慢,照样会覆盖掉第二次的结果。所以防抖只能算”降低概率”,不能算”解决问题”——面试时如果把防抖当成答案的全部,基本会被追问到崩。
记住这句边界:防抖减少请求数量,序号校验才保证结果正确。
3. 第二层:AbortController 真正取消
1
2
3
4
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort(); // 立刻中断这次请求
实测确认的几个行为细节(很容易被追问):
abort()是幂等的,重复调用不抛错;signal.aborted变成true;signal.reason是一个name为AbortError的DOMException;- abort 之后
await会 reject,实测真实fetch抛出的就是name === 'AbortError'的 DOMException(各环境message文案不一致,所以只能判断name,不要判断 message); - axios 是传
signal选项(v0.22.0+ 支持);XHR 需要自己监听signal的 abort 事件去调xhr.abort()。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 只 catch 通用错误:用户快速切 tab 时会疯狂弹"请求失败"的 toast
try {
const res = await fetch(url, { signal });
setData(await res.json());
} catch (e) {
setError(e);
}
// ✅ 区分「被主动取消」和「真的失败」
try {
const res = await fetch(url, { signal });
setData(await res.json());
} catch (e) {
if (e.name === 'AbortError') return; // 主动取消,静默忽略
setError(e);
}
另外两个现代 API 顺手提一句(都属于”知道更好”的加分项):AbortSignal.timeout(3000) 一行做超时;AbortSignal.any([s1, s2]) 把多个取消源合成一个信号(超时 + 手动取消)。实测在 Node 22 与当前主流浏览器都已可用。
4. 第三层:请求序号 / 有效性校验(兜底)
思路极简单:每次发起请求先领一个递增编号,响应回来时判断”我还是不是最新的那个”,不是就直接丢掉。
1
2
3
4
5
6
7
8
const requestIdRef = useRef(0);
async function load(tabKey) {
const id = ++requestIdRef.current; // 领号
const data = await api(tabKey);
if (id !== requestIdRef.current) return; // 过期响应,丢弃
setData(data);
}
这里有个必答的细节:“最新编号”要存在 ref 里,不能存 state。 因为 setState 的更新要等到渲染才可见,而同一个 tick 内多次调用 load() 时你要立刻比较出”谁是最新的”——ref 的读写是同步立即可见的,state 不是。这条被追问过无数次。
5. React 里的标准写法:useEffect cleanup + ignore flag
React 官方文档在处理”Effect 里取数据”时给的就是这个模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
为什么它能挡住竞态? 关键在于”每次 effect run 都有自己独立的一份闭包变量”。ignore 不是组件级的共享状态,而是这一次执行专属的局部变量:
userId从 Alice 变成 Bob → React 先跑 Alice 那次的 cleanup(把 Alice 的ignore置true),再跑 Bob 那次的 setup;- Alice 的响应无论多晚返回,它的
if (!ignore)都是false,写不进去; - 实测结果:Bob 正常写入,Alice 被忽略。
这同时也解释了开发环境里 StrictMode 发两次请求的现象。官方原话是:这没有错——第一次 effect 会立刻被 cleanup 掉,所以它的 ignore 已被置为 true,多出来的那次请求不会影响状态;生产环境只会发一次请求。所以正确的问法不是”怎么让 effect 只跑一次”,而是”怎么让 effect 在 remount 之后依然正确”。
6. 取消 vs 忽略:到底该用哪个
取消旧请求(AbortController) | 忽略旧响应(序号 / ignore flag) | |
|---|---|---|
| 效果 | 真断掉连接,省带宽、减后端压力 | 请求照跑,只是不写状态 |
| 依赖 | 请求库要支持 signal | 与请求库无关,任何异步都能用 |
| 局限 | 响应已在路上时取消不了;不支持的库要自己接 | 浪费一次网络往返 |
| 定位 | 能省则省 | 必须有,做兜底 |
生产里的做法通常是两个一起上:新请求发出前先把旧的 abort 掉(减少堆积),再用序号校验兜住”取消不掉”的那一次。注意顺序——取消动作要放在发起新请求之前,这样连重复请求堆积都一起解决了。
7. 分页 / 筛选场景:最容易被漏掉的 loading 校验
快速点分页 1 → 2 → 3 的场景里,”取消”其实盖不住全部情况:第 1、2 次请求可能已经返回并在队列里排队了。所以 useEffect(..., [page]) 配 ignore flag(或 abort)是最干净的写法;不走 effect、直接在事件里调接口的话,就用 requestId ref。
这里有个几乎人人都会漏一次的点:setLoading(false) 也要做同样的校验。否则旧请求返回时会把新请求的 loading 关掉,界面出现”数据还没来但转圈没了”的诡异状态。
1
2
3
4
5
6
7
8
9
10
11
12
const id = ++requestIdRef.current;
setLoading(true);
try {
const data = await api(query);
if (id !== requestIdRef.current) return; // 过期,直接走
setData(data);
} catch (e) {
if (e.name !== 'AbortError') setError(e);
} finally {
if (id === requestIdRef.current) setLoading(false); // ← loading 也要校验
}
还有一个相关但不同的坑:不要只清空 data 而不清空 error / 分页游标,否则上一次的失败提示会赖在新页面上。这类”多状态不同步”的问题,本质是同一个竞态的变体。
8. 别自己造轮子:TanStack Query 怎么处理
如果用 TanStack Query,这件事框架层面已经解决:queryKey 变化时旧 query 会被标记为 out-of-date 并触发取消(前提是你消费了传给 queryFn 的 signal)。
1
2
3
4
5
useQuery({
queryKey: ['todos', page],
queryFn: ({ signal }) =>
fetch(`/todos?page=${page}`, { signal }).then(r => r.json()),
});
两条官方 caveat 必须知道,很容易被追问:
- 默认不会取消:如果 query 在 promise resolve 之前 unmount 或变成 unused,它不会被取消,数据依然会进缓存(好处是返回上一页能立刻拿到数据)。只有你消费了
signal,才真的会取消,而且取消后该 query 的状态会回滚到发起前的状态; - Suspense 版本不支持取消:
useSuspenseQuery/useSuspenseQueries/useSuspenseInfiniteQuery拿不到这个能力。
另外翻页时常用 placeholderData: keepPreviousData 让旧数据继续显示、避免白屏。但要讲清楚:这是”故意保留旧数据当占位”,和竞态不是一回事,别把两者混着答。
9. Vue 里的对应写法
Vue 的 watch 自带副作用清理,思路和 React 的 cleanup 一模一样:
1
2
3
4
5
6
7
8
9
10
import { watch, onWatcherCleanup } from 'vue';
watch(id, (newId) => {
const controller = new AbortController();
fetch(`/api/${newId}`, { signal: controller.signal }).then(/* ... */);
onWatcherCleanup(() => {
controller.abort(); // id 变化时终止过期请求
});
});
onWatcherCleanup 是 Vue 3.5+ 的 API,且必须在 watch 回调的同步执行期间调用——await 之后就不能用了。3.5 之前或需要异步场景,用 watch 回调的第三个参数 onCleanup(watchEffect 则是第一个参数),它绑定在 watcher 实例上,不受这个同步限制。组件卸载时统一收尾用 onBeforeUnmount。
极简项目里也可以只用序号那一层,不引入 AbortController——三层里最不能省的是序号校验。
其实你每天都在用
- 搜索框连打”react”:中间某个字的结果慢半拍返回,列表跳回旧关键词的结果 → 典型的旧响应覆盖。
- 后台列表快速点分页 1→2→3,最后停在 3,内容却是第 2 页的 → 竞态最经典的表现。
- 详情页从用户 A 快速切到用户 B,页面显示 B 的标题、A 的数据 → 两次请求写进了同一个 state。
- 切 tab 时弹出”请求失败”的红 toast → abort 被当成了真实错误,缺
e.name === 'AbortError'判断。 - 切 tab 后 loading 转圈突然消失、列表空白 →
setLoading(false)没做序号校验,被旧请求关掉了。 - 详情页切走后控制台报”更新已卸载组件” → React 18 之前的老代码;现在不报警告了,但竞态本身还在。
- 开发环境 Network 面板里同一个接口发了两次,以为代码写错了 → StrictMode 的故意行为,生产只发一次。
- 用了 TanStack Query 之后这类 bug 自己消失了 →
queryKey变化时框架帮你把旧 query 作废了。
常见误解(FAQ)
❌ 误区1:”加了防抖就不会有竞态了。”
防抖只减少请求数量,不保证返回顺序。用户短暂停顿后再输入,两次请求照样可能乱序返回。防抖 ≠ 解决竞态,它只是第一层的成本优化,正确答案里必须还有取消或序号校验。
❌ 误区2:”调用 abort() 之后,后端就不会继续处理了。”
不是。abort() 断的是客户端的连接与响应处理,服务端多数情况下照样把活干完(除非它专门监听连接关闭来做中断)。所以它省的是前端资源和带宽,不是后端计算。这个区分答出来,”用过”和”懂”立刻分开。
❌ 误区3:”只要取消了旧请求,旧数据就一定不会覆盖新数据。”
恰恰相反,已经返回、正卡在 JS 任务队列里的响应是取消不掉的(abort 只对还没回来的请求有效)。所以必须有”忽略过期响应”这一层兜底。只靠取消的代码在弱网或快速操作下仍然会翻车。
❌ 误区4:”requestId 用 useState 存就行,反正要重新渲染。”
不行。useState 的更新要等一次渲染才对读代码可见,而同一次事件里你可能连续调两次请求函数,此时比较的仍然是旧值,等于没防住。要用 useRef:它的写入是同步可见的。
❌ 误区5:”只要校验了 setData,loading 不用管。”
这是最常见的漏点。旧请求返回后在 finally 里无条件 setLoading(false),会把最新请求的 loading 状态关掉,界面出现”没数据也不转圈”的空窗。所有受这次请求影响的状态,都要用同一个 id 校验。
❌ 误区6:”组件卸载后再 setState 会报错 / 内存泄漏,所以必须防。”
React 18 已在变更日志里明确移除了这条警告,因为对一次性请求来说它本来就是 no-op、谈不上泄漏(React 团队原话是这些规避写法反而让代码更糟)。真正要防的是竞态本身——它和组件是否卸载无关,发生在”两次请求乱序”上。所以别拿”怕报错”当理由,要拿”数据会错”当理由。
❌ 误区7:”AbortController 只能取消 fetch。”
它本质是一个通用的取消信号,任何异步任务都能消费它(XHR、setTimeout 包装、自定义 Promise、第三方 SDK)。配合 AbortSignal.timeout() 能做超时、AbortSignal.any() 能合并多个取消源。把它理解成”Promise 世界的取消协议”比记成”fetch 的配套 API”更准。
一句话总结
请求竞态的本质是”返回顺序 ≠ 发起顺序”,所以正确的目标不是让旧请求别返回,而是让它的结果”即使返回也无效”——防抖减少请求、AbortController 真取消省资源、请求序号(存 ref 不存 state)兜底保证正确;凡是受这次请求影响的 state,连 loading 在内都要一起校验。