写一个 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 的支持仍然是实验性的。你需要:
- 用
--experimental-vm-modules启动 Node.js - 测试文件里用
await import()而不是require() - 模块路径必须带
.js后缀(TypeScript 编译后的实际文件) - 全局
fetchmock 的方式和 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。你不会想在生产环境发现"哦,原来当 body 是 null 且 method 是 POST 时,序列化逻辑会崩溃"。
最终的架构
回过头看,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
不多不少。运行时零依赖 ------ 不引入任何第三方包,所有功能都基于原生 fetch、AbortController、Promise 实现。打包体积约 30KB,没有 node_modules 的包袱。加上 100% 测试覆盖率。
写在最后
看起来简单的封装,往往藏着最深的设计决策。
"只是包一下 fetch",但超时和取消怎么共存?钩子的返回值该怎么设计?重试插件怎么和缓存插件配合?队列怎么做到透明?
每一个问题都没有现成的答案。你必须自己权衡,自己取舍。
如果你也在寻找一个轻量的、类型安全的、不带包袱的 fetch 封装,试试 afetch。如果你觉得它哪里做得不对,欢迎提 issue。
或者你也可以开发更有意思的插件。
毕竟,世界上确实不需要又一个 HTTP 客户端 ------ 但如果它能让你少踩一个坑,那就值得了。