写一个 Fetch 封装库,没那么简单

写一个 Fetch 封装库,没那么简单

世界上不需要又一个 HTTP 客户端 ------ 除非它真的解决了你的问题。

为什么要做这件事

市面上已经有 axios、ky、ofetch、wretch...... 为什么还要写一个?

最开始的原因很简单:我不想要那么多东西

axios 带着它自己的 XMLHttpRequest 适配层,Node.js 里还要装额外的 polyfill。ky 依赖 ky/distribution 的打包路径,偶尔在某些 bundler 里抽风。ofetch 是 Nuxt 生态的一部分,单独用总觉得怪怪的。

我只是想用原生 fetch,但又想要一些基本能力:类型安全、超时、重试、取消请求。而且我不想因此引入任何第三方运行时依赖------每次 npm install 多拉一个包,就多一个供应链风险,多一份打包体积的焦虑。

"这不就是一个薄薄的封装层吗?"------我是这么想的。

于是 afetch 诞生了。

第一个坑:超时与取消的冲突

最开始的设计很简单,超时用 setTimeout,取消用 AbortController。代码大概是这样的:

typescript 复制代码
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);

看起来完美,但是我发现:用户可能自己也传了一个 signal

如果用户传了 signal,就需要同时监听两个信号------用户的 signal 和超时 signal。任何一个触发都应该取消请求。但如果用户取消了请求,我不应该误判为超时。如果超时触发了,我不应该干扰用户的 signal 状态。

最终我写了一个 createTimeoutController 函数,创建一个组合 AbortController,同时监听两个信号,并在 finally 块里清理定时器(signal 的监听器用 { once: true } 自动移除)。光这一个函数,测试就写了近十个用例。

第二个坑:插件系统比想象中复杂得多

最初的想法是"加几个钩子就行了"。三个生命周期:请求前、响应后、出错时。

但问题很快就来了:

重试插件的 onError 要返回响应 。如果重试成功了,它需要把成功的响应"注入"回调用链,让调用者拿到的是重试后的结果,而不是错误。这意味着 onError 钩子不能只是"通知",它必须能"接管"流程。

缓存插件的 beforeRequest 要短路请求 。如果缓存命中,整个请求都不应该发出去。这意味着 beforeRequest 钩子不能只是"修改配置",它必须能直接返回一个响应,跳过后续所有步骤。

队列插件要延迟请求的执行 。这看起来和缓存插件的"短路"完全相反------缓存是"提前返回结果",队列是"先等着,轮到你了再走"。但有意思的是,它们可以用同一个机制实现:beforeRequest 返回一个 Promise<void>,hook runner 会 await 它,等 promise resolve 后继续执行。返回 void(falsy)表示"继续",返回 AResponse(truthy)表示"短路"。

这个设计让我花了很长时间。我不想搞出一个复杂的中间件管道(像 Koa 那样),但也不想让钩子能力太弱导致插件做不了事情。最终的方案是:钩子的返回值决定了流程走向

yaml 复制代码
beforeRequest → void: 继续请求 | AResponse: 短路(缓存命中)
afterResponse → void: 使用原响应 | AResponse: 替换响应
onError       → void: 传播错误    | AResponse: 恢复(重试成功)

简单,但足够强大。

第三个坑:重试的 retryOn 不只是状态码匹配

重试插件最初只支持"在这些状态码上重试":

typescript 复制代码
retryOn: [500, 502, 503]

但真实的场景远比这复杂。401 错误需要先刷新 token 再重试,而不是简单地再请求一次。这意味着 retryOn 的每个条目不能只是一个数字,还得支持一个带 hook 和 call 的配置对象:

typescript 复制代码
retryOn: [
    500,
    {
        hook: async (error) => error.status === 401,
        call: async () => { await refreshToken(); },
    },
]

hook 决定是否匹配,call 在重试前执行副作用。更麻烦的是,delay 也有两种情况:一种是全局默认延迟,一种是 retryOn 里每个条目自己的 retryDelay。优先级怎么定?最终的规则是:retryOn 条目的 retryDelay > 请求级 delay > 插件级 delay > 默认 1000ms

第四个坑:TypeScript ESM + Jest = 地狱

这是整个项目中最让我痛苦的部分。

项目使用 ESM("type": "module"),TypeScript 编译成 ESNext 模块,然后用 Jest 测试。听起来很标准,对吧?

现实是:Jest 对 ESM 的支持仍然是实验性的。你需要:

  1. --experimental-vm-modules 启动 Node.js
  2. 测试文件里用 await import() 而不是 require()
  3. 模块路径必须带 .js 后缀(TypeScript 编译后的实际文件)
  4. 全局 fetch mock 的方式和 CommonJS 完全不同
typescript 复制代码
// ❌ 这样不行
const { afetch } = require('../src/index');

