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

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

相关推荐
shijuejianceliu22 分钟前
传统规则视觉与嵌入式AI智能相机的技术差异
人工智能·嵌入式硬件·视觉检测·相机
goujunwe39 分钟前
GEO 内容的“可摘录性”打磨技巧:段落、表格、小标题怎么写,方便大模型提取独立答案块
大数据·人工智能
羲云网络1 小时前
GPT Image 2.6 接入指南:Flare、Sunburst 怎么选,编辑失败如何排查
人工智能·python·aigc·图像生成·acedatacloud
张张123y1 小时前
DSH 插件调试避坑:client.js 设置面板、热重载与高频报错排查表
开发语言·javascript·人工智能·开源·ecmascript·后端开发·ai agent
tellmewhoisi3 小时前
深度学习0:数学知识:正态分布4(坐标变换的缩放比例(面积微元变换))
人工智能·深度学习
布吉岛的石头3 小时前
Java 程序员第 50 阶段14:灾难性遗忘与回放
人工智能·深度学习·transformer
不吃鱼的羊3 小时前
诊断27解锁dll文件制作(Visual Stdio无法安装版)
人工智能
后端小肥肠4 小时前
漫画工坊上线用户破百:公众号漫画从需求到变现全复盘
人工智能·aigc·agent
赛博山人4 小时前
字节跳动下线扣子企业版,团队已并入豆包
人工智能