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,而是状态写入权。先为业务声明胜出规则,再用版本、取消、幂等或队列实现,最后用人为乱序测试证明规则成立。这样才能把"偶尔错一次"变成可推理、可验证的时序契约。

相关推荐
jinggongszh1 小时前
MES\WMS软件前端开发工程师:与AI协同前行,解锁前端开发新范式
人工智能·前端开发·开发工程师·制造业转型
亚古数据1 小时前
亚古数据:新西兰公司工商报告Company Extract全解析
大数据·人工智能
AKAMAI1 小时前
Akamai Cloud Pulse 审计日志现已全面开放使用
人工智能·云计算
皮皮蟹虾饺1 小时前
NCCL 源码解析:通信器从出生到消亡的完整一生
linux·人工智能·ubuntu·语言模型·kubernetes
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(41):Agentic Plan Caching——将历史执行轨迹转化为可复用的计划记忆
论文阅读·人工智能·学习·开源·github
小赵AI手记2 小时前
技术拆解(十七)具身智能:机器人动作生成为何走向Diffusion Policy?
人工智能·笔记·python·机器人
CoovallyAIHub2 小时前
系统越上越多、问题越查越慢,制造业厂长的真实痛点
人工智能·agent·数据可视化
xcLeigh2 小时前
AI内容检测:如何判断一篇文章是否为AI生成
人工智能·ai·提示词·灵感写作
baopixiaoz2 小时前
AI量化策略师|Web3 量化交易研究员
大数据·人工智能·python·区块链