// ✅ 必须这样
const { afetch } = await import('../src/index.js');

最抓狂的是 mock fetch。在 CJS 里你可以 jest.mock('node-fetch'),但在 ESM 里模块是不可变的。最终的方案是依赖注入 ------在配置里传入 fetchAdapter,测试时传入 mock 函数。这反而让代码更干净了,因为核心实现不依赖任何全局状态。

第五个坑:队列插件的 API 设计

第一版队列插件的用法是这样的:

typescript 复制代码
const queue = createQueuePlugin({ maxConcurrent: 3 });
api.use(queue);

const results = await Promise.all(
    urls.map(url => queue.run(() => api.get(url)))
);

queue.run(() => api.get(url)) ------需要手动包装每个请求。这让用户困惑:我装了插件,为什么还要手动包一层?

问题在于,队列插件需要"延迟"请求的执行,但 fetch 是立即调用的。我需要在 fetch 被调用之前拦截它。

最终的方案是利用 beforeRequest 钩子的 Promise<void> 返回值。当并发槽满时,beforeRequest 返回一个 promise,hook runner 会 await 它,直到有槽位释放。这样用户只需要:

typescript 复制代码
const queue = createQueuePlugin({ maxConcurrent: 3 });
api.use(queue);

// 直接用,队列自动生效
const results = await Promise.all(urls.map(url => api.get(url)));

透明,无侵入。

第六个坑:100% 覆盖率意味着什么

项目强制要求 100% 测试覆盖率------语句、分支、函数、行,全部 100%。

至于为什么 100% 是不是太过了?99% 行不行?

如果 99% 可以,那 98% 呢? 98% 和 99% 差多少? 1% 而已。那 95% 呢? 也就差 4%。90% 其实也挺高的了吧?最终你会发现,覆盖率的门槛一旦开始降低,就没有一个自然的停下来的点。每个数字都能找到"差不多就行"的理由。

所以我选择了一个有明确边界的数字:100%。不需要讨论,不需要判断"哪些行可以不覆盖",不需要每次提交 PR 时考虑 "这个分支要不要写测试"。100% 就是 100%,没有灰色地带。

99% 到 100% 的提升远大于 90% 到 99%。

而且对于一个 HTTP 客户端库来说,每一个未覆盖的分支都是一个潜在的边界 bug。你不会想在生产环境发现"哦,原来当 bodynullmethodPOST 时,序列化逻辑会崩溃"。

最终的架构

回过头看,afetch 的核心其实就几个文件:

csharp 复制代码
src/
├── afetch.ts      # 核心:请求编排、钩子执行、错误处理
├── plugin.ts      # 插件系统:HookRunner
├── error.ts       # AFetchError 类
├── events.ts      # 事件发射器(给 event-bus 插件用)
├── utils.ts       # 工具函数
└── plugins/       # 内置插件
    ├── retry.ts
    ├── event-bus.ts
    ├── queue.ts
    └── cache.ts

不多不少。运行时零依赖 ------ 不引入任何第三方包,所有功能都基于原生 fetchAbortControllerPromise 实现。打包体积约 30KB,没有 node_modules 的包袱。加上 100% 测试覆盖率。

写在最后

看起来简单的封装,往往藏着最深的设计决策

"只是包一下 fetch",但超时和取消怎么共存?钩子的返回值该怎么设计?重试插件怎么和缓存插件配合?队列怎么做到透明?

每一个问题都没有现成的答案。你必须自己权衡,自己取舍。

如果你也在寻找一个轻量的、类型安全的、不带包袱的 fetch 封装,试试 afetch。如果你觉得它哪里做得不对,欢迎提 issue。

或者你也可以开发更有意思的插件。

毕竟,世界上确实不需要又一个 HTTP 客户端 ------ 但如果它能让你少踩一个坑,那就值得了。

相关推荐
#麻辣小龙虾#1 小时前
vue3基于leaflet.js实现地图编辑功能
javascript·ecmascript·leaflet.js
渣波2 小时前
手把手教你写出优雅的 API 接口调用
前端·javascript
spmcor2 小时前
JavaScript 日期限制的“三个月陷阱”:从边界溢出到稳健实现
javascript
半个落月2 小时前
Ajax 异步编程全攻略:从 XHR 到 async/await
javascript
橘子星2 小时前
深入理解 AJAX 中的 JSON 序列化与 JS 异步处理
前端·javascript·后端
夏幻灵2 小时前
深度解析 JavaScript 异步编程:从回调地狱到 Promise 的重构
开发语言·javascript·重构
Cobyte2 小时前
20.Vue Vapor 的应用初始化
前端·javascript·vue.js
HYCS3 小时前
用pixi.js实现fabric.js(七):框选、ActiveObject和控制点
前端·javascript·canvas