在 JavaScript 中发起 HTTP 请求有两种方式,关于它们的争论从未停止,而争论的焦点通常集中在最不重要的方面。
包大小、语法优雅性、依赖项是否合理——这些都是个人偏好,理性的人对此会有不同的看法。 **其中有一点差异并非个人偏好,且会在生产环境中引发真实的错误:**当服务器返回错误状态码时,fetch 不会拒绝其 Promise。404 状态码会被解析,500 状态码也会被解析。你的 .catch() 永远不会被执行,而代码却会继续运行,仿佛一切正常。
这是文档中明确记载的行为,而非某种怪癖,也是在理解其他内容之前必须首先掌握的关键点。
我们是 Geonode,主要销售代理服务,因此在此补充相关说明:这两者在 Node 中的代理支持 方面存在真实且未充分记录的差异,这经常会让用户措手不及。 Node中的原生fetch无法识别标准的代理环境变量,这会让几乎所有认为它与curl行为一致的人感到惊讶。这部分内容有专门的章节,如果你不通过代理路由请求,可以完全跳过这一部分——大多数人应该如此。
这里有一条简短的说明,因为这会影响那些点击旧链接的读者:Axios 的文档已迁移。 axios-http.com 现在会重定向到 axios.rest。指向旧域名的书签和 Stack Overflow 答案仍可通过重定向访问,但规范地址已发生变更。
下文中关于行为的所有内容均来自 MDN 和 Axios 自身的文档,而非任何人的记忆。
导致真正错误的差异
请从这里开始,因为这是唯一一个不属于个人喜好范畴的差异。
MDN 如何说明
直接引用文档内容:
“fetch()
承诺仅在请求失败时才会被拒绝,例如由于请求 URL 格式错误或网络错误。 如果服务器返回表示错误的 HTTP 状态码(如 404
、504
等),fetch()
承诺 不会 拒绝。相反,then()
处理程序必须检查 Response.ok
和/或 Response.status
属性。”
不会 这一强调是 MDN 原文中的。
这在实际中意味着什么
// Looks correct. Is not.
try {
const res = await fetch('/api/user/999');
const user = await res.json();
showUser(user); // runs on a 404
} catch (err) {
showError(err); // never runs on a 404
}
服务器返回了包含错误正文的 404 状态码。fetch
成功解析。res.json()
解析了错误对象。showUser
接收到了非用户对象,而该错误随后在其他地方以“未定义属性”的令人困惑的错误形式显现出来。
正确的版本:
try {
const res = await fetch('/api/user/999');
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const user = await res.json();
showUser(user);
} catch (err) {
showError(err);
}
增加了三行代码,而这些代码在您编写的每一个请求中都是必需的。
Axios 的行为方式
Axios 默认会因 2xx 范围外的状态码而拒绝请求。等效的代码则无需进行状态码检查:
try {
const { data } = await axios.get('/api/user/999');
showUser(data);
} catch (err) {
showError(err); // runs on a 404
}
哪种行为是正确的
两种做法都有其合理性,分歧在于哲学层面。
fetch
持这样的观点:传输是成功的——服务器已被连接,它已响应,响应也完整地到达。 404 是一个对问题的有效回答,而非提问失败。若将其拒绝,则会将传输故障与应用程序语义混为一谈。顺便提一下,这恰恰也是 curl 的立场,在 curl 中 404 同样会产生零退出代码。
Axios则认为,大多数调用者都将4xx或5xx视为失败,因此它也应表现得像失败一样。
实际情况是,fetch
的模型虽然更准确,却也更容易出错,因为它要求每次调用都必须严格遵守规则,而一旦疏忽,却没有任何警告机制。
什么是 Fetch 以及它带来的成本
这项标准既有其优势,也存在不足。
优势
它是内置的。 无需依赖项、无需安装、无需打包成本,也不需要审核供应链。在浏览器和现代 Node 中,它本就存在。
**它是标准。**由标准规范定义,而非由某个项目维护——后者可能会改变方向、被弃用,或按自身时间表引入破坏性变更。
**它是基础。**许多更高层次的库都是围绕它构建的封装层,因此理解 fetch 就意味着理解这些库的功能。
它能很好地处理流式数据。 Response.body 是一个可读流,这使得对大型响应进行渐进式处理变得自然而然。
它无法做到的事情
这些是你需要自行填补的空白,而它们的数量正是选择 Axios 的真正理由。
**不支持自动解析 JSON。**你需要调用 .json(),这相当于又一个 await 操作,如果响应不是 JSON(例如来自配置错误的服务器返回的 HTML 错误页面),这里就可能成为另一个失败点。
**不支持状态码拒绝。**上文已提及。
默认不支持超时。 fetch 可能会无限期挂起。AbortSignal.timeout() 在现代环境中提供了超时功能,但需手动启用且容易被遗忘——而这恰恰在挂起问题最严重的场景中最为关键。
不支持拦截器。 无法集中挂载认证令牌、相关性 ID 或日志记录。 要么每次调用都重复这些操作,要么编写一个封装类,而编写封装类往往会导致人们无意中构建出比 Axios 更糟糕的版本。
不支持上传进度。 下载进度可通过响应流实现;上传进度则无法直接实现。
不支持请求正文的自动序列化。 每次调用 JSON.stringify 时,都必须自行设置内容类型。
Node 中不支持代理配置。 具体内容见下文单独一节。
客观总结
fetch 是一个设计精良的低级原语。其功能缺失是刻意为之——标准应当保持精简且不带立场。
问题不在于它是否优秀,而在于你是否愿意自己实现缺失的那一层,以及你实现的版本是否会比由拥有庞大用户群、且能发现各种边界情况的项目所维护的版本更好。
Axios 能为您带来什么
从其官方文档来看,功能列表本身就是最好的论据。
基于 Promise 的 HTTP 客户端,在不同环境中提供一致的接口,并提供独立的浏览器版和 Node 版软件包。
用于请求和响应的拦截器,这是最宝贵且最难干净利落地复现的功能。只需一次添加认证头部、一次处理 401 刷新、一次添加日志——应用程序中的每个请求都会继承这些设置。
双向自动 JSON 处理。 请求正文会被序列化并设置内容类型;响应会被解析为 response.data。
错误处理:对非 2xx 状态码的请求予以拒绝。
超时配置,文档中描述其作用是防止请求无限期挂起。仅需一个配置值,而非每次调用都需单独设置中止控制器。
正在处理中的请求取消。
进度跟踪,支持上传和下载,而 fetch 并未直接提供此功能。
内置 XSRF 保护。
文件提交和多部分表单数据 由系统自动处理。
速率限制和请求限流。
带默认值的实例,因此只需创建一次包含基础 URL、请求头和超时设置的配置客户端,即可将其导入到任何地方。
成本
依赖项。 需要安装、保持更新并进行审计。在注重安全的环境中,这绝非理论上的成本,而是切实存在的成本。
**包体积。**对于小型前端项目而言意义重大,但对于大型应用程序或任何服务器端应用则微不足道。
需要学习的又一层抽象,且其行为有时会与底层平台存在差异,甚至会出乎意料。
客观的评价
当需要这些功能时,Axios 大致相当于大多数人最终在 fetch 基础上构建的解决方案——只不过它已经编写完成、调试完毕,并且已经处理了你尚未想到的各种情况。
如果你完全不需要这些功能,那么它就是一种毫无意义的依赖。
并列对比
| fetch | Axios |
|---|
| 安装 | 内置 | npm install |
| 404/500 错误时拒绝请求 | 否 | 是 |
| JSON 解析 | 手动 .json() | 自动 |
| 请求正文序列化 | 手动 | 自动 |
| 超时 | AbortSignal.timeout() | 配置选项 |
| 拦截器 | 无 | 是 |
| 上传进度 | 操作不直观 | 是 |
| 下载进度 | 通过响应流 | 是 |
| 取消 | AbortController | 内置 |
| XSRF 保护 | 手动 | 内置 |
| 带默认值的实例 | 无 | 是 |
| Node 中的代理配置 | 无 | 是 |
| 流式传输 | 出色 | 较为有限 |
| 捆绑开销 | 零 | 较小但不为零 |
同一请求,两种实现方式
// fetch, written correctly
const res = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: 'Alice' }),
signal: AbortSignal.timeout(5000)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// Axios
const { data } = await axios.post(
'https://api.example.com/users',
{ name: 'Alice' },
{ headers: { Authorization: `Bearer ${token}` }, timeout: 5000 }
);
两种实现方式均正确。fetch版本有11行代码,而Axios版本只有5行,两者的差异完全在于那些必须在每次调用时都记住的内容,而非只需配置一次的内容。
决定胜负的两行代码
404/500 状态码回绝和Node 中的代理配置是唯一两行代码,其中一种工具无法直接实现另一种工具的功能。其余部分都是模板代码,而模板代码虽然会带来成本,但绝非无法实现。
模板代码的开销
真正的比较并非在于一次 fetch
调用与一次 axios
调用之间,而是在于分别使用这两种方式的代码库之间。
大家最终都会写出的代码
当你在第三或第四个地方重复进行状态检查、超时处理和 JSON 解析时,你会写出这样的代码:
async function request(url, options = {}) {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers
},
signal: options.signal ?? AbortSignal.timeout(options.timeout ?? 10000)
});
if (!res.ok) {
const body = await res.text();
throw new HttpError(res.status, body);
}
return res.status === 204 ? null : res.json();
}
这是一个合理且相当不错的封装。它无疑也是一个微型 Axios。
直到有人注意到之前,这个封装无法处理的问题
带退避策略的重试。当多个调用同时失败时,在 401 状态码下刷新令牌,同时避免请求风暴。返回 HTML 而非 JSON 的请求。没有正文的 204 响应。通过嵌套调用传播的取消操作。上传进度。JSON 以外的内容类型。用于追踪的关联 ID。
每一项都是一个小小的补充。 但综合起来,它们就构成了一座库,而你编写的版本所经过的测试,远不及成千上万用户正在使用的那个版本。
何时适合自己编写
当你对它的需求非常少时。 仅需向一个 API 发送寥寥几个 GET 请求。上面的封装器只有三十行代码,而且你完全掌控它。
当包体积确实至关重要时。 例如在性能关键型页面中,每一千字节都至关重要。
当依赖项成本过高时。 例如在每个包都需要经过审核的环境中。
当你希望深入理解该平台时。 这是一个正当的理由,且这种理解具有迁移价值。
何时不适合
当你已经进行到封装器的第三次迭代时,当代码库的不同部分使用了不同的封装器时,或者当你需要添加重试逻辑时。到了那个时候,你实际上是在维护一个作为副项目的库,而你原本试图避免的依赖项,其成本反而比你自己构建的要低。
打包大小与节点问题
影响答案的两个上下文因素。
在浏览器中
Axios 会增加你的打包文件大小。这是否重要,完全取决于你正在构建什么。
如果是着陆页或小部件,其加载时间就是产品本身——请使用 fetch。 每一千字节都至关重要,且请求模式通常足够简单,以至于模板代码微不足道。
大型应用程序——已包含框架和组件库——边际成本微乎其微,而拦截器的支持价值远超这些字节。
坦率地说,当人们想为一种审美偏好寻找技术理由时,包大小往往就是他们拿来作为论据的。虽然包大小确实存在,但它真正起决定性作用的情况,远比人们援引它的频率要少得多。
在 Node 中
计算方式完全不同。在服务器端,打包大小无关紧要,因此反对 Axios 的主要论点便不攻自破。
现代 Node 环境中已提供原生 fetch,且运行良好。但服务器端代码往往恰恰需要 fetch 所缺失的功能:对所有操作设置超时、带退避机制的重试、集中式身份验证处理、出站调用的结构化日志记录,以及——如下一节所述——代理配置。
因此,在服务器端,天平比在浏览器端更倾向于 Axios,这与通常的论点恰恰相反。
无人提及的折中方案
你可以两者兼用。简单调用时使用 fetch,需要复杂机制时使用 Axios。没有任何规定禁止这样做,而且关于一致性的论点其实没有听起来那么有说服力。
真正造成问题的是在一个代码库中同时存在三个不同的自制封装层,它们处理错误的方式各不相同。这比始终如一地使用其中任何一个库都要糟糕,而令人惊讶的是,许多项目最终都陷入了这种境地。
两者的代理设置
这是我们的领域,也是让许多开发者度过了一个真正令人困惑的下午的根源。
简要说明
Node中的原生fetch不会读取标准的代理环境变量。 设置HTTP_PROXY和HTTPS_PROXY没有任何作用,这与curl不同,与大多数HTTP库不同,也与几乎所有人的预期不符。
Node 的全局 fetch 基于 undici 构建,通过代理进行路由需要显式提供一个分发器:
import { ProxyAgent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new ProxyAgent('http://user:pass@proxy.example.com:8080'));
// now fetch goes through the proxy
const res = await fetch('https://example.com');
或者通过在请求中传递 dispatcher 选项来实现。
Node 中的 Axios
Axios 提供了一个 proxy 配置选项:
const res = await axios.get('https://example.com', {
proxy: {
protocol: 'http',
host: 'proxy.example.com',
port: 8080,
auth: { username: 'user', password: 'pass' }
}
});
对于 SOCKS 代理,或者为了更精细的控制,通常的做法是将代理库作为 httpAgent 和 httpsAgent 参数传入。
在浏览器中,两者均无法实现
值得说明这一点,因为这能节省时间。 浏览器中的 JavaScript 无法设置代理。 浏览器会使用系统或扩展程序配置的代理,任何库都无法更改这一点。Axios 的 proxy 选项是 Node.js 的特性。
如果需要从基于浏览器的代码发起代理请求,该请求必须通过你控制的服务器。
调试线索
如果在 Node 中代理“无法正常工作”,请在检查其他任何内容之前,先确认是哪个客户端发起了请求。在 Axios 中正常工作但在 fetch 中失败的代码——或者反之——几乎总是由于这个原因,而且由于两者均不会报错,因此这一问题难以察觉。请求只是直接发送了出去。
可通过以下方式验证:使用已配置的客户端请求一个返回地址的端点,并确认返回的地址是代理服务器的地址。
与自身利益相悖的部分
大多数服务器端的 HTTP 调用根本不需要代理。 从被允许访问该 API 的服务器上,调用您已持有凭据的 API 时,无需任何额外操作——代理只会增加延迟、引入故障点并产生费用。代理的价值在于地理位置验证,以及在受按地址限流约束的大规模请求场景中发挥作用。除此之外,直接客户端才是更好的选择。
如何选择
这是一份决策清单,而非最终结论。
何时使用 fetch
当包大小确实至关重要时。 例如着陆页、小工具、嵌入式脚本。
当请求非常简单时。 仅需几个 GET 请求,错误处理极少,且无需共享身份验证。
无法添加依赖项,或者每个包都需要审核。
正在处理流。 fetch 的流式处理模型更优,这是一种真正的技术优势,而非个人偏好。
希望学习该平台。 相关知识具有通用性;而 Axios 特有的知识则不然。
何时使用 Axios
你需要拦截器。 集中式认证、令牌刷新、日志记录、关联 ID。这是最有力的单一理由,且 fetch 没有与之相当的解决方案。
你在服务器端开发。 打包大小不是问题,而那些缺失的功能恰恰是服务器端代码所需要的。
你需要上传进度,而 fetch 无法直接实现这一点。
你在大型代码库中发起多种多样的请求,且希望行为一致,同时无需维护封装类。
你需要代理配置,并且更倾向于使用有文档记录的选项,而非自行组装分发器。
无论您选择哪种方案
务必设置超时。 使用 AbortSignal.timeout() 或 Axios 的 timeout 选项。未设置超时的请求可能会无限期挂起,这是 JavaScript HTTP 代码中最常见的可靠性缺陷。
务必使用 fetch 检查状态。 每次调用都必须如此,无一例外。res.ok 虽然只有两个单词,但正是它们的缺失,才导致了本文开头提到的那个 bug。
集中管理。 要么使用一个封装器,要么使用一个经过配置的 Axios 实例。在一个代码库中同时存在三种不一致的做法,其后果比单独使用任一库都要糟糕,而这也是没有人会刻意选择的结果。
大家还问
Axios 和 fetch 之间的主要区别是什么?
错误处理。根据 MDN 的说明,fetch 承诺“如果服务器返回的 HTTP 状态码指示存在错误,则不会拒绝请求”——你必须自行检查 response.ok。而 Axios 会在遇到非 2xx 状态码时拒绝请求。其余功能均属于便利性增强:Axios 提供了拦截器、自动 JSON 解析、超时设置、进度跟踪以及代理配置。
既然 fetch 已内置,Axios 还有必要吗?
这取决于你的需求。对于简单的请求,不需要。但若需要拦截器、上传进度、实例级默认值或 Node 代理配置,Axios 仍提供 fetch 所不具备的功能,而自行实现这些功能意味着需要维护一个小型库。
为什么 fetch 在遇到 404 时不抛出异常?
因为传输成功了——服务器已被连接且已返回响应。fetch 将 404 视为有效的响应而非请求失败,仅在网络错误或 URL 格式错误时才会拒绝请求。请在每次调用时检查 response.ok。
Axios 和 fetch 哪个更快?
对于单次请求,两者之间的差异可以忽略不计;两者都受网络速度的限制。Axios 会增加少量处理开销,在浏览器中还会因加载库本身而产生少量下载开销。
如何使用 fetch 设置超时?
AbortSignal.timeout(5000) 在现代环境中,可通过signal选项传入超时值,或使用包含自定义计时器的AbortController。由于没有默认超时,未设置超时的请求可能会无限期挂起。
fetch在Node中能否与代理配合使用?
无法通过常规的环境变量实现。Node的全局fetch基于undici构建,不会读取HTTP_PROXY或HTTPS_PROXY。 你必须提供一个 ProxyAgent 调度器,既可以通过 setGlobalDispatcher 全局设置,也可以针对每个请求单独设置。Axios 则提供了一个 proxy 配置选项。
我可以在浏览器中使用 fetch 配合代理吗?
不可以。浏览器中的 JavaScript 无法配置代理——浏览器使用系统或扩展程序的设置。任何库的代理选项都是 Node 专有的功能。 来自浏览器的代理请求必须通过您控制的服务器。
是否应该在一个项目中同时使用 Axios 和 fetch?
这本身并不是问题。真正造成麻烦的是多个自制的封装层,它们的错误处理行为不一致。错误处理的一致性比请求由哪个库生成更为重要。
总结
其中有一点是实质性的差异,其余的则属于个人偏好。
fetch 不会因 HTTP 错误而拒绝请求。 MDN 明确指出:404 或 504 错误仍可解析,你必须自行检查 response.ok 或 response.status。 如果在某次调用中忽略了这一点,失败就会在完全不同的地方显现出来,表现为一条关于从未有效的数据的令人困惑的错误。Axios 默认会在非 2xx 状态码时拒绝请求。这两种立场都有其合理性;但只有一种需要在每次调用中都保持严谨,而这种严谨性在赶工期限下往往难以在代码库中维持。
其余一切都取决于你正在构建什么。 在浏览器端,fetch 是内置且免费的,对于简单的请求模式,其模板代码微不足道。在服务器端,包大小不再是问题,而 fetch 省略的功能——超时、拦截器、重试、代理配置——恰恰是服务器代码所必需的,因此天平向另一边倾斜。
值得参考的检验标准是:**如果你为 fetch 编写了一个封装类,而代码行数已超过三十行左右,那么你实际上是在维护一个小型 HTTP 库。**这既可能是经过深思熟虑的合理选择,也可能是无意中做出的糟糕决定。
关于代理——这也是人们常为此浪费一个下午的时间:Node.js 中的原生 fetch 会忽略标准的代理环境变量。设置 HTTPS_PROXY 没有任何作用,请求会直接发送,且不会报错。请提供一个 undici ProxyAgent 分发器,或使用 Axios 的 proxy 选项——并通过对地址报告端点的验证来确认,而不是直接假设。
我们提供代理服务,但大多数服务器端请求并不需要代理。当确实需要代理时,了解正在使用的客户端是调试的第一步,而不是最后一步。