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 分支的错误处理。半天之内,你的取消逻辑会从「每处各写一套」收敛成「全站一个模式」。
评论区
登录后可评论。