搜索框里打个"abc",列表闪一下,显示的却是"a"的结果。刷新一下好了,再打一遍又复现——这就是请求竞态。写搜索、筛选、分页的前端人人都见过它,也人人都有一套防法:加 loading、上防抖、请求带序号。下拉筛选、tab 切换、无限滚动,全是同一个雷换了个皮。
我把这 6 种防法全部丢上同一台实验台,两种打字节奏各跑几十轮,判分标准跑前定死。先看判决:
| 写法 | 快速连打 | 正常打字速度 | 服务端实际算(快打) | 判决 |
|---|---|---|---|---|
| 什么都不加 | 100% 显示旧数据 | 47% 显示旧数据 | 3/3 | 出事 |
| loading flag 守卫 | 100% 错,新输入直接被吞 | 43% 显示旧数据 | 1/3(错得更狠) | 出事 |
| 300ms 防抖 | 全对(多花 300ms) | 50% 显示旧数据 | 3/3 | 出事 |
| 请求序号守卫 | 全对 | 全对 | 3/3 白算 | 能用 |
| 最新值比对 | 全对 | 全对 | 3/3 白算 | 能用 |
| AbortController | 全对 | 全对 | 1/3,真取消 | 全能 |
最流行的两个兜底——loading flag 和防抖——全在"出事"那一档。下面按最坑的顺序一个一个说。
判决表怎么读:前两列是 30 轮里最终显示错数据的比例,第三列是快打 30 轮里服务端实际算完的请求数(总共发了 3 个)——它衡量的是浪费,不是对错。
实验台怎么搭的
判分标准跑前定死:最终 UI 正确且不白发请求 = 全能;正确但白算 = 能用;只要存在一种打字节奏会显示旧数据 = 出事。不允许跑完再定标准。
服务端是本机起的一个搜索接口,语义只有一条:词越短,命中越多,算得越慢("a" 要 600ms,"abc" 只要 150ms,带随机抖动)。这不是刁难——真实搜索后端就是这样,短词匹配的文档多、聚合重,反而比长词慢。缓存更会把这件事放大:长词经常命中缓存秒回,短词每次都打穿到计算层。
输入流 a → ab → abc,跑两种节奏:间隔 60ms 的快速连打(输入法联想那种手感),和间隔 400ms 的正常打字速度。为什么必须跑两种?快打测的是"防线扛不扛得住",慢打测的是"会不会悄悄失守"——有的防线快打满分,慢打开漏(防抖说的就是它)。只测一种节奏等于白测。Node 跑 30 轮,再开真 Chrome 跑 10 轮交叉验证,两边结论完全一致。判决表里"正常打字速度"那一列,就是防抖的死亡之列。
旧数据是怎么合法上屏的
裸奔组快打 30 轮全错。把时间线摊开看,机制很朴素:
t=0 发出 a (后端 600ms)
t=60 发出 ab (后端 350ms)
t=120 发出 abc (后端 150ms)
t=270 abc 回来了 → 上屏 ✓
t=530 ab 回来了 → 上屏(此刻 UI 已经错了)
t=720 a 回来了 → 上屏(最终停在错误结果上)
回来顺序和发出顺序完全相反,谁最后到谁上屏——a 永远是最后一个。abc 的正确结果在屏幕上停留了不到半秒,就被两个过期响应先后冲掉。用户看到的是转圈、闪两下、然后一份"a"的旧数据。
你等的那一下转圈,转完经常是错的。慢打组也有 47% 的错率,这意味着不修也"能用"是错觉:正常用半天就能撞见一次。
有人会问:慢打间隔 400ms,a 都回来 100ms 了才发 ab,怎么还会乱序?答案在后端的方差。我给接口加了 ±600ms 的随机抖动——真实后端就是这种脾气:查缓存 50ms,没查中算 800ms,同一接口两次耗时能差十几倍。慢打时 a 恰好抽中一次长耗时,就被后写的 ab 反超。乱序不需要你打得快,后端波动一个随机数就够了。
loading flag 不防竞态,它吞输入
流传最广的防法长这样:
let loading = false;
async function search(q) {
if (loading) return; // ← 雷
loading = true;
const res = await fetch(`/api/search?q=${q}`);
render(await res.json());
loading = false;
}
快打时发生的事:"a" 发出,loading 变 true,紧接着的 "ab" 和 "abc" 全被 return 挡掉,a 的结果回来上屏,loading 释放,结束。
你输入的 abc 这个请求,从头到尾没有发出去过。
30/30 全错,而且界面上没有任何异常——列表有数据、不报错、不转圈,用户以为搜完了。慢打时 flag 会释放,但释放之后的窗口照样是裸奔,43% 错。
这是六种写法里我唯一建议你今晚就去 grep 代码库的:搜 if (loading) return,命中在搜索函数里的,大概率埋着这颗雷。
为什么这种写法遍地都是?三个原因:代码最短,两行搞定;看起来省请求,符合直觉;连不少面试教学都这么写。但它防的是"重复提交",不是竞态——竞态的本质是响应乱序,flag 从头到尾没碰过乱序问题,只是把发请求的入口关小了。入口关小了,里面该乱还是乱。
防抖是个节奏赌徒
防抖在快打时确实完美:键间隔小于 300ms,请求被合并成 1 个,全对。「加了防抖就没竞态」这句话就是这么来的。
但把键间隔放到 400ms——正常人打字就是这个速度——防抖每次都放行,每个键各发一个请求,竞态窗口和裸奔一模一样:50% 显示旧数据。
防抖只管频率,不管正确性。它赌你打得快,赌输了就是裸奔。(防抖背锅这不是第一次,懒加载那篇它刚背过一回。)
替它说句公道话:防抖本身没错,它解决的是"别每个键都发请求"这个频率问题。错的是大家把它当成了竞态防线——它压根没接过这个活。所以结论不是弃用防抖,而是别指望它防竞态。
而且快打全对也有代价:300ms 延迟税。正确结果 576ms 才上屏,AbortController 只要 269ms。
UI 全对的两种,服务端在白算
序号守卫和最新值比对是"正确派":响应回来先验明正身,过期的丢弃。序号守卫比对请求序号,最新值比对 query 字符串,思路等价,UI 60/60 轮零错。
序号守卫长这样,核心三行:
let seq = 0;
async function search(q) {
const mySeq = ++seq;
const res = await fetch(`/api/search?q=${q}`);
if (mySeq !== seq) return; // 过期响应,丢弃
render(await res.json());
}
问题在另一头。服务端实际算了几次?3/3。每个键都白发一个完整请求,三分之二的计算结果被扔掉。搜索联想是每键一发的场景,这个浪费是乘在 QPS 上的。移动端弱网下更贵:3 个请求 3 份流量 3 份电量,用户只看到 1 份结果。
为什么不全用它?abort 是把请求掐死在半路,序号是让请求跑完全程再验票——票作废了,车钱照付。能用。但要知道自己在花这个钱。
AbortController,唯一的全能选手
let ctrl;
async function search(q) {
ctrl?.abort(); // 上一个请求,死
ctrl = new AbortController();
const res = await fetch(`/api/search?q=${q}`, { signal: ctrl.signal });
render(await res.json());
}
过期请求被中途取消,根本没有"回来覆盖"的机会——UI 60/60 全对。服务端日志里看得见取消的现场:
ABORT q=a after=63ms
ABORT q=ab after=70ms
连接被客户端提前断开,服务端不再计算。30 轮均值下,服务端从算 3 次降到算 1 次,三分之二的算力直接省掉。换到服务端视角,被取消的请求是一次 socket 提前断开:请求头进来了,算到一半对端没了。如果你的后端在监控接口耗时,被 abort 掉的请求根本不会计入完成耗时——前端这一手省算力,后端监控看得见。
两个注意事项。一,abort 会抛 AbortError,不 catch 就是控制台红字,永远 .catch() 接住。二,组件卸载时也要 abort——React 里最典型的写法是 useEffect 里带上 cleanup:
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(render)
.catch((e) => { if (e.name !== 'AbortError') throw e; });
return () => ctrl.abort(); // q 变了或组件卸了,旧请求当场死
}, [q]);
没有这行 cleanup,切页之后还会飞回来一个幽灵响应——你的 setState 打在一个已经卸载的组件上,竞态从搜索框转移到了页面生命周期里,套路一模一样。
有人觉得 abort 门槛高——兼容性、库支持。2026 年这个顾虑可以放下了:fetch、axios、uni.request 全都吃 signal,Node 的 fetch 也原生支持,浏览器支持早就全绿。它反而是今天六种写法里写起来最不费劲的一个。
正确背法
一句话:防抖管频率,abort 管正确性,两个一起上。
let ctrl, timer;
function onInput(q) {
clearTimeout(timer);
timer = setTimeout(() => {
ctrl?.abort();
ctrl = new AbortController();
fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(render)
.catch((e) => { if (e.name !== 'AbortError') throw e; });
}, 300);
}
防抖把请求频率压下来,abort 兜住所有竞态窗口。请求没法 abort 的场景(POST 查询、GraphQL)退而求其次用序号守卫,但要清楚服务端在白算。React Query、SWR 这类库内部干的就是 abort 加守卫这套——手写场景照抄这个思路就够了。
存量代码不用推倒重来:老搜索函数里已经套了防抖的,在请求发起那一行补上 abort 三件套就够了,改动不超过 5 行;想彻底省心就把整段换成 TanStack Query 的 useQuery({ queryKey: [q] }),竞态处理它自带。三行起步,看你想投多少。
一行速查版:
- 每键即搜的搜索联想:防抖(管频率)+ abort(管正确性),缺一不可
- 请求发不出去的(POST 查询、GraphQL):序号守卫顶上,接受三分之二白算
- 不想手写:TanStack Query / SWR,内部就是这套
- 唯独不能选的:什么都不加,和只加 loading flag
两个抬杠我先接了
「我没加任何防竞态,从来没出过事。」两种可能:你的请求恰好永远比旧请求快(比如接口严格按顺序返回),或者你根本没盯着看。慢打 47% 的错率,撞上是时间问题。真正可以不加的场景也有——整个页面只有一个请求的详情页,或者后端做了请求排队。但"我们后端会排队"这句话,先去验证了再信,别听嘴上的。
「序号守卫够用了,别吓唬人。」够用,判决表里我写的就是"能用"。但每键白发三分之二请求是真实成本。知道成本再选,和不知道成本瞎选,是两回事。
「axios CancelToken 行不行?」行,但它已经被 axios 官方标了 deprecated,官方文档自己推荐的替代品就是 AbortController——新代码别再往 CancelToken 上写了。还有人觉得 React 的 setState 批处理或并发特性能兜住乱序:批处理管的是多次 setState 合并成一次渲染,不管网络响应谁先谁后,两码事。乱序发生在网络层,框架层接不住,只能你自己在请求层接。
实验边界
三条。一,服务端是单机本地接口,没有公网 RTT 方差——真实公网只会乱得更凶,不会更轻。二,判决表数字是 Node 30 轮 + Chrome 10 轮的均值口径,实验代码全部在 race-lab 目录,可复跑。三,框架层不在本次范围:React 严格模式的 double-effect、React Query 内部的去重机制,值得单独写一篇。
评论区报一下你们项目在用哪种——尤其还在写 if (loading) return 的。踩过 flag 吞输入的更好,说说当时怎么排查的,攒够素材我把库层竞态那篇提前。