掘金热榜上那篇《为什么我在团队里禁止使用 axios?》,评论区吵成两派:一派说 fetch 早就够用了,axios 是历史包袱;另一派说拦截器和封装生态没法替代。我看完两边都没吭声,因为我手里有个项目,光 axios 调用就散落在六十多个文件里,换不换不是我拍脑袋的事。
与其站队,不如把那篇文章列的理由逐条跑一遍。今天中午我把本地测试服务起起来,装了 axios 1.20.0------去 npm registry 查过,这就是当前 latest------加上 Node 自带的 fetch,在我这台 v22.23.2 的机器上把每条论断都过了一遍。跑完发现:文章说对的部分确实对,但它没讲的地方,才是我多跑了几轮实验才补齐的。
超时和取消,fetch 真的接住了
这两条没什么悬念,实测结果也干净。同一个 3 秒才响应的接口,axios 限 500ms 超时,实际 517ms 断开,报 ECONNABORTED;fetch 用 AbortSignal.timeout(500),504ms 断开,报 TimeoutError。时间上没有实质差别,唯一要注意的是错误类型不一样------axios 是 AxiosError 带 code,fetch 抛的是 TimeoutError,你原来 catch 里那些 error.code === 'ECONNABORTED' 的判断,迁移后一个都匹配不上,得重写。
取消这条更有意思。axios 官方文档在 Cancellation 页面写着,CancelToken "已废弃,将在下一个大版本移除",新写法是 AbortController。我在 1.20.0 上把两种写法都跑了,CancelToken 还能用,取消后 axios.isCancel 照样返回 true,但错误对象已经变成 CanceledError 了。也就是说旧代码现在不报错,等 axios 出大版本那天会一起塌。 所以"fetch 已覆盖超时和取消"这条,我认。这不是 axios 的功能变弱了,是平台把这些能力内置了,库再包一层确实只剩习惯的价值。
拦截器没被替代,但它有个顺序坑
fetch 没有拦截器,这是事实,迁移后所有统一加 token、统一打日志的逻辑都得自己包一层。不过我实测的时候发现一个以前只觉得"难调试"、这次才看清根源的东西:axios 请求拦截器的执行顺序,跟注册顺序是反的。 我按 A、B、C 的顺序注册了三个请求拦截器,再按 1、2 的顺序注册两个响应拦截器,跑一个请求,输出是这样的:
scss
[req] C(最后注册) →
[req] B(后注册) →
[req] A(先注册) →
[res] ← 1(先注册)
[res] ← 2(后注册)
响应拦截器是正常队列,先注册先执行;请求拦截器是栈,后注册先执行。这个设计文档里提过一嘴,但极度不显眼。我在旧项目里见过有人注册两个请求拦截器------一个注入 request-id,一个依赖 request-id 打日志------注册顺序一颠倒,日志里全是 undefined,查了半天不知道为什么。现在算是知道病根了:写的时候以为拦截器是"从上往下排队",实际是"后插队的先走"。 这段我贴出来不是为了说 axios 设计有问题,而是想说那篇热榜文章讲"拦截器嵌套难调试",方向对但没到根上。难调试的根源不在嵌套,是顺序给人的直觉错觉。
迁移最大的坑,不在任何功能清单里
跑完功能对照,我一度觉得迁移成本可控,心想下午就能把验证报告写完。结果我往测试服务里加了一个 404 接口,事情变了。 axios 遇到 404,默认直接 reject,error.code 是 ERR_BAD_REQUEST,走 catch。fetch 遇到 404,请求正常完成 ,进 then,res.ok 是 false,状态码 404 要你自己看。
单独看这条好像没什么,但你想象一下迁移现场:项目里几百个调用点,axios 时代 catch 块里写的错误提示逻辑------"网络异常请重试"那种 toast------是按"只有网络层才会炸"的假设写的。迁到 fetch 之后,服务端返回的每一个 404、500 都会静默穿过 then,拿着 undefined 的数据往下渲染,直到页面某个角落白屏,你才知道出事了。
我后来琢磨了一下,这其实是两个库对"错误"的定义不同:axios 把 HTTP 状态码也算进错误,fetch 只管传输层。谁对谁错不好说,但迁移时如果你只对着功能清单勾选------超时有、取消有、JSON 解析有------这项根本不会出现在清单上,因为它不是"缺功能",是语义反过来。
最费时间的是 401 刷新,两边都有坑
那篇文章给了个 30 行的 fetch 封装,我照着思路写了个带 token 刷新的版本,41 行,能跑。测到这里我还挺满意,觉得这就是迁移后的最终形态了。 然后我加了并发场景:5 个请求同时打一个需要登录的接口,token 已过期。预期是第一个 401 触发刷新,其他 4 个排队等新 token,总共只刷一次。我用一个全局的 refreshing 变量做单飞------第一次 401 时发起刷新请求存进这个变量,其他请求看到变量非空就等它,刷新完成清空:
ini
let res = await doFetch();
if (res.status === 401) {
if (!refreshing) {
refreshing = refreshToken().finally(() => { refreshing = null; });
}
await refreshing; // 所有 401 都等同一个刷新
res = await doFetch(); // 用新 token 重放
}
fetch 版跑出来很漂亮:5 个请求全部成功,/refresh 只被调用了 1 次。 接着我把同样的逻辑搬进 axios 拦截器------毕竟真实迁移是两边都要能跑的。诡异的事情来了:5 个请求也全部成功,但 /refresh 被调用了 3 次。同一台机器,同一个测试服务,同一段刷新逻辑,就换了个客户端,刷新次数翻了三倍。 我的第一反应是 axios 的问题,差点就写进结论里了。好在多跑了一步:我把测试服务改成让 401 分批返回------3 个立即返回,2 个延迟 150 毫秒才返回------再看 fetch 版,刷新次数变成 2 次。
这下清楚了,跟库没关系。这段刷新代码有个竞态窗口:刷新完成的那一刻 refreshing 被清空,如果这之后才有 401 慢吞吞地回来,它看到 refreshing 是空的,无法知道"刚才已经有人刷过了",于是老老实实再刷一次。axios 的拦截器链多几层异步处理,5 个错误响应进错误分支的时间点更容易散开,所以撞上的概率高;fetch 版 5 个 401 恰好挤在同一个事件循环里进来,躲过去了。本质是运气好,不是逻辑对。 修复倒是简单,在发请求前记一下当时的 token,401 回来先比一比:
ini
const tokenAtRequest = accessToken; // 发请求时用的 token
let res = await doFetch();
if (res.status === 401) {
if (accessToken !== tokenAtRequest) {
// token 已经被并发的其他请求刷过了,直接重放,不再刷
} else {
if (!refreshing) {
refreshing = refreshToken().finally(() => { refreshing = null; });
}
await refreshing;
}
res = await doFetch();
}
加这一行判断之后再跑,分批 401、同 tick 401,/refresh 都是 1 次。回头翻了几个开源仓库,这段逻辑跟流传很广的 axios 刷新模板长得几乎一样------也就是说那些模板里大概率埋着同一颗雷,只是大部分项目的并发量没高到让它显形,或者刷新接口幂等,刷三次也没人发现。
我的结论
不端水地说:**新项目用 fetch 封装,值;存量项目专门为了"禁用 axios"做迁移,不值。**理由就是上面那些------功能上 fetch 确实接得住,但 404 语义翻转和错误类型重写意味着存量项目的迁移是全量回归测试级别的工程,省下的包体积(axios 加依赖约莫百来 KB)撑不起这个成本。
不过这轮实测收获更大的,反而是热榜那篇文章没讲的三个点:请求拦截器的栈式顺序、404 的语义翻转、401 并发刷新的竞态。前两个是 axios 侧的坑,第三个迁移不迁移都得防。哦对,还有 Edge Runtime 那条我没实测------本地起不了真的边缘环境, axios 官方有 fetch adapter 这点我查了文档确认存在,但不装懂,就不展开说了。 这一轮折腾全部在本地,零 API 成本。哪个同学要复现,脚本照着贴就能跑。