我在做前端接口层的那几年,被问得最多的问题不是"某个框架怎么用",而是"为什么我这个 POST 请求后端收不到参数"。fetch 这个 API 进入主流视野已经很多年,语法简单到几乎不用记忆,可真正把它用对、用稳,踩的坑远比想象中多:请求体编码对不上、响应 200 却拿不到数据、跨域预检被拦在外面、传大文件时页面一直转圈没有任何反馈、超时了请求还挂在那里。这篇文章就把"使用 fetch 发送 POST 请求"这件事从头到尾捋一遍,讲清楚它是什么、能解决什么问题、适合谁来参考。如果你刚开始接触前端接口调用,或者正从老式写法迁移过来但总觉得哪里别扭,又或者你在维护一个历史项目的请求层,下面的内容基本都能对上你的场景。
1. 先把 POST 请求这件事的本质想明白
1.1 一次接口联调卡壳的真实场景
早几年我参与过一个内部管理系统的重构,前端换成新的技术栈,后端接口是老的。联调第一天就卡住了:页面点保存,控制台没有任何报错,后端日志里请求确实进来了,但取参数的地方全是空值。前端同学坚持说自己传了数据,后端同学坚持说自己没收到,两边对着屏幕僵了快两个小时。
最后把请求抓出来一看,请求头里的Content-Type是text/plain;charset=UTF-8,请求体是一串看起来像 JSON 的字符串。问题就出在这:后端框架按照Content-Type去选择解析器,看到text/plain就当纯文本处理,根本没有往 JSON 解析器那边走,参数自然全是空的。而前端同学困惑的点在于——"我明明传的是 JSON 字符串啊"。
这个案例几乎概括了 fetch POST 八成的坑:fetch 不猜你的意图,它只按规范办事。你给它一个字符串,它就按字符串处理;你不告诉它Content-Type,它就自己填一个默认值。这个默认值在 GET 请求里无所谓,在 POST 请求里往往是致命的。理解了这一点,后面所有的细节都是这条原则的延伸。
1.2 为什么还要用 fetch,而不是别的方案
说到发送 POST 请求,可选项其实不少,我把常见的几类放在一起对照,方便你判断自己的项目适合哪种。
| 方案 | 上手成本 | 请求体自动序列化 | 上传进度 | 超时控制 | 适合场景 |
|---|---|---|---|---|---|
| XMLHttpRequest | 高 | 否 | 原生支持 | 原生支持 | 老项目、需要上传进度 |
| jQuery.ajax | 低 | 是(对象自动转表单) | 支持 | 支持 | 维护中的历史项目 |
| axios | 低 | 是 | 支持 | 支持 | 业务复杂、需要拦截器 |
| fetch | 中 | 否 | 不支持上传进度 | 需配合 AbortController | 原生环境、轻量需求、现代浏览器 |
fetch 的优势很明确:它是浏览器原生能力,不引入任何第三方体积;它基于 Promise,天然适配 async/await 的写法;它在 Service Worker、Node 环境里同样可用,一套写法能在多处复用。劣势同样明确:抽象层级低,序列化、超时、错误判定、重试这些在封装库里现成的东西,你都得自己补。
我个人的判断标准是:如果项目里请求量不大、或者你正在写一个不依赖第三方库的小工具,fetch 完全够用,把本文后面的封装抄过去就行;如果项目里有几十个接口、需要统一鉴权、统一错误提示、统一 loading 状态,那老老实实用成熟库更省心,硬造轮子的维护成本会反噬你。
1.3 POST 的本质是把数据塞进请求体
很多人把 GET 和 POST 的区别记成"一个传参一个不传参",这不准确。真正的区别在于:GET 的语义是"取",参数拼在 URL 上,受长度限制、会被日志记录、会被缓存;POST 的语义是"提交",数据放在请求体里,长度理论上不受限,不进 URL,不进浏览器历史。
放到 fetch 的语境里,这意味着 POST 请求必须多关注一个东西——body。而body的类型是有严格限制的,它只接受这么几类:字符串、URLSearchParams、FormData、Blob、ArrayBuffer、TypedArray、ReadableStream。你直接塞一个普通对象进去,浏览器不会帮你转,它要么报错,要么在某些宽松实现下变成[object Object]这种诡异字符串。所以"把对象变成 fetch 认识的 body",是所有 POST 请求的第一道工序。
2. 三件套:method、headers、body 的配合关系
2.1 一个能跑通的最小 POST 请求
先把最基本的形态摆出来,这是后面所有讨论的地基。
const res = await fetch('/api/user/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'A同学', age: 18 }) }); const data = await res.json(); console.log(data);这十几行里有四个必须理解的决策点,我逐个说。
第一,method: 'POST'必须显式写。fetch 的默认方法是 GET,你不写,它就当 GET 发,body 会被静默忽略——注意是静默,不报错,这才最坑。
第二,Content-Type必须手动设成application/json。前面那个联调案例就栽在这里。fetch 只有在 body 是URLSearchParams或FormData时才会替你设置合理的Content-Type,body 是普通字符串时它填的是text/plain;charset=UTF-8。
第三,JSON.stringify必须自己调。fetch 不做对象序列化,这是它和 axios 最直观的差异。
第四,res.json()返回的仍然是 Promise,必须再 await 一次。这是初学者最容易漏的一步,漏了就会拿到一个Response对象然后打印出一堆看不懂的属性。
2.2 Content-Type 决定了后端走哪个解析分支
可以这样类比:请求体是一封信,Content-Type是信封上写的"这封信是中文写的"还是"这封信是英文写的"。后端拿到信之后,先看信封标注,再决定找哪个翻译。标注错了,翻译找错了,内容再正确也读不出来。
服务端主流框架的行为基本一致:Spring 系看@RequestBody配合消息转换器,Node 系看body-parser或框架内置解析器,Go 系看json.NewDecoder。它们的选择依据都是Content-Type。你把 JSON 字符串标成text/plain,后端不会尝试 JSON 解析,直接把整个字符串当参数值塞进去,表现出来就是"收到了但字段全空"。
还有一种更隐蔽的情况:浏览器在跨域场景下,如果Content-Type是application/json,会触发预检请求(OPTIONS)。有些老服务端没处理 OPTIONS,预检直接 404,浏览器就拦下了真正的 POST。这时候看控制台只有一条 CORS 报错,很容易误判成"跨域没配好",其实是预检没被放行。这部分在第 5 章会展开。
2.3 三种主流请求体格式怎么选
实际项目里,POST 的请求体格式基本就这三种,选错了就是白忙活。
| 格式 | Content-Type | body 写法 | 后端接收方式 | 典型场景 |
|---|---|---|---|---|
| JSON | application/json | JSON.stringify(obj) | 反序列化成对象 | 现代 REST 接口、前后端分离项目 |
| 表单编码 | application/x-www-form-urlencoded | new URLSearchParams(obj) | 按键值对逐个取 | 老接口、第三方回调、部分支付网关 |
| 多部分表单 | multipart/form-data | FormData对象 | 按字段名取,文件单独处理 | 文件上传、混合文本与二进制 |
第一种是现在的主流,只要接口文档写的是 JSON,闭着眼睛用就行。第二种在老系统里极其常见,很多第三方平台的回调地址至今只认这个格式,用 JSON 发过去一律返回参数错误。第三种是文件上传的唯一选择,没有替代方案。
这里有个细节值得单独提醒:使用FormData时,绝对不要手动设置Content-Type。因为multipart/form-data必须带一个boundary分隔符,这个分隔符是浏览器在发送时随机生成并拼进请求头的。你手动写死Content-Type: multipart/form-data,boundary 就丢了,后端解析时会直接报"找不到分隔符"或者解析出空字段。正确做法是把Content-Type这一行整个删掉,让浏览器自己填。
// 正确:不设置 Content-Type,浏览器自动补 boundary const fd = new FormData(); fd.append('file', fileInput.files[0]); fd.append('bizType', 'avatar'); await fetch('/api/upload', { method: 'POST', body: fd });2.4 那些不该手动设置的请求头
除了Content-Type在 FormData 场景下不能手写,还有几个头也容易踩雷。
Content-Length不要自己设。浏览器会根据实际 body 计算,你算错了或者用了流式 body,反而会导致请求被截断。Host、Origin、Referer这类由浏览器托管的头,规范上属于禁止修改的名单,设了也会被忽略,某些环境甚至会直接抛错。User-Agent在浏览器里同样改不了,别在这上面浪费时间。
还有一个常被误用的:X-Requested-With: XMLHttpRequest。这是 jQuery 时代留下的习惯,用途是让后端识别"这是 AJAX 请求"。现在很多项目已经不需要它了,而加上它会让请求从"简单请求"变成"需要预检的请求",白白多一次 OPTIONS 往返。如果你的接口不依赖这个头做判断,删掉它。
3. 把 POST 请求写健壮:超时、错误、解析
3.1 fetch 不会因为 4xx 和 5xx 而 reject
这是 fetch 设计上最反直觉的一点,也是我见过最多人栽跟头的地方。很多人写完try/catch,以为 500 错误会被捕获,结果catch里永远是空的,业务代码拿着一个错误响应继续往下跑,最后在某个不相干的地方崩掉。
fetch 的 Promise 只在两种情况下 reject:网络层失败(断网、DNS 解析不了、请求被浏览器拦截)和请求被 abort。只要服务端返回了响应,哪怕是 500、404、401,Promise 都是 fulfilled。所以状态码判断必须自己做。
const res = await fetch('/api/order/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { // ok 为 true 的条件是 status 在 200-299 之间 throw new Error(`请求失败,状态码 ${res.status}`); }res.ok是个语法糖,等价于res.status >= 200 && res.status < 300。但我建议在关键业务里别只用它,因为 204 无内容、304 未修改这些边界情况需要单独处理,后面会讲到。
3.2 超时控制为什么必须用 AbortController
fetch 本身没有 timeout 参数,这点和 XMLHttpRequest 不同。早期有人用Promise.race配合一个定时器来模拟超时,这个方案有个致命缺陷:它只是让代码不再等待,底层请求并没有被取消,连接依然占着,服务端依然在处理,如果并发量大,会白白消耗资源。
正确做法是AbortController。
async function postJSON(url, data, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: controller.signal }); return res; } finally { clearTimeout(timer); } }clearTimeout放在finally里是必须的。如果请求很快返回而定时器没清,那么在超时时间到达时依然会触发abort(),虽然此时请求已完成不受影响,但如果这个 controller 被复用(比如某些封装里做了缓存),就会误伤后续请求。这种 bug 极难排查,因为现象是"偶尔某个请求莫名失败"。
新一些的运行环境已经支持AbortSignal.timeout(ms),一行就能搞定,不用手动清理定时器:
const res = await fetch(url, { method: 'POST', body: JSON.stringify(data), signal: AbortSignal.timeout(8000) });需要说明的是,被 abort 之后抛出的错误,name属性是AbortError。如果你在 catch 里统一处理错误提示,记得把它和真正的网络错误区分开,否则用户会看到"网络异常"这种让人一头雾水的提示,实际上只是请求慢了点儿。
3.3 响应解析的边界情况
res.json()看着无害,实际上有几种情况会抛异常,而且抛的是SyntaxError,和你预期的业务错误完全不是一类。
第一种,响应体为空。比如接口返回 204,或者后端在某些分支里直接return;没写任何内容。这时候调res.json()会抛Unexpected end of JSON input。
第二种,响应体不是 JSON。比如后端出错时返回了一个 HTML 错误页,网关超时返回了一段纯文本。这时候同样会解析失败。
第三种,Content-Type标错了。响应头写的是text/html但内容是 JSON,有些环境会宽容处理,有些不会,行为不一致。
稳妥的写法是先判断状态码和内容类型,再决定怎么解析:
async function parseResponse(res) { if (res.status === 204 || res.status === 205) { return null; } const contentType = res.headers.get('Content-Type') || ''; if (contentType.includes('application/json')) { const text = await res.text(); // 空字符串直接返回 null,避免 JSON.parse 抛错 return text ? JSON.parse(text) : null; } return await res.text(); }这段代码里用res.text()而不是res.json(),是因为text()对空响应体是安全的,返回空字符串。先拿文本再自己JSON.parse,可以把"解析失败"和"请求失败"这两类错误彻底分开,日志里也更容易定位。
3.4 一个可以直接抄走的封装
把前面几节的东西揉到一起,就是我平时项目里用的基础版。它不追求功能全,但覆盖了 90% 的场景。
async function request(url, options = {}) { const { method = 'GET', data, headers = {}, timeout = 10000, credentials = 'same-origin' } = options; const finalHeaders = { ...headers }; let body; if (data !== undefined) { if (data instanceof FormData || data instanceof URLSearchParams) { body = data; // 这两种类型不手动设置 Content-Type } else { finalHeaders['Content-Type'] = 'application/json'; body = JSON.stringify(data); } } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); let res; try { res = await fetch(url, { method, headers: finalHeaders, body, credentials, signal: controller.signal }); } catch (err) { if (err.name === 'AbortError') { throw new Error('请求超时,请检查网络后重试'); } throw new Error('网络连接失败,请稍后重试'); } finally { clearTimeout(timer); } const payload = await parseResponse(res); if (!res.ok) { const message = (payload && payload.message) || `请求失败(${res.status})`; const error = new Error(message); error.status = res.status; error.payload = payload; throw error; } return payload; }这个封装的取舍点值得说说。我把data和body分开了,调用方只传普通对象,序列化逻辑在内部判断类型,这样调用方不用每次记得JSON.stringify。错误对象上挂了status和payload,上层可以做更细的分支处理,比如 401 跳登录、403 提示无权限。超时和网络错误被翻译成了中文提示,但保留了原始错误类型判断,方便排查。
4. 表单、文件、凭证这些特殊场景
4.1 FormData 的追加细节
FormData用起来简单,但有几个细节能让人折腾半天。
append第三个参数可以指定文件名。上传 Blob 或者从 canvas 转出来的图片时,不指定文件名,后端收到的就是一个没有扩展名的无名文件,很多存储服务会拒收。
const fd = new FormData(); fd.append('file', blob, 'avatar.png');同一个字段名append多次,会形成数组。这是上传多文件的正确姿势,不要用field[]这种 PHP 风格的命名,除非后端明确要求。
FormData里的所有值最终都会变成字符串或二进制。数字 123 传过去后端拿到的是 "123",布尔值会变成 "true"/"false" 字符串。如果后端做了严格类型校验,这里就会出问题,需要在后端侧做转换,或者干脆改用 JSON。
4.2 URLSearchParams 与中文编码
老接口常见application/x-www-form-urlencoded格式,用它就对了。
const params = new URLSearchParams(); params.append('keyword', '测试'); params.append('page', 1); await fetch('/api/search', { method: 'POST', body: params });URLSearchParams会自动做 URL 编码,中文、空格、特殊字符都能正确处理,并且会自动把Content-Type设成application/x-www-form-urlencoded;charset=UTF-8。这点比自己拼字符串可靠得多——手拼的时候忘了encodeURIComponent,遇到&或者=就会把参数结构破坏掉,表现出来就是"某个字段的值被截断了"。
有个坑要提醒:URLSearchParams的构造函数接受对象时,数组会被转成逗号拼接的字符串,嵌套对象会变成[object Object]。传复杂结构时老老实实用append逐个加。
4.3 credentials 与跨域携带凭证
credentials这个参数决定请求是否携带 Cookie 和 HTTP 认证信息,默认值是same-origin,意思是同源才带。如果你的前端和后端不同源,需要带登录态,就必须显式设成include。
await fetch('https://api.example.com/user/profile', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });注意,这需要服务端配合:响应头必须包含Access-Control-Allow-Credentials: true,并且Access-Control-Allow-Origin不能是通配符*,必须回显具体的来源。这两个条件缺一个,浏览器就会拦下响应,控制台只报 CORS 错误,看不出具体是哪一条没满足。排查时先看响应头,比反复改前端代码高效得多。
还有一个容易忽略的点:设了include之后,如果服务端的Allow-Origin是*,浏览器不仅拦截响应,甚至不会把响应交给你的代码,你连状态码都拿不到,只能在 catch 里看到一个TypeError: Failed to fetch。这种"信息量极低"的错误是跨域问题最难排查的地方。
4.4 上传进度这件事,fetch 确实做不到
这是个硬性限制。fetch 目前没有暴露上传进度的接口,ReadableStream作为 body 的方案在部分环境下可用(需要duplex: 'half'),但兼容性很差,生产环境不建议依赖。
如果产品明确要求"上传时显示进度条",方案只有一个:上传这一个接口用 XMLHttpRequest,其余接口继续用 fetch。这不是什么丢人的妥协,实际项目里很常见。
function uploadWithProgress(url, formData, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(`上传失败:${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('网络异常')); xhr.send(formData); }); }下载进度倒是可以用 fetch 做,通过res.body.getReader()逐块读取并累计字节数。不过这个用法相对小众,如果你的场景是"下载一个大文件并显示进度",用同样的思路实现即可,注意要把各个 chunk 收集起来再拼成 Blob。
5. 常见问题与排查技巧实录
5.1 预检请求被拦下,但报错信息看不出所以然
跨域 POST 最常见的拦路虎是 OPTIONS 预检。先明确什么情况下会触发预检:请求方法不是 GET/HEAD/POST,或者设置了非安全列表内的请求头,或者Content-Type不是那三个允许值之一。application/json恰好不在允许列表里,所以几乎所有 JSON 格式的跨域 POST 都会先发一次 OPTIONS。
排查顺序我一般是这么走的。第一步,在浏览器网络面板里看有没有那条 OPTIONS 请求,如果没有,说明问题不在预检,去看别的地方。第二步,看 OPTIONS 的响应状态码和响应头,服务端必须返回 2xx,并且带上Access-Control-Allow-Methods(含 POST)、Access-Control-Allow-Headers(含Content-Type以及你自定义的头)。第三步,如果是带凭证的请求,还要检查Allow-Credentials和Allow-Origin。
这里有个很多人不知道的点:预检响应可以被缓存。服务端返回Access-Control-Max-Age: 86400,浏览器在一天内对同样的请求不再重复预检,能省掉大量 OPTIONS 往返。这个头的值别设太大,否则服务端改了 CORS 策略后客户端长时间不生效,调试时会怀疑人生。我一般设 600 到 1800 秒之间。
5.2 状态码 200,但数据就是不对
这类问题比报错更难查,因为没有错误信息。我整理过几个典型原因。
请求体格式和后端预期不一致,前面说过,优先级最高。参数名大小写不一致,userName和username在某些序列化框架下不兼容。数字被当成字符串传过去,后端做严格比较时判断失败。日期格式不一致,前端传 ISO 字符串,后端按时间戳解析。还有一类是Content-Type带了额外的 charset,某些严格的解析器会因此匹配不上。
排查的最快路径永远是:在浏览器网络面板里把请求原样复制成 curl 命令,拿到命令行里重放。如果 curl 能成功而浏览器失败,问题在浏览器侧(跨域、凭证、缓存);如果 curl 也失败,问题在请求本身,逐字段比对即可。这个动作能省掉大量"你改一下我试试"的来回。
5.3 常见问题速查表
| 现象 | 高概率原因 | 排查动作 |
|---|---|---|
| 后端收到的参数全为空 | Content-Type 缺失或错误 | 检查请求头是否为 application/json |
报[object Object] | body 直接传了对象未序列化 | 补 JSON.stringify |
| 控制台只有 CORS 报错 | 预检未通过或 Allow-Origin 不匹配 | 看 OPTIONS 请求与响应头 |
| 401 但明明登录了 | credentials 未设为 include | 检查请求配置与服务端凭证头 |
| 上传文件后端收不到 | 手动设置了 multipart/form-data | 删除该请求头让浏览器生成 boundary |
| 请求发出但一直没有响应 | 未设置超时 | 加 AbortController |
res.json()抛 SyntaxError | 响应体为空或非 JSON | 改用 text() 后判断再解析 |
| 请求偶发失败 | 定时器未清理导致误 abort | 检查 finally 里的 clearTimeout |
6. 工程化落地:从"能跑"到"好用"
6.1 请求层要收口,不要散落各处
项目初期大家习惯在每个页面里直接写fetch,写着写着就出问题了:鉴权头有的地方加了有的地方没加,错误提示文案五花八门,某个接口改了路径要全局搜索替换。我踩过这个坑之后,定了一条规矩——所有网络请求必须走统一的请求层,业务代码里不允许出现裸的 fetch。
请求层至少要承担四件事:拼公共头和鉴权信息、统一序列化和反序列化、统一错误翻译、统一超时和重试策略。这四件事收口之后,业务代码就只剩"调接口、处理数据"两件事,可读性和可维护性完全是两个量级。
6.2 重试要考虑幂等性
网络抖动时自动重试很香,但不是所有 POST 都能重试。判断标准是幂等性:同一个请求发一次和发十次,结果是否一致。查询类、更新某个确定字段的接口通常可以重试;创建订单、提交支付、扣减库存这类接口重试会造成重复数据,必须配合幂等键(客户端生成唯一 ID 随请求带上,服务端做去重)才能安全重试。
我的默认策略是:只对网络层错误和 5xx 做重试,4xx 一律不重试(那是请求本身有问题,重试一百次也一样),最多重试两次,间隔用指数退避(比如 300ms、900ms)。并且在重试前打日志,否则线上出问题时你根本不知道某个接口实际被调用了三次。
6.3 几个我在实际项目里攒下的心得
第一个心得:给每个请求加一个可追踪的 ID。生成一个随机串放进自定义请求头,同时打进前端日志,服务端也记录这个头。线上排查问题时,用户报了一个时间点,你就能顺着这个 ID 把前后端日志串起来。这个成本极低,收益极高。
第二个心得:不要在请求层里做 UI 相关的副作用。早期我把"弹出错误提示"写进了请求层,结果某个后台轮询接口一失败就弹窗,用户五分钟内被弹了十几次。请求层只负责抛出结构化的错误对象,弹不弹、怎么弹,交给调用方决定。
第三个心得:缓存策略要显式声明。POST 请求默认不会被缓存,但如果中间有代理或者 CDN,某些配置下依然可能出现意外行为。稳妥的做法是在请求头里带上Cache-Control: no-store,尤其是提交类接口,避免中间层做出不符合预期的处理。
第四个心得:调试阶段把完整请求打出来。浏览器的网络面板很强大,但在移动端内嵌页面、或者某些特殊容器里,面板不一定可用。我习惯在开发环境给请求层加一个开关,打开后把 method、url、headers、body 和响应耗时全部打印出来,定位问题的速度比翻面板快得多。
最后再分享一个实用的小技巧。当你不确定某个接口到底该用哪种Content-Type时,别猜,直接去看接口文档里的请求示例,或者让后端把一段能跑通的 curl 命令发给你。把那条 curl 里的-H参数原样搬到 fetch 的 headers 里,把-d的内容按格式还原成 body,成功率基本是百分之百。我这些年遇到的绝大多数"请求发不出去"的问题,最后都是靠这个办法在十分钟内解决的。