AbortSignal.timeout() 进 Baseline 两年,你还在 new AbortController() 配 setTimeout 手搓超时

一个 fetch 请求卡住了,页面转了十秒钟的圈;用户点了取消,请求还在后台跑;组件已经卸载,回调还往已销毁的 DOM 上写数据。这三件事的根源是同一个:你的异步操作没有一个统一的「终止开关」。而浏览器早就有两把现成的钥匙——AbortSignal.timeout() 和 AbortSignal.any(),从 2024 年 4 月起就进了 Baseline,你可能一次都没用过。

先说结论

  • 超时不用再手搓 new AbortController() + setTimeout,一行 AbortSignal.timeout(ms) 就够;
  • 「用户取消」和「超时」这两个条件要同时生效,用 AbortSignal.any([...]) 合并成一个信号传给 fetch;
  • 超时抛的是 TimeoutError,用户主动取消抛的是 AbortError,两者的处理分支不一样,别再笼统地 catch 一把梭;
  • 这两个都是静态方法,返回的 AbortSignal 自带 reason,不需要你自己维护状态。

问题:手搓超时这件事,错在哪

大部分项目里的超时代码大概长这样:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

try {
  const res = await fetch(url, { signal: controller.signal });
  clearTimeout(timer); // 忘了这句,定时器就泄漏
  return await res.json();
} catch (e) {
  // 分不清是超时还是用户取消,还是一般网络错误
}

问题有三层:第一,clearTimeout 一旦漏写,定时器就挂在事件循环里;第二,controller.abort() 不带 reason,catch 里拿到的 AbortError 无法区分「时间到了」和「用户点了取消」;第三,如果你想同时支持「用户手动取消」和「最多等 5 秒」,就得自己把两个信号缝起来,缝不好还会漏掉一种情况。

方案:两个静态方法各管一件事

超时交给 AbortSignal.timeout()

try {
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
  const data = await res.json();
} catch (err) {
  if (err.name === "TimeoutError") {
    // 5 秒没等到结果
  } else if (err.name === "AbortError") {
    // 浏览器主动中止(用户点停止、关闭标签页等)
  } else {
    // 网络错误等其他情况
  }
}

它返回的 signal 会在指定毫秒后自动 abort,并以 TimeoutError 作为 reason。不需要 controller,不需要 clearTimeout,也不需要自己记账。

「超时或用户取消,谁先到算谁」交给 AbortSignal.any()

const userCancel = new AbortController();

cancelBtn.addEventListener("click", () => userCancel.abort());

const combined = AbortSignal.any([
  userCancel.signal,          // 用户点了取消
  AbortSignal.timeout(5000),  // 或者 5 秒到了
]);

try {
  const res = await fetch(url, { signal: combined });
} catch (e) {
  if (e.name === "AbortError") { /* 用户取消 */ }
  else if (e.name === "TimeoutError") { /* 超时 */ }
}

AbortSignal.any() 接受一组信号,返回一个「任意一个先 abort 就跟着 abort」的新信号,reason 取第一个触发的那个。

三个容易踩的坑

1. any() 之后分不清是谁触发的? 它能保住 reason:如果超时先到,reason 就是 TimeoutError;用户先点取消,reason 就是用户传来的。但如果你在 any() 外面套了一层自己的 abort(),那就区分不出来了——按 reason 判断,别按「调用顺序」判断。

2. 超时信号无法中途取消。 MDN 明确写了:AbortSignal.timeout() 没有提供取消自身定时器的能力,操作提前完成也不会取消它。所以别在长生命周期对象上挂超长超时(比如几小时的 session);如果一定要能提前清理,就用 AbortController + setTimeout / clearTimeout 那套手工方案。

3. 监听器记得摘。 只要 signal 上还挂着 abort 监听器,signal 就会在超时到期前一直存活,长超时 + 长生命周期容易拖住 GC。用 { once: true },或者在操作结束后手动 removeEventListener

一并改造那些「可取消」的自有 API

不只是 fetch。任何返回 Promise 的自有方法都可以接受一个 signal,做成可中止:

function longTask({ signal }) {
  return new Promise((resolve, reject) => {
    signal.throwIfAborted(); // 已经中止就直接抛,别白干
    const onAbort = () => reject(signal.reason);
    signal.addEventListener("abort", onAbort, { once: true });
    // ...真正的工作,完成后 resolve(result)
  });
}

这样 AbortSignal.timeout()AbortSignal.any() 的能力就能透传到你自己封装的 API 上,整个调用链共享同一套取消语义。

落地下一步

搜一遍代码库里的 new AbortController(),看看哪些是为了「配一个超时」才创建的——这些都可以直接换成 AbortSignal.timeout(),顺手删掉配套的 setTimeout / clearTimeout。如果发现某个请求既要「超时」又要「用户可取消」,就把它改成 AbortSignal.any([...]),并补上按 err.name 分支的错误处理。半天之内,你的取消逻辑会从「每处各写一套」收敛成「全站一个模式」。

评论区

0 条评论

登录后可评论。

阿速·性能优化 89 阅读