JavaScript 异步竞态治理:先定义谁能写,再谈取消请求

JavaScript 单线程并不等于没有竞态。只要多个异步任务会写同一份状态,而完成顺序又不可控,旧结果就可能覆盖新意图。本文用搜索框为例,把"最新者胜出、首个成功、排队执行"三类业务策略落成可测试代码,并解释为什么防抖、awaitAbortController 都不能单独解决全部问题。

单线程为什么仍有竞态

主线程一次只执行一段 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";
  }
}

这里有两个独立保障:版本号负责正确性,取消负责节省网络与计算。loadingerror 也必须经过版本校验,否则旧请求的 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,而是状态写入权。先为业务声明胜出规则,再用版本、取消、幂等或队列实现,最后用人为乱序测试证明规则成立。这样才能把"偶尔错一次"变成可推理、可验证的时序契约。

相关推荐
LaughingZhu几秒前
Product Hunt 每日热榜 | 2026-09-05
人工智能·深度学习·神经网络·搜索引擎·百度
魔众4 分钟前
5 分钟用 AIGCPanel 部署阿里 SenseVoice,中粤日韩英语音识别 + 情感分析全搞定
人工智能·语音识别
xwz小王子9 分钟前
机器人的“最后一毫米”: 新加坡南洋理工大学Facet-0如何教会基础模型“感受”自己的动作?
大数据·人工智能·机器人
今天AI了吗10 分钟前
DeepSeek Harness 深度解析:从评测架构到实战落地
java·网络·数据库·人工智能·架构·java-ee
腾视科技-AI21 分钟前
腾视科技AIBOX双版本重磅发布!本地安全与全球适配,解锁视频智能新可能
大数据·人工智能·科技·安全·大模型·腾视科技·ai算力盒
a11177638 分钟前
基于 ROS 2 的 Franka Panda 机械臂视觉分拣系统
人工智能·开源
逍遥~1421 小时前
什么是工业级算力?算盘科技的核心能力解析
网络·人工智能·科技
硅谷秋水1 小时前
JEPA-WAM:基于联合嵌入世界建模的VLA策略学习
人工智能·机器学习·计算机视觉·语言模型·机器人
新知图书1 小时前
第10章 云上MCP服务的部署与使用
人工智能·智能体
程序员无隅1 小时前
从 Vibe Coding 到可控交付:用 Spec-Driven Development 驾驭 AI 编程 Agent
人工智能·驱动开发