一条 curl 命令本质上是一组请求参数:方法、URL、请求头、正文、认证和重定向策略。转换时把每个参数映射到 fetch 或 axios 的对应选项,然后用请求检查工具对比两边实际发出的头和正文,而不是只看代码像不像。
- curl -d 会发送正文,并默认使用 application/x-www-form-urlencoded;JSON 需要显式设置 Content-Type。
- fetch 不会因为 4xx 或 5xx 而 reject,必须自己检查 response.ok。
- 浏览器 fetch 会执行 CORS,curl 和服务端 fetch 不会。
- axios 传入对象时会自动序列化 JSON。
- 转换完成后要对比实际请求,而不是只对比代码。
从接口文档复制一条 curl 命令很容易,把它准确转成 fetch 或 axios 才是细节所在:Content-Type、正文编码、认证、重定向和错误处理,任何一项不同都会导致结果不同。
这篇文章把常用参数逐项映射,并说明如何验证转换是否正确。
把 curl 命令拆成请求参数
curl 命令不是一整块字符串,而是一组参数:
curl -X POST 'https://api.example.com/orders' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer TOKEN' \
-d '{"item":"keyboard","quantity":2}'
它对应的请求是:
- 方法:
POST - URL:
https://api.example.com/orders - 请求头:
Content-Type: application/json、Authorization: Bearer TOKEN - 正文:
{"item":"keyboard","quantity":2}
把这四件事列清楚,JavaScript 版本就很容易写。
四行摘要还会漏掉一些会改变请求的信息:没有 -X 时 -d 隐含 POST,-I 隐含 HEAD,-G 会把数据移到查询串而不是正文。还有一些参数根本无法映射到浏览器 fetch:--resolve、--cacert、--cert、--proxy、--http1.1、--connect-timeout 描述的是传输层,不是 HTTP 请求本身,它们属于服务端客户端或代理。默默丢掉这些参数,就是“curl 能跑、浏览器跑不通”的常见起点。
shell 引号也是请求的一部分。单引号会保护 $ 和 !,双引号会做变量展开,所以文档里含 $TOKEN 的命令,可能已经在文档作者的环境里替换过,也可能在你的环境里展开成空字符串。-d '...' 里的多行 JSON 之所以保留换行,是引号的作用;重新手打时如果去掉引号,正文就变了。
参数映射表
| curl 参数 | 含义 | fetch | axios |
|---|---|---|---|
-X POST | 请求方法 | method: "POST" | method: "post" |
-H 'Name: value' | 请求头 | headers: { Name: "value" } | headers: { Name: "value" } |
-d '...' | 请求正文 | body: "..." | data: "..." |
--data-raw | 原样正文 | body: "..." | data: "..." |
--data-binary @file | 二进制正文 | body: fileBlob | data: fileBlob |
-F 'name=value' | 表单字段 | FormData | FormData |
-u user:pass | Basic 认证 | Authorization 头 | auth: { username, password } |
-b 'name=value' | Cookie | credentials 或 Cookie 头 | withCredentials 或头 |
-L | 跟随重定向 | redirect: "follow"(默认) | 默认跟随 |
--json '{...}' | JSON 正文加 JSON 内容类型(curl 7.82+) | body: JSON.stringify(...) 加头 | data: {...} |
-G | 把数据移到查询串 | 用 URLSearchParams 拼接 | params |
--data-urlencode | 对表单字段做 URL 编码 | URLSearchParams | params |
-I | HEAD 请求 | method: "HEAD" | method: "head" |
-m 10 | 整个传输的超时 | signal: AbortSignal.timeout(10000) | timeout: 10000 或 signal |
--retry 3 | 失败自动重试 | 自己实现带退避的循环 | 用拦截器实现 |
--compressed | 请求压缩响应 | 自动处理 | 自动处理 |
两个映射要特别小心。--data-urlencode 会对每个字段做表单编码,fetch 没有对应参数,应该构造 URLSearchParams,而不是手工把字符串拼到 URL 上。-m 10 限制整个传输,Axios 的 timeout 在不同 adapter 下语义不完全一致;需要精确截止时间时,fetch 和 Axios 都用 AbortSignal.timeout。--retry 完全没有直接对应物,重试策略还必须知道请求是否幂等:重试 POST 可能创建重复订单,所以只对安全方法或支持幂等键的接口启用,并加抖动退避。
带请求头的 GET
curl 'https://api.example.com/orders?status=open' \
-H 'Accept: application/json' \
-H 'Authorization: Bearer TOKEN'
const response = await fetch("https://api.example.com/orders?status=open", {
method: "GET",
headers: {
Accept: "application/json",
Authorization: "Bearer TOKEN"
}
});
if (!response.ok) {
throw new Error(`请求失败:${response.status} ${await response.text()}`);
}
const data = await response.json();
axios 版本:
const { data } = await axios.get("https://api.example.com/orders", {
params: { status: "open" },
headers: { Authorization: "Bearer TOKEN" }
});
axios 会把 params 拼成查询字符串;fetch 需要用 URLSearchParams 自己拼。
查询串的细节容易造成静默偏差。URLSearchParams 把空格编码成 +,这对表单编码是正确的,但期待 %20 的接口可能不认。重复键必须用 append:对象字面量无法表示 status=open&status=paid,后一个值会覆盖前一个。Axios 默认把数组序列化成 status[]=open,接口如果期待重复的裸键,需要自定义 paramsSerializer。
fetch 也不允许 GET 或 HEAD 带正文:new Request(url, { method: "GET", body: "..." }) 会抛 TypeError: Request with GET/HEAD method cannot have body。curl 会老老实实发送 -X GET -d '...',浏览器 API 不会,这种请求需要重新设计,而不是换个参数。
POST JSON
curl -X POST 'https://api.example.com/orders' \
-H 'Content-Type: application/json' \
-d '{"item":"keyboard","quantity":2}'
const response = await fetch("https://api.example.com/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: "keyboard", quantity: 2 })
});
axios 传入对象时会自动设置 JSON 内容类型;fetch 需要自己设置头并序列化正文。
JSON.stringify 不是对 curl 正文的透明复制。值为 undefined 的属性会消失,Date 会通过 toJSON 变成 ISO 字符串,BigInt 会抛 TypeError: Do not know how to serialize a BigInt,循环引用会抛 Converting circular structure to JSON。如果原始正文是手写的,序列化后的空白、重建对象后的键顺序、非 ASCII 的转义方式都可能不同。普通接口不在意,签名方案会在意。
Axios 只在它自己序列化对象时自动加 JSON 内容类型;传入已经序列化好的字符串时,必须显式设置请求头,否则请求可能以 text/plain 发出,内容协商直接失败。
Content-Type 陷阱
curl -d 默认使用 application/x-www-form-urlencoded,不是 JSON。如果命令里有 -H 'Content-Type: application/json',以显式头为准;如果没有,服务端收到的就是表单编码。
表单编码请求应该使用 URLSearchParams:
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: "abc",
client_secret: "secret"
});
const response = await fetch("https://auth.example.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body
});
表单 URL 编码转换工具 可以检查编码结果。
curl 的 -d @file 读取文件时会去掉换行和回车,--data-binary @file 才保留原始字节。格式化过的 JSON 用 -d @payload.json 发送,到达服务端时变成一行,通常无害;但依赖换行的内容(NDJSON、签名、PEM 块)会被破坏。文件字节必须原样保留时,用 --data-binary。
URLSearchParams 作为 fetch 正文时会自动设置 Content-Type: application/x-www-form-urlencoded;charset=UTF-8。手动设置一个不带 charset 的头会覆盖它,有些服务端对 charset 参数很严格;要么让对象自己设置,要么把参数写全。代理和应用各加一个 Content-Type 时,重复头也会导致 415。
Basic 认证
curl -u 'user:password' 'https://api.example.com/me'
const credentials = btoa("user:password");
const response = await fetch("https://api.example.com/me", {
headers: { Authorization: `Basic ${credentials}` }
});
axios:
const { data } = await axios.get("https://api.example.com/me", {
auth: { username: "user", password: "password" }
});
浏览器里的 btoa 处理不了非拉丁字符。应该先把凭据编码成 UTF-8 字节。Basic 认证头生成工具 会处理这个问题。
RFC 7617 在解码后的凭据里按第一个冒号切分:用户名不能包含冒号,密码可以。用户名里带冒号时,服务端会解析成另一个用户,或者直接认证失败。非 ASCII 凭据没有统一字符集,标准允许 charset 参数;没有该参数时,有的服务端按 UTF-8,有的按 Latin-1。接口文档指定了字符集就按它编码,不要假设 btoa 已经处理正确。
浏览器里的 Basic 认证对用户和任何能读取页面的脚本都可见,它不是密钥存储机制,而且每次请求都会带上密码。只在 TLS 上使用,接口支持短期 Token 时优先用 Token。
文件上传
curl -X POST 'https://api.example.com/upload' \
-F 'file=@report.pdf' \
-F 'description=Monthly report'
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("description", "Monthly report");
const response = await fetch("https://api.example.com/upload", {
method: "POST",
body: form
});
不要手动设置 Content-Type。浏览器会自动加上正确的 multipart boundary;手动设置通常会让服务端无法解析。
-F 携带的信息比字段名多。-F 'file=@report.pdf;type=application/pdf' 设置分片的 content type,-F 'file=@photo.jpg;filename=renamed.jpg' 覆盖服务端看到的文件名。在 fetch 里,form.append("file", blob, "renamed.jpg") 的第三个参数就是文件名;File 自带 type,普通 Blob 可能需要在构造时显式指定。同名字段上传多个文件,就是重复调用 append。
fetch 无法报告上传进度。界面需要进度条时,用 XMLHttpRequest 或 Axios 的 onUploadProgress(浏览器里底层就是 XHR)。Node 里的 Axios 可能需要 form-data 包,或者依赖运行时提供的全局 FormData;不要假设浏览器代码在服务端能原样运行,先测运行时。
重定向与 Cookie
fetch 默认跟随重定向,但跨域重定向和携带凭据的请求行为不同。如果原 curl 依赖 -L 和 Cookie,需要逐项确认:
- 浏览器 fetch:跨域 Cookie 使用
credentials: "include"。 - Node fetch:不会自动管理 Cookie,需要手动传
Cookie头。 - 浏览器 axios:
withCredentials: true。 - Node axios:使用 cookie jar 或手动传头。
fetch 的 redirect 有三个值:follow(默认)、error、manual。浏览器里 manual 返回 opaque-redirect,状态是 0,读不到 Location;Node 的 fetch 实现里 manual 会暴露 3xx 响应。需要读取跳转目标的转换,可能在 Node 通过、在浏览器失败。fetch 还会限制跳转次数,超过限制时以 TypeError reject,而不是返回最后一个响应。
Cookie 不属于 fetch API。浏览器里 credentials: "include" 让浏览器按自己的 Cookie 策略携带和保存 Cookie,但 JavaScript 仍然读不到 Set-Cookie。Node 的 fetch 完全没有 cookie jar,登录流程需要手动处理请求头,或者用带 jar 的客户端库。Node 里的 Axios 同样如此,withCredentials 只对浏览器生效。
如何验证转换正确
最可靠的方法是对比两边实际发出的请求。可以用 HTTP 请求工作台 或 cURL 转换工具 检查。
重点对比:
- 方法和 URL,包括查询参数。
- 每个请求头,尤其是
Content-Type和Authorization。 - 正文字节。JSON 键顺序通常不重要,但编码和空白可能重要。
- 重定向行为。
- 错误处理。fetch 返回 401 时仍然是 resolve,不是成功。
转换正确的标准是服务端收到同样的请求,而不是代码看起来相似。
请求带签名时,字节级一致性是硬要求。AWS Signature V4 和 webhook HMAC 都会对包含正文哈希的规范请求签名;用 JSON.stringify 重建正文可能改变键顺序或转义,JSON 语义相同,签名却对不上。这种场景要保留原始正文字符串,对那串字节签名。
Chrome 和 Firefox 的网络面板可以把请求导出成 cURL(Copy as cURL),这是对比浏览器版本和原始命令最快的方法。curl 的 --trace-ascii 会打印收发的字节和请求头,涉及空白或编码时比 -v 更精确。
浏览器与服务端的区别
curl 通常在浏览器之外运行。如果转成浏览器端 fetch,会多出一些限制:
- 非简单请求会触发 CORS 预检。
- 不能读取某些禁止的头。
- 携带凭据需要显式配置。
- 混合内容会被拦截。
禁止头列表解释了不少失败案例。浏览器 fetch 会静默丢弃对 Host、Cookie、Content-Length、Origin、Referer 的设置。服务端如果要求手工计算的 Content-Length 或特定的 Cookie 头,浏览器 JavaScript 根本做不到,只能把请求移到服务端。
最后一条约束是保密。文档里的 curl 命令带 API key,是因为读者会在可信机器上运行;把同一个 key 放进浏览器代码,等于向每个访客公开。使用密钥的接口应该由后端调用,浏览器只调用自己的后端。能跑通但把密钥发到前端的转换,仍然是错误的架构。
如果接口并不是为浏览器设计的,应该把请求留在服务端,只暴露一个窄接口给前端。不要为了跑通复制来的命令而关闭浏览器安全策略。
常见问题
为什么转换后的 fetch 请求返回结果不一样?
最常见的原因是缺少 Content-Type、正文序列化方式不同、漏了 Authorization 头,或者 curl 跟随了重定向而 fetch 没有。应该逐项对比请求头、请求体和重定向行为。
fetch 遇到 500 会抛错吗?
不会。只要网络层成功,fetch 对 4xx 和 5xx 都会正常 resolve。需要检查 response.ok 或 response.status,并显式读取错误正文。
curl -u user:password 怎么转成 fetch?
把用户名和密码用冒号拼接,做 Base64 编码,然后作为 Authorization: Basic <值> 发送。不要把凭据直接放进 URL。
为什么 curl 成功,浏览器 fetch 却报 CORS?
curl 不执行浏览器的同源策略。浏览器对很多跨域请求会先发预检,服务端没有返回正确的 CORS 头时就会拦截。需要改服务端配置,而不是改 fetch 写法。
fetch 怎么上传文件?
使用 FormData,把文件和普通字段 append 进去。不要手动设置 Content-Type,浏览器会自动加上带 boundary 的 multipart/form-data。axios 也接受同样的 FormData。