ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

fetch请求超时控制全解析:从AbortController到生产级封装

fetch请求超时控制全解析:从AbortController到生产级封装 你有没有遇到过这种情况后端接口偶尔抽风迟迟不给响应页面上的 loading 转了几十圈用户等得不耐烦直接关掉页面。更烦人的是你用fetch()发请求时发现——它压根没有一个timeout参数。这和axios里的timeout: 5000一比简直就是原始社会。网上搜一圈相关的报错倒是一大堆什么failed to fetch remote profile with status 403、could not fetch index base url、git 拉代码时fetch卡死归根结底都是同一个问题请求没有超时控制系统只能在底层连接彻底失败时才放弃。这个需求太常见了请求超时、接口超时、fetch 超时、AbortController。今天我把 fetch 请求超时的完整方案讲清楚从最基础的原理到生产可用的封装再到各种场景下的坑一次说透。适合刚接触 fetch 的小白也适合想把请求层做扎实的中级前端。1. 先搞明白fetch 为什么没有一个 timeout 参数很多从axios转过来的同学都有这个疑惑。axios里写timeout: 5000就能让请求在 5 秒后自动取消fetch作为浏览器原生 API为什么连这么基础的能力都没有1.1 浏览器网络请求的超时模型先说结论fetch不提供timeout参数不是因为浏览器做不到而是设计哲学不同。fetch的设计理念是底层能力的最小集合它把超时、重试、缓存这些高级能力留给开发者自己组合。这就像给你一套乐高积木而不是一个拼好的模型——灵活但需要自己动手。底层上浏览器本身是有超时机制的。TCP 连接有connect timeoutTLS 握手有超时读取响应有超时。但这些超时时间由浏览器内核控制通常在几十秒到几分钟不等而且不同浏览器、不同网络环境下差异很大。对一些需要快速失败的场景比如用户点击按钮后 5 秒内必须给出反馈浏览器默认的超时时间显然太慢了。1.2 真正的主角AbortController既然fetch不提供超时参数那怎么实现答案就是AbortController。这是 DOM 标准提供的终止信号机制从 Chrome 66、Firefox 57、Safari 12.1 开始就得到了广泛支持现在已经是所有现代浏览器的标配。AbortController的使用非常直观const controller new AbortController(); const signal controller.signal; // 把 signal 传给 fetch fetch(/api/data, { signal }); // 随时调用 abort() 终止请求 controller.abort();当你调用abort()时fetch返回的 Promise 会立即以AbortError的理由 reject请求被终止。这就是实现超时的基石——用一个定时器到点后触发abort()逻辑上完全说得通。有一点需要注意fetch之外AbortController还能用来终止其他支持信号的操作比如ReadableStream的读取、EventSource、axios的新版本也支持signal配置。这是个通用机制值得花时间搞清楚。1.3 超时和不超时的行为差异我实测过几种情况下的差异直接看结果场景无超时控制有超时控制如 5s后端 3 秒返回正常正常无感知后端 10 秒返回10 秒后才 resolve5 秒时 reject快速失败后端永远不返回挂起直到浏览器内部超时或用户离开页面到点即失败用户手动取消无 API 支持可以配合 abort 实现没有超时控制时最坏的情况是页面组件已经卸载了请求还挂在那里等它返回时可能触发setState警告甚至造成内存泄漏。加了超时控制后至少你的代码能在一个可预期的时间点接管控制权。2. 最主流做法用 AbortController setTimeout 给 fetch 加超时理解了AbortController的原理实现超时的基本思路就出来了发起请求的同时启动一个定时器到点就调用abort()。2.1 5 行代码跑通基础版function fetchWithTimeout(url, options {}, timeout 5000) { const controller new AbortController(); const { signal } controller; // 到点就终止请求 const timer setTimeout(() controller.abort(), timeout); return fetch(url, { ...options, signal }).finally(() { clearTimeout(timer); }); }用法try { const res await fetchWithTimeout(/api/user, { method: GET }, 3000); const data await res.json(); console.log(data); } catch (err) { if (err.name AbortError) { console.error(请求超时了); } else { console.error(其他错误, err); } }这个版本的逻辑fetch一结束无论成功还是失败就用finally把定时器清掉防止定时器残留。如果一个请求 1 秒就返回了那个 5 秒的定时器还挂着到点后会调用一个已经没有意义的abort()——虽然abort()对已完成的请求无效不会报错但白白占了一个定时器资源在频繁请求的场景下积累起来是隐患。2.2 为什么要用 finally 而不是 then初学的时候我写过这样的代码// 错误示范 function fetchWithTimeout(url, options {}, timeout 5000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }).then( (res) { clearTimeout(timer); return res; }, (err) { clearTimeout(timer); throw err; } ); }功能没错但把清理逻辑散落在then的成功和失败回调里代码丑且容易遗漏。finally不管 Promise 最终是 resolve 还是 reject 都会执行天然适合无论结果如何都要做的清理操作。这是fetch社区的常见写法也是我认为最干净的一种。这里有个细节值得注意清定时器的动作不需要在abort()之前做。abort()之后fetch会进入 reject 流程finally里的clearTimeout会执行顺序是安全的。2.3 基础版的问题超时信息不够明确用基础版捕获到的错误err.name是AbortError只知道请求被终止了无法直接判断是超时终止还是用户手动取消。咱们自己写的代码里只有超时会触发abort()暂时没这个问题。但一旦代码复杂起来多个地方可能持有同一个 controller就要想办法区分错误来源。这个问题在下一节展开讲。另一个隐患是超时后直接吞掉原始请求的响应。如果请求实际上在 5.5 秒时成功了但我们的超时设置是 5 秒abort()已经发出请求结果会被浏览器丢弃不会交给业务代码。这不是 bug是超时控制的预期行为但心里要有数。3. 进阶玩法三种提升健壮性的封装思路基础版能用但离生产级还有距离。我工作中遇到的真实需求往往比这个复杂有的要求超时后清理所有资源有的要求手动取消和超时共存有的要求兼容不支持AbortController的老环境。下面这些是我实战中验证过的封装思路。3.1 AbortSignal.timeout()一行代码搞定如果你不需要兼容老浏览器AbortSignal.timeout()是目前最简洁的写法。这是 2022 年前后加入标准的静态方法Chrome 103、Firefox 100、Safari 16 都支持。// 5 秒超时 const res await fetch(/api/data, { signal: AbortSignal.timeout(5000), });AbortSignal.timeout(5000)会生成一个 5 秒后自动触发 abort 的信号不需要手动管理定时器不需要清理逻辑。它的行为和我们用setTimeoutabort()完全一致但代码量少了四行。不过有个细节要注意AbortSignal.timeout()生成的信号对象你拿不到对应的AbortController所以它只能用于超时不能用于手动取消。如果你在页面上有一个取消按钮需要用户手动终止请求那就得另配一个 controller。两种信号不能互相合并使用除非用AbortSignal.any()。3.2 AbortSignal.any()把手动取消和超时取消合并AbortSignal.any()也是较新的 APIChrome 116、Firefox 116、Safari 17 才支持。它的作用是接收多个信号只要其中一个 abort整个信号就 abort。这个场景在同时支持用户取消和超时时非常有用。function fetchWithManualAbort(url, options {}, timeout 5000) { const controller new AbortController(); // 组合两个信号手动取消 超时 const signal AbortSignal.any([ controller.signal, AbortSignal.timeout(timeout), ]); return fetch(url, { ...options, signal }).finally(() { // 手动 controller 也需要随手清理 }); } // 外部可以通过 controller.abort() 手动取消 const controller new AbortController(); fetchWithManualAbort(/api/data, { signal: controller.signal });这里需要用你自己的controller.signal替换掉示例里写的controller.signal注意别和内部手动 controller 搞混。最稳妥的写法是function fetchWithManualAbort(url, options {}, timeout 5000) { const timeoutController new AbortController(); const timer setTimeout(() timeoutController.abort(), timeout); // 外部传入的 signal用于手动取消和超时信号合并 const signal options.signal ? AbortSignal.any([options.signal, timeoutController.signal]) : timeoutController.signal; return fetch(url, { ...options, signal }).finally(() { clearTimeout(timer); }); }这样调用方传入的signal依然有效而内部超时逻辑也保留两者互不干扰。3.3 Promise.race 方案看着简单坑不少网上还有一种实现超时的方案是用Promise.race// 错误示范只做超时判断没真正取消请求 function fetchWithTimeoutByRace(url, options {}, timeout 5000) { return Promise.race([ fetch(url, options), new Promise((_, reject) setTimeout(() reject(new Error(timeout)), timeout) ), ]); }这个方案的优点是非常直观——谁先完成谁说了算。但它有两个致命问题第一请求并没有被真正取消。如果 5 秒超时了Promise.race确实 reject 了但底层的fetch还在继续飞等到服务器返回后Promise 的 resolve 没有任何人接收资源白耗了。在移动端弱网环境下这个行为会导致连接池被占满后续请求全部排队。第二超时后的定时器没有清理。如果fetch在 2 秒时就成功返回那个 5 秒的定时器还挂着直到触发 reject 才发现没人接。虽然 JavaScript 的定时器不至于直接崩溃但确确实实是资源浪费。所以我的建议是如果只是临时调试Promise.race能用如果要做成基础设施选AbortController方案。真正的请求取消和假的提前返回之间生产环境必须选前者。4. 实战中踩过的坑错误区分、连接释放与流式请求写到这里基础逻辑基本讲完了。但把超时方案真正落地到项目里你还会遇到几个绕不开的细节问题。这些坑我基本都踩过一遍一个个说。4.1 如何准确区分超时和普通失败AbortController.abort()产生的错误err.name是AbortError这是规范规定的。但这里有个细节别的场景也可能抛AbortError。比如用户按了取消按钮或者浏览器因为某种策略主动终止请求再或者你页面上多个请求共用一个AbortController很多人会犯这个错一个 controller 控制多个请求一个 abort 全部取消。我一般建议在封装层就固定好超时和其他的边界超时发生后捕获AbortError重新抛出一个业务异常。class TimeoutError extends Error { constructor(message 请求超时) { super(message); this.name TimeoutError; } } async function fetchWithTimeout(url, options {}, timeout 5000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); try { return await fetch(url, { ...options, signal: controller.signal }); } catch (err) { if (err.name AbortError) { throw new TimeoutError(请求超时: ${url}); } throw err; } finally { clearTimeout(timer); } }这样调用方只需要判断err instanceof TimeoutError就能精确识别超时不会和其他AbortError混淆。4.2 超时之后请求真的停了吗很多人以为abort()之后浏览器就会立刻断开连接。实际不完全是这样。abort()会让fetch的 Promise 立刻 rejectJavaScript 层面的逻辑马上就能接管。但底层的 TCP 连接是否断开、何时断开取决于浏览器内核的实现。有些情况下连接会立即关闭比如还没有收到响应头的时候有些情况下浏览器会等正在传输的数据块读完再关闭比如已经收到了部分响应体。这带来的一个实际影响是如果你的封装里在超时后没有对AbortError进行特殊处理可能在catch后还要再等一小段时间才能完全清理资源。不过这个时间通常很短不会造成用户可感知的卡顿。真正需要注意的是后面这个——响应体的读取。4.3 大文件下载 / 流式响应的超时处理fetch超时有一个经典误伤场景下载大文件。假设你设置了 10 秒超时文件下载需要 30 秒用上面所有超时后 abort的方案10 秒到点整个下载就失败了。但你可能本意只是如果 10 秒内拿不到响应头就失败而不是整个下载必须在 10 秒内完成。区分这两种需求很重要连接超时从发起到收到响应头的时间超过阈值就失败。总时长超时整个请求含下载/上传必须在阈值内完成。fetch的 API 设计里超时控制作用于整个请求。要实现连接超时 下载不限时需要结合响应流判断。大致思路是用AbortSignal.timeout()只包裹到拿到响应头这一步之后正常读取 body。async function fetchWithConnectTimeout(url, options {}, timeout 5000) { // 只对获取响应头阶段做超时 const response await fetch(url, { ...options, signal: AbortSignal.timeout(timeout), }); // 到这里响应头已到达超时信号不再影响 body 读取 return response; }实测下来这种方式对下载类接口特别友好。如果你做的是文件上传/下载工具建议用这种模式。4.4 那些热搜场景git、pip、oauth 报错背后的问题我在开头提到了一些热搜词像git pull 卡住、pip install 报 could not fetch index base url、failed to fetch oauth token。这些场景看着五花八门但根子上是一回事客户端发起了请求然后在没有任何超时兜底的情况下干等。比如 git 拉取远程仓库时如果 SSH 或 HTTPS 通道被网络策略阻断客户端会一直卡在连接阶段直到系统底层的 TCP 超时通常要几十秒甚至更久才报错。pip也一样could not fetch index base url通常意味着访问 PyPI 的请求被防火墙挡住但客户端还是等满了重试超时才放弃。这些场景的解决方案本质上和我上面讲的思路一致在客户端给每个外部请求加上合理的超时阈值。git可以配置http.lowSpeedLimit和http.lowSpeedTimepip可以配--timeout前端就是本文讲的AbortController套路。工具不同道理相同——网络请求永远不应该无限等待。5. 生产级封装示例从真实项目里提炼的完整版最后给一个我在实际项目中用到的、综合了以上所有考量的封装。它支持超时、手动取消、超时错误分类、JSON 自动解析以及4xx/5xx状态码错误抛出。你可以直接抄走再按自己的项目结构调整。5.1 完整代码// request.js export class TimeoutError extends Error { constructor(message 请求超时) { super(message); this.name TimeoutError; } } export class HttpError extends Error { constructor(status, message) { super(message); this.name HttpError; this.status status; } } export async function request(url, options {}, timeout 8000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); // 合并外部传入的 signal手动取消与内部超时 const signal options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal; try { const response await fetch(url, { ...options, signal }); if (!response.ok) { throw new HttpError( response.status, 请求失败: ${response.status} ${response.statusText} ); } const contentType response.headers.get(content-type) || ; if (contentType.includes(application/json)) { return await response.json(); } return await response.text(); } catch (err) { if (err.name AbortError) { throw new TimeoutError(请求超时 (${timeout}ms): ${url}); } throw err; } finally { clearTimeout(timer); } }5.2 使用示例import { request, TimeoutError, HttpError } from ./request; // 基础使用 const user await request(/api/user); console.log(user.name); // 自定义超时和请求方法 const updated await request(/api/user, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ name: 张三 }), }, 10000); // 手动取消 超时 const controller new AbortController(); const promise request(/api/long-task, { signal: controller.signal }, 30000); // 用户点击取消按钮时 document.getElementById(cancelBtn).onclick () controller.abort(); try { await promise; } catch (err) { if (err instanceof TimeoutError) { showToast(请求超时请稍后重试); } else if (err instanceof HttpError) { showToast(服务器返回错误: ${err.status}); } else { showToast(网络异常请检查连接); } }5.3 几个值得注意的取值细节超时时间怎么定这个没有统一标准但可以参考几个经验值普通接口3~8 秒。用户点击按钮后的等待耐心大约在 5 秒左右。上传/下载接口总超时不要设太短建议 30 秒以上更合理的是只做连接超时见 4.3。批量操作/报表生成这类接口通常要轮询任务状态单独设置 10~30 秒超时。另外这个封装里我用AbortSignal.any如果你的项目还需要兼容 Safari 16 以下的版本那需要换成 3.2 节里内部timeoutController 外部 signal 手动合并的写法不能直接用这个any。兼容性列表可以在构建时用browserslist核对。6. 最后补充两个关于 fetch 超时的冷门细节分享两个容易被忽略但实际影响很大的小细节。第一个是AbortSignal.timeout()的 DOMException 类型。它触发 abort 后fetchreject 的原因依然是AbortError但底层原因可以从signal.reason拿到。在标准库的实现里AbortSignal.timeout()的reason是一个DOMException名字叫TimeoutError。这意味着你可以在catch里做更精确的判断catch (err) { if (err.name AbortError err.signal?.reason?.name TimeoutError) { // 这是真正的超时 } }不过实际项目中我很少依赖这个细节因为不同浏览器的实现有差异还是统一封装成TimeoutError更稳。第二个是HTTP/2 连接复用下的“假超时”。现代浏览器默认使用 HTTP/2多个请求共享同一个 TCP 连接。如果一个请求被abort()了浏览器会发送RST_STREAM帧终止该流其他使用同一连接的请求不会受到影响。这一点你不需要额外处理但我遇到过真的有人在abort()之后担心影响其他请求非要手动断开连接——完全没必要浏览器早就处理好了。我在实际项目里把这些方案落地后最直观的感受是页面上的请求失败从转圈半天然后报错变成了快速失败并给出明确提示用户体验提升了一个档次。而且排查问题时有了明确的TimeoutError字样一眼就能区分是后端慢还是前端配置问题省了很多沟通成本。
返回列表