JavaScript 单线程并不等于没有竞态。只要多个异步任务会写同一份状态,而完成顺序又不可控,旧结果就可能覆盖新意图。本文用搜索框为例,把"最新者胜出、首个成功、排队执行"三类业务策略落成可测试代码,并解释为什么防抖、await 和 AbortController 都不能单独解决全部问题。
单线程为什么仍有竞态
主线程一次只执行一段 JavaScript,但网络、计时器和用户操作可以交错完成:
text
输入 vue -> 请求 A --------------------> 返回
输入 react -> 请求 B -----> 返回
若 A、B 都能直接执行 state.results = data,最终页面取决于响应顺序,而不是用户最后一次输入。竞态的判断条件可以压缩成三问:是否有多个存活任务,是否写同一状态,完成顺序是否不可控。
第一步不是选 API,而是定义胜出规则
不同业务需要不同规则:
| 场景 | 规则 | 常见实现 |
|---|---|---|
| 搜索建议、路由详情 | 最新意图胜出 | 版本号 + 取消旧任务 |
| 支付、创建订单 | 首个成功胜出 | 幂等键 + 禁止重复提交 |
| 批量上传、迁移任务 | 保持顺序 | 队列或串行消费者 |
| 多来源聚合 | 全部保留 | Promise.allSettled + 按来源合并 |
把所有异步操作都套成"最后返回者胜出",只是把偶发 Bug 写成规则。
最新意图胜出:资格校验与取消要同时做
AbortController 能减少无效工作,但"请求被取消"不等于"旧任务绝不写状态":响应可能已经到达,后处理也可能不可取消。因此仍需版本号决定写入资格。
ts
type SearchState<T> = {
status: "idle" | "loading" | "success" | "error";
data: T[];
error?: string;
};
let generation = 0;
let activeController: AbortController | undefined;
async function search<T>(keyword: string, state: SearchState<T>) {
const mine = ++generation;
activeController?.abort();
const controller = new AbortController();
activeController = controller;
state.status = "loading";
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = (await response.json()) as T[];
if (mine !== generation) return;
state.data = data;
state.status = "success";
} catch (error) {
if (mine !== generation || controller.signal.aborted) return;
state.status = "error";
state.error = error instanceof Error ? error.message : "unknown error";
}
}
这里有两个独立保障:版本号负责正确性,取消负责节省网络与计算。loading、error 也必须经过版本校验,否则旧请求的 finally 可能提前关闭新请求的加载状态。
首个成功与排队执行
提交类操作不能照搬"最新者胜出"。客户端按钮禁用只是体验优化,服务端还需要幂等键:
ts
const idempotencyKey = crypto.randomUUID();
await fetch("/api/orders", {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": idempotencyKey,
},
body: JSON.stringify(order),
});
必须保持顺序的任务可用一个简单 Promise 链串行化;失败是否阻断后续任务,要由业务明确决定。
ts
let tail = Promise.resolve();
function enqueue<T>(job: () => Promise<T>): Promise<T> {
const current = tail.then(job, job);
tail = current.then(() => undefined, () => undefined);
return current;
}
用故意乱序的测试抓住问题
竞态很难靠正常网络稳定复现,测试应主动控制完成顺序:
ts
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
}
// 测试思路:先发 A,再发 B;先完成 B,再完成 A;断言最终仍是 B。
至少覆盖:旧请求晚到、旧请求报错、新请求被取消、组件卸载、空查询、连续三次输入,以及 loading 不被旧任务关闭。防抖只减少请求数量,不能替代这些时序断言。
边界条件
- 流式响应需要在每次追加片段前检查版本,而非只在连接建立时检查。
- 缓存命中也可能异步返回,不能假设它一定先于网络结果。
- 组件销毁时既要取消任务,也要阻止后续状态写入。
- 服务端产生副作用的请求不能依赖客户端取消;取消连接不代表服务端已回滚。
总结
治理竞态的核心不是某个 API,而是状态写入权。先为业务声明胜出规则,再用版本、取消、幂等或队列实现,最后用人为乱序测试证明规则成立。这样才能把"偶尔错一次"变成可推理、可验证的时序契约。