文章

请求竞态处理:快速翻页与过期响应拦截

面试向讲清前端最真实的 bug 之一——请求竞态:为什么"后发"的请求可能"先至"、 旧响应如何覆盖新数据;三层解决方案(防抖减少 / AbortController 真取消 / 请求序号兜底); React useEffect cleanup + ignore flag 的官方写法与它为什么有效; 以及 loading 状态漏校验、ref 而非 state 存 requestId 等高频踩坑点。

请求竞态处理:快速翻页与过期响应拦截

一句话概括

用户手速永远比网络快。切 tab、点翻页、连打搜索词——先发出去的请求不一定先回来,慢的那次后返回,就把新数据覆盖成了旧数据。这就是请求竞态(race condition),也是前端项目里最真实、最容易上线后才发现的 bug。

一句话结论:不能让”谁最后返回谁生效”,只能让”最新发起的那次生效”。

面试里的标准结构是三层,按这个顺序答最稳:

  1. 减少无效请求:输入类场景加防抖,或者复用缓存 —— 从源头少发几次;
  2. 让旧请求真正停下:AbortController 取消,顺带省流量、减轻后端;
  3. 让旧结果”即使回来也无效”:请求序号 / 有效性校验,丢弃过期响应。

第三层必须有,因为取消不一定来得及——响应已经在上路上(甚至已经回到 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 在内都要一起校验。

本文由作者按照 CC BY 4.0 进行授权