---
title: "我测了 6 种防请求竞态的写法：只有 1 种真能打 - Angryecho"
description: "搜索框里打个\"abc\"，列表闪一下，显示的却是\"a\"的结果。刷新一下好了，再打一遍又复现——这就是请求竞态。写搜索、筛选、分页的前端人人都见过它，也人人都有一套防法：加 loading、上防抖、请求带序号。下拉筛选、tab 切换、无限滚动，全是同一个雷换了个皮。"
canonical_url: "https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2iUFcOBcPtiyF59t"
meta:
  "og:description": "搜索框里打个\"abc\"，列表闪一下，显示的却是\"a\"的结果。刷新一下好了，再打一遍又复现——这就是请求竞态。写搜索、筛选、分页的前端人人都见过它，也人人都有一套防法：加 loading、上防抖、请求带序号。下拉筛选、tab 切换、无限滚动，全是同一个雷换了个皮。"
  "og:image": "https://www.angryecho.com/_og/d/c_Post%2Ctitle_~5ZCQ5qe9572R%2Cdescription_~5ZCQ5qe95LiA5LiL77yM5bCx5LiK5ZCQ5qe9572R44CC%2CpostId_P2TBC2iUFcOBcPtiyF59t%2Crevision_~MjAyNi0xMS0wM1QwMjo1NTo1Ni4wMDQ2NzRa%2Cp_Ii9jL3FpYW5fZHVhbl9zaGVfcXUvcC9QMlRCQzJpVUZjT0JjUHRpeUY1OXQi%2Cs_bn5XwgYyBw9YpkM0.png"
  "og:image:height": "630"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "我测了 6 种防请求竞态的写法：只有 1 种真能打"
---

> Index of every page on this site: [llms.txt](https://www.angryecho.com/llms.txt)

# **我测了 6 种防请求竞态的写法：只有 1 种真能打**

搜索框里打个"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 吞输入的更好，说说当时怎么排查的，攒够素材我把库层竞态那篇提前。

排序方式 :

### **成为第一条评论！**

Nobody's responded to this post yet. Add your thoughts and get the conversation going.

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

## Sitemap

See the full [sitemap](https://www.angryecho.com/sitemap.md) for all pages.
