给 fetch 加上超时:AbortSignal.timeout 怎么用
fetch 默认没有超时。本文用 AbortSignal.timeout 与 AbortController 做超时和取消,并分清 TimeoutError、AbortError 与 res.ok。
fetch 很好用,但有个容易忽略的点:它默认没有超时。请求卡住时,Promise 会一直挂着,页面上的 loading、按钮禁用状态也可能跟着挂。对用户来说,这比直接报错更糟——界面像死了一样,却不知道还能不能点。
本文说明怎么用标准的 AbortSignal / AbortController 给请求加上超时和手动取消。示例已在 Node.js v20.19.2 上跑通;浏览器侧请对照文末 MDN 的兼容性说明(AbortSignal.timeout() 属于 Baseline 2024 Newly available)。
要解决的问题
日常前端里,请求相关的麻烦通常不是「怎么发」,而是「发完之后怎么收场」。常见需求有两类:
- 超时:比如 5 秒还没拿到响应,就放弃,别无限等。接口偶发变慢时,这能避免整页被拖住。
- 取消:用户点了「取消」,切换了路由,或组件卸载了,应立刻停掉进行中的请求,少做无用功,也少触发过期的状态更新。
这两件事都靠同一个机制:给 fetch 传入一个会被「中止」的 signal。中止之后,fetch 返回的 Promise 会以错误结束,你可以在 catch 里决定提示文案。
核心概念
先分清三个名字:
AbortController:你主动调用abort()时,关联的signal变为已中止。适合按钮取消、页面离开这类「人的决定」。AbortSignal.timeout(ms):返回一个信号,到时间后自动中止。按 MDN,超时时以TimeoutError的DOMException中止,这样能和用户取消区分开。AbortSignal.any([...]):任一信号中止,合成信号就中止——适合「超时或用户取消,谁先谁赢」。
MDN 对 AbortSignal.timeout() 的说明大意是:它返回一个会在指定时间后自动中止的 AbortSignal,超时时以 TimeoutError 中止。超时按「活跃时间」计算;页面进了往返缓存等挂起场景时,计时可能暂停,这点写死「墙上时钟」时要心里有数。
示例:超时请求
async function fetchWithTimeout(url, ms = 5000) {
const res = await fetch(url, {
signal: AbortSignal.timeout(ms),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
}
try {
const data = await fetchWithTimeout("/api/items", 5000);
console.log(data);
} catch (err) {
if (err.name === "TimeoutError") {
console.error("超时了,可以提示用户重试");
} else if (err.name === "AbortError") {
console.error("请求被中止(例如用户取消)");
} else {
console.error("其它错误", err);
}
}
本机验证:对故意延迟 500ms 的本地 HTTP 服务,用 AbortSignal.timeout(50) 调用 fetch,抛出的错误名为 TimeoutError;延迟 10ms、超时设为 1000ms 时则正常返回正文。
示例:超时 + 手动取消
async function fetchFlexible(url, { ms = 5000, manual } = {}) {
const signals = [AbortSignal.timeout(ms)];
if (manual) signals.push(manual);
const res = await fetch(url, {
signal: AbortSignal.any(signals),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
const controller = new AbortController();
// 用户点取消时:controller.abort()
const data = await fetchFlexible("/api/items", {
ms: 5000,
manual: controller.signal,
});
本机验证:用 AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),在 15ms 后调用 controller.abort(),错误名为 AbortError。
老环境若不支持 AbortSignal.timeout,可用经典写法:setTimeout(() => controller.abort(), ms),再把 controller.signal 传给 fetch。Chrome 开发者博客早期的 Abortable fetch 文章就演示过这种模式。
常见误区
- HTTP 4xx / 5xx 不会让
fetch抛错。 只有网络失败、被中止等才会进catch。本机对返回 404 的本地服务验证:res.ok === false,status === 404,没有异常。业务失败要自己检查res.ok,再决定是否抛业务错误。 TimeoutError和AbortError不是一回事。 用AbortSignal.timeout单独超时时,错误名是TimeoutError;用户abort()一般是AbortError。UI 文案可以分开写:超时偏「再试一次」,取消偏静默结束。- 兼容性。 MDN 将
AbortSignal.timeout()标为 Baseline 2024 Newly available(约 2024 年 4 月起各主流最新版可用)。若还要照顾更老的浏览器,优先用AbortController+setTimeout的兼容写法。 - 同一个已中止的
signal不要重复拿去发新请求。 MDN 说明大意是:信号中止后,再用它发起的fetch会立刻失败。每次请求用新的 controller / timeout 信号更稳妥。
小结
fetch 本身不带超时;用 AbortSignal.timeout(或 AbortController + 定时器)把「放弃」说清楚,再用 err.name 区分超时和手动取消,并始终检查 res.ok。这三件事做完,请求层的行为就清晰多了——用户也不会对着一个永远转圈的按钮干着急。
参考资料
谢谢你读到这里。