1. 先搞清楚 fetch 发 POST 到底难在哪
1.1 它只是换掉了回调,也换掉了整套错误模型
很多人第一次用fetch发 POST 请求,是把它当成XMLHttpRequest的简写版本:少写几个回调,少嵌套几层,代码看着干净。真跑起来才发现坑全在后面——接口明明返回了 400,代码却"成功"地走进了then;服务端日志里躺着一条 415,前端控制台干干净净;上传大文件想做个进度条,翻遍文档发现根本没有这个钩子。这些不是配置写错了,而是fetch的语义模型和 XHR 压根不是一套东西。
fetch发 POST 请求,核心就五个参数:method、headers、body、credentials、signal。看起来简单得不像话,但每一个都牵着一串连锁反应:headers里的Content-Type决定了服务端用什么解析器,也决定了浏览器要不要发预检;body的类型决定了浏览器会不会自动帮你补Content-Type;credentials和跨域响应头是一对必须同时满足的约束;signal则是唯一能让你主动掐断请求的入口。这篇内容就是把这五个参数背后的因果链拆开,讲清楚为什么这么配、不这么配会出什么事,以及一套能直接拿去用的 POST 封装。
适合谁来读:写过一两个接口调用、但对fetch只停留在"会抄模板"阶段的同学,能拿到完整的原理和可复用的封装;已经用了两三年、被偶发的跨域和超时问题折腾过的同学,可以在跨域预检和排查清单那两节里找到一些不那么容易搜到的细节。下面的代码都是原生 API,不依赖任何第三方请求库,浏览器和较新的服务端 JS 运行时都能跑通,差异点我会单独标出来。
1.2 POST 请求的四要素与它们的连坐关系
我习惯把一次 POST 拆成四件事:往哪发、发什么格式、带不带凭证、什么时候放弃。这四件事在代码里是四个字段,在运行时却互相咬合。
第一件事是目标地址与mode。默认mode: 'cors',同源请求不受影响,跨源请求就要走跨域那套规则。有人为了"绕开跨域报错"去写mode: 'no-cors',结果请求确实发出去了,但响应对象是不透明的,status永远是 0,body读不出来,等于只能发不能收。这在 POST 场景下几乎没用,因为 POST 十有八九是要读返回值的。
第二件事是请求体格式。Content-Type必须和body的真实类型匹配,这不是"建议",是硬约束。你body里塞了JSON.stringify(obj),却忘了写application/json,浏览器默认按text/plain发过去,服务端框架一看类型不对,直接回 415;反过来,你写死了application/json却传了个FormData,浏览器生成的boundary分隔符被你的手写头覆盖掉,服务端解析器找不到边界,一样是 400 起步。这两种错我见过太多次,本质都是"头"和"体"没对齐。
第三件事是凭证。credentials有三个值:omit、same-origin、include。它的作用是告诉浏览器,跨源请求要不要带上 Cookie 和认证信息。这里有个死结:一旦你写了include,服务端的Access-Control-Allow-Origin就不能是*,必须是具体来源,而且还得带上Access-Control-Allow-Credentials: true。前端改一行、后端改两行,任何一边漏了,浏览器都会把响应拦下来,控制台只给你一句含糊的跨域提示。
第四件事是放弃条件。fetch默认没有超时,一个卡住的请求会一直挂着,直到浏览器自己判死。所以AbortController不是可选项,是必需品。
提示:这四件事里,只有
method: 'POST'是写死的,另外三件都必须依据服务端契约来定。先问清楚接口要什么格式、要不要 Cookie、超时设多久,比先写代码重要得多。
1.3 什么场景该用 fetch,什么场景该换别的方案
fetch的定位是"底层的、贴近协议的请求原语",它的优点是零依赖、语义清晰、能拿到Response流、和AbortController天然配合。缺点是它把一堆工程化的事留给了你:超时、重试、序列化、错误分层、进度回调,全得自己写。
我的判断标准大致是这样:
| 场景 | 建议方案 | 理由 |
|---|---|---|
| 简单表单提交、一次性的接口调用 | 原生fetch | 代码量最小,没有额外依赖 |
| 需要统一拦截、统一错误码处理的中大型项目 | 在fetch上做一层封装 | 保留原生能力,同时复用工程逻辑 |
| 必须做上传进度条 | XMLHttpRequest或服务端分片 | fetch拿不到上传进度 |
| 需要读取大响应流做边下边处理 | fetch的response.body | XHR 拿不到流式接口 |
| 老浏览器兼容要求严格 | XHR 或带垫片的方案 | 部分早期浏览器没有完整实现 |
这不是"哪个更好"的问题,是"哪一层抽象更合适"的问题。绝大多数业务场景,在fetch上封一个 100 行左右的工具函数就够用了,没必要上重型方案;但涉及上传进度、断点续传、并发控制,硬用fetch会写得很别扭,还不如老老实实换工具。
另外要提醒一句:fetch在不同运行时的行为有细微差异。浏览器里它受同源策略约束,服务端运行时没有这个概念,但多了一层duplex配置的要求——当你给body传的是可读流时,某些实现要求显式声明duplex: 'half',否则直接抛错。同一份代码跨端跑,这类差异最容易在联调时才暴露。
2. body 与 Content-Type:请求体格式的三种流派
2.1 JSON 提交:序列化顺序决定了成败
JSON 是现在最常见的 POST 体格式,写法就三行:
const res = await fetch('/api/user/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ name: 'A同学', age: 24 }), });看着简单,但有两个细节决定成败。
第一个是顺序。必须先把对象JSON.stringify成字符串,再塞进body。直接塞对象进去,浏览器会把它转成"[object Object]"这个字符串发出去——不会报错,服务端会收到一段非法 JSON,然后返回 400,你在控制台看半天以为自己头写错了。这个错误的隐蔽之处在于:fetch对body的类型检查很宽松,字符串、Blob、FormData、URLSearchParams、ArrayBuffer、ReadableStream都接受,唯独普通对象不接受,但它不会在开发阶段给你明显提示。
第二个是Content-Type必须自己写。这是fetch和XHR的一个明显区别:用FormData时浏览器会自动补multipart/form-data; boundary=...,用URLSearchParams时自动补application/x-www-form-urlencoded;charset=UTF-8,但传字符串时它只会默认text/plain;charset=UTF-8。所以要发 JSON,头必须手写。
还有个小坑:JSON.stringify会丢掉值为undefined的字段,会把Date转成 ISO 字符串,会把NaN和Infinity转成null。如果你的接口依赖"字段存在但值为空"来区分"不更新"和"更新为空",序列化前得自己处理一遍,别指望JSON.stringify帮你保住语义。
// 需要保留 undefined 语义时,先清洗再序列化 function toJSONBody(data) { const cleaned = Object.fromEntries( Object.entries(data).filter(([, v]) => v !== undefined) ); return JSON.stringify(cleaned); }2.2 表单两派:URLSearchParams 和 FormData 的边界
传统表单提交分两派,对应两种Content-Type,选择依据是"有没有文件"。
没有文件、只是键值对,用URLSearchParams最省事:
const params = new URLSearchParams(); params.append('username', 'demo'); params.append('password', 'p@ss'); const res = await fetch('/api/login', { method: 'POST', body: params, // 浏览器自动补 application/x-www-form-urlencoded;charset=UTF-8 });注意这里我没有写headers。这就是URLSearchParams的好处:类型信息自带,浏览器知道该怎么标。如果你手贱加了一行'Content-Type': 'application/x-www-form-urlencoded'把 charset 丢掉,某些老派服务端解析器会因为编码声明缺失而把中文变成乱码,这种问题排查起来特别费时间。
有文件、或者需要传二进制,用FormData:
const form = new FormData(); form.append('file', fileInput.files[0]); form.append('bizType', 'avatar'); const res = await fetch('/api/upload', { method: 'POST', body: form, // 千万不要手写 Content-Type credentials: 'include', });FormData这条最经典的错误就是手写Content-Type: multipart/form-data。因为真正的头里必须带boundary=----WebKitFormBoundaryXXXX这样的分隔符,这个值由浏览器在发送时生成,你手写了头就把自动生成覆盖了,boundary丢失,服务端解析器无法切分字段,直接失败。记住一句话:只要body是FormData,headers里就不要出现Content-Type。
FormData还有一个容易忽略的能力:append的第三个参数可以指定文件名。
form.append('file', blob, 'report-2024.pdf');不传第三个参数时,文件名默认是"blob",服务端拿到的原始文件名就丢了,做文件类型校验或者存储命名时会很难受。另外FormData的字段值只能是字符串或Blob,传数字会被自动转成字符串,传对象会变成"[object Object]",需要嵌套结构就老老实实序列化成 JSON 字符串再塞进去。
| 场景 | body 类型 | Content-Type | 是否需要手写 |
|---|---|---|---|
| JSON 接口 | JSON.stringify(obj) | application/json | 必须手写 |
| 传统表单 | URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 | 不要手写 |
| 文件上传 | FormData | multipart/form-data; boundary=... | 绝对不要手写 |
| 纯文本 | 字符串 | text/plain;charset=UTF-8 | 默认值,一般不用写 |
| 二进制 | Blob/ArrayBuffer | 按内容定,如application/octet-stream | 建议手写 |
2.3 二进制与流:Blob、ArrayBuffer 和 ReadableStream 的取舍
上传图片、音频、打包文件时,body可以直接放二进制。三种类型各有用途:
Blob适合"已经是一个完整文件"的场景,比如从<input type="file">拿到的File对象本身就是Blob的子类,直接传就行,浏览器会自动算Content-Length。ArrayBuffer适合你在内存里拼装过二进制数据的场景,比如做了图片压缩、加了水印之后得到的Uint8Array。ReadableStream适合"边生成边发送"的场景,比如超大文件分片、实时日志推送。
// Blob 直传 await fetch('/api/upload', { method: 'POST', body: blob }); // ArrayBuffer 直传,需要自己声明类型 await fetch('/api/upload', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: new Uint8Array(buffer), });Blob和ArrayBuffer这两条路上有个隐蔽陷阱:Content-Length是浏览器算的,但如果你在外面套了一层压缩或者代理做了转换,实际发送的字节数可能和原始数据不一致。这不影响请求本身,但会影响到服务端做的大小校验。更稳妥的做法是把大小信息放在自定义头里,服务端读头判断,别依赖它自己重新算一遍。
至于ReadableStream,浏览器端支持的实现还比较新,而且一旦用了流式body,请求的重试就变得不可能了——流只能读一次,读完就没了。所以流式上传的重试逻辑必须放在更上层,靠重新构造流来实现,而不是靠重发同一个请求对象。
注意:
body传流时,某些运行时会强制要求配置duplex: 'half',不写会直接抛出类型错误。这个字段目前的标准名和实现细节在变,跨端写代码时务必先在目标环境里跑一遍最小用例。
2.4 不能放进 body 的东西,以及 keepalive 的硬限制
有几类东西塞不进body,或者塞进去会出问题,值得单独列一下。
GET和HEAD请求不允许带body,这是规范层面的限制,fetch会直接抛TypeError。想用GET传复杂结构,只能编码进查询串或者改用POST。
body只能设置一次。fetch会消耗掉body,同一个BodyInit用在两个请求上,第二个会报"body 已被使用"。要复用,得重新构造,或者用clone()复制请求对象(但复制本身也受流式限制)。
还有keepalive,这个参数很少人用,但用对了能救命。它的作用是让请求在页面卸载之后继续存活,典型场景是用户关页面时上报埋点。用了keepalive: true之后,请求体的总大小被限制在 64KB 以内,超了就抛错。所以埋点上报不能把整个页面状态塞进去,得先裁剪。
window.addEventListener('pagehide', () => { fetch('/api/analytics/leave', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page: location.pathname, ts: Date.now() }), keepalive: true, }); });这里顺带说一句pagehide优于beforeunload和unload:后两者在移动端和部分浏览器的页面生命周期里不可靠,pagehide是更稳定的时机。埋点这类"发出去就行、不要响应"的请求,配合keepalive才算完整。
3. 从零封装一个能上生产的 POST 工具
3.1 最小可用骨架,先把参数面设计清楚
直接每次都手写fetch调用,代码会迅速失控:错误处理重复、超时各写各的、Content-Type到处漏。所以封装是必须的,但封装的关键不是省代码,是把参数面设计对。
我自己的封装遵循三条原则:默认值要覆盖 90% 的场景,扩展点要留足,错误输出要能直接定位问题。参数设计大概是这样:
/** * @param {string} url * @param {object} data 业务数据,默认按 JSON 发送 * @param {object} options * - timeout: 超时毫秒数,默认 15000 * - headers: 追加头 * - bodyType: 'json' | 'form' | 'formdata' | 'raw' * - credentials: 默认 'same-origin' * - retry: 重试次数,默认 0 * - signal: 外部取消信号 */ async function post(url, data, options = {}) { const { timeout = 15000, headers = {}, bodyType = 'json', credentials = 'same-origin', signal: outerSignal, ...rest } = options; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); // 外部 signal 与内部超时 signal 合并 if (outerSignal) { if (outerSignal.aborted) controller.abort(); else outerSignal.addEventListener('abort', () => controller.abort(), { once: true }); } const { body, defaultHeaders } = buildBody(data, bodyType); try { const res = await fetch(url, { method: 'POST', credentials, headers: { Accept: 'application/json', ...defaultHeaders, ...headers }, body, signal: controller.signal, ...rest, }); return res; } finally { clearTimeout(timer); } } function buildBody(data, bodyType) { if (bodyType === 'json') { return { body: JSON.stringify(data ?? {}), defaultHeaders: { 'Content-Type': 'application/json' }, }; } if (bodyType === 'formdata') { return { body: data, defaultHeaders: {} }; // 交还给浏览器 } if (bodyType === 'form') { const p = new URLSearchParams(); Object.entries(data ?? {}).forEach(([k, v]) => { if (v != null) p.append(k, String(v)); }); return { body: p, defaultHeaders: {} }; } return { body: data, defaultHeaders: {} }; // raw }这个骨架里有两处值得说。
一是finally里clearTimeout。很多人把它写在catch里,一旦请求正常返回就直接跳过清理,定时器还挂着,页面上的定时器越积越多,长连接页面跑久了会明显卡顿。放finally里才是正确位置。
二是外部signal的合并。业务层可能需要在路由切换时批量取消请求,此时它会传一个自己的AbortController;而超时又是内部逻辑。两个信号必须同时生效,所以要么手动合并,要么用较新实现里的AbortSignal.any([...])。手动合并时记得加{ once: true },避免重复触发和内存泄漏。
3.2 超时、取消与重试:三个动作的上限和代价
超时好理解,重点是取值。我的一般策略是:普通查询接口 8 到 10 秒,写操作 15 秒,文件上传按大小估算,公式大概是"基础 10 秒 + 每 MB 2 秒",比如 10MB 的文件给 30 秒。这个估算不是拍脑袋,是考虑到弱网环境下实际带宽可能只有标称值的十分之一,留足冗余比反复调值省心。
取消的关键在于区分"谁取消的"。超时取消和用户主动取消,在错误处理里应该走不同分支:超时值得提示"网络较慢,请重试",用户主动取消则应该静默丢弃,因为这是预期行为,弹个错误提示反而打扰人。区分方式是用controller.abort(reason)传入不同原因,再在catch里判断:
const controller = new AbortController(); const timer = setTimeout( () => controller.abort(new DOMException('请求超时', 'TimeoutError')), 15000 ); try { await fetch(url, { method: 'POST', signal: controller.signal /* ... */ }); } catch (err) { if (err.name === 'TimeoutError') { // 超时分支 } else if (err.name === 'AbortError') { // 用户主动取消分支,静默 } else { // 真正的网络错误 } } finally { clearTimeout(timer); }abort(reason)这个能力在部分实现里可能不完整,兜底方案是自己在外部记一个标记位,catch时查标记,别完全依赖err.name。
重试是三个动作里最容易写出事故的一个。核心原则只有一条:POST 默认不幂等,重试必须有前提。一个创建订单的请求,超时了不一定没成功——可能服务端已经写库,只是响应慢。此时无脑重试,用户就下了两单。所以重试要么只针对明确的幂等接口(查询、更新、删除),要么带上幂等键让服务端去重。
async function postWithRetry(url, data, { retries = 2, baseDelay = 300, idempotencyKey } = {}) { let lastErr; for (let i = 0; i <= retries; i++) { try { const res = await post(url, data, { headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, }); // 只对 5xx 和 429 重试,4xx 重试没有意义 if (res.status >= 500 || res.status === 429) { lastErr = new Error(`HTTP ${res.status}`); } else { return res; } } catch (err) { // 超时和网络错误可重试,主动取消不可重试 if (err.name === 'AbortError') throw err; lastErr = err; } if (i < retries) { const delay = baseDelay * 2 ** i + Math.random() * 100; // 指数退避 + 抖动 await new Promise((r) => setTimeout(r, delay)); } } throw lastErr; }退避里加抖动的理由是避免"重试风暴":服务端刚恢复,所有客户端同时按同一节奏重发,瞬间又被打挂。加个随机偏移,把压力摊平,这是很便宜但很有效的措施。
| 场景 | 是否重试 | 说明 |
|---|---|---|
| 网络断开、DNS 失败 | 是 | 典型的瞬时错误 |
| 请求超时 | 谨慎 | 服务端可能已处理,需幂等保障 |
| HTTP 500 / 502 / 503 | 是 | 服务端临时故障 |
| HTTP 429 | 是 | 需遵循Retry-After头 |
| HTTP 400 / 401 / 403 / 404 / 422 | 否 | 参数或权限问题,重试无意义 |
| 用户主动取消 | 否 | 属于预期行为 |
3.3 错误分层:网络错误、HTTP 错误、业务错误要分开处理
fetch最反直觉的一点是:它只在网络层失败时 reject,HTTP 4xx 和 5xx 一律 resolve。也就是说,服务端返回 500,你的await fetch(...)会正常返回一个Response对象,status是 500,而不是抛异常。这是无数"请求成功但数据是空的"问题的根源。
所以判断成功必须两步:先看res.ok(等价于status在 200 到 299 之间),再看业务体里的业务码。
const res = await post('/api/order/create', payload); // 第一层:HTTP 层 if (!res.ok) { const text = await res.text().catch(() => ''); throw new HttpError(res.status, text); } // 第二层:业务层 const json = await res.json(); if (json.code !== 0) { throw new BizError(json.code, json.message); } return json.data;这里有个细节必须强调:res.text()和res.json()都会消耗body,一个Response只能读一次。所以错误处理里先text()拿原文,就不能再json()。更稳的写法是先clone()一份再读:
const copy = res.clone(); try { return await res.json(); } catch { const raw = await copy.text(); throw new Error(`响应不是合法 JSON:${raw.slice(0, 200)}`); }还有一种特殊情况:204 No Content。这种响应没有 body,你调res.json()会直接抛解析错误。所以封装里对status === 204或者Content-Length: 0要单独处理,返回null而不是硬解析。
三层错误的分工我一般是这么定的:网络层错误提示"网络异常,请检查连接",附带重试按钮;HTTP 层按状态码给出对应文案,401 跳登录、403 提示无权限、413 提示文件过大、429 提示操作过快;业务层直接用服务端返回的message,因为那通常是最贴合场景的文案。混在一起处理,用户看到的永远是"请求失败",运营和客服会被问爆。
4. 跨域、凭证与预检:POST 最容易翻车的三个地方
4.1 什么时候会触发预检,什么情况下不会
跨域请求里有个"简单请求"的概念,符合全部条件的请求不走预检,直接发。条件大致是:方法是GET、HEAD或POST之一;Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain三者之一;没有自定义头。
看清楚这个列表就会发现:用fetch发 JSON 的 POST,一定触发预检。因为application/json不在白名单里。这是常态,不是问题,别一看到网络面板里多出一个OPTIONS就慌。
预检的流程是:浏览器先发一个OPTIONS请求,带上Origin、Access-Control-Request-Method、Access-Control-Request-Headers,服务端必须正确响应,浏览器才继续发真正的 POST。常见失败原因有这么几个:
服务端没实现OPTIONS路由,直接返回 405;服务端的跨域中间件只处理了GET和POST,漏了OPTIONS;Access-Control-Allow-Headers里没有列出你实际用到的自定义头,比如X-Trace-Id;预检响应没有Access-Control-Max-Age,导致每个请求都要多走一次往返。
最后一件事影响很实在:没有Max-Age,每个 POST 前面都挂一个OPTIONS,端到端延迟直接翻倍。合理值是几十秒到几分钟,太长会导致配置变更后客户端迟迟不生效。
| 你的 POST 写法 | 是否预检 | 备注 |
|---|---|---|
body为URLSearchParams,无自定义头 | 否 | 简单请求,最快 |
body为FormData,无自定义头 | 否 | 简单请求 |
Content-Type: application/json | 是 | 最常见的预检来源 |
任意自定义头(如X-Token) | 是 | 头名会被列进预检 |
credentials: 'include' | 是 | 且服务端不允许* |
4.2 credentials 与 Access-Control-Allow-Origin 的死结
这是跨域里最容易卡住半天的地方,我把规则摊开讲。
当credentials: 'include'时,浏览器会带上 Cookie。但规范要求此时服务端返回的Access-Control-Allow-Origin必须是确切的来源地址,不能是通配符*,同时还必须带Access-Control-Allow-Credentials: true。这两条必须同时满足,缺一条浏览器就把响应丢掉。
很多人踩的坑是:本地开发时前端和后端不同源,一开始没带凭证一切正常,登录功能上线后突然全部跨域报错。原因就是登录后要带 Cookie 了,credentials从same-origin改成了include,而服务端还在用通配符返回。改法不是在前端折腾,是让服务端读取请求的Origin,校验通过后原样回填。
还有一个反向的坑:服务端为了"兼容所有环境",把Allow-Origin设成了反射Origin头的值,同时又允许了Credentials。这实际上等于允许任何来源携带凭证访问,是非常危险的做法,等于把身份认证的防护整个拆掉。正确姿势是维护一份可信来源白名单。
// 前端:跨源带凭证 await fetch('https://api.example.internal/user/profile', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'refresh' }), });// 服务端示意(伪代码):白名单校验后回填,而不是反射任意来源 const origin = req.headers.origin; if (TRUSTED_ORIGINS.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Vary', 'Origin'); } res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Idempotency-Key,X-Trace-Id'); res.setHeader('Access-Control-Max-Age', '600'); if (req.method === 'OPTIONS') return res.status(204).end();Vary: Origin那一行很容易被忽略。如果服务端前面有 CDN 或者缓存层,没有Vary会导致缓存把 A 域的响应喂给 B 域,出现诡异的"某些用户跨域失败"。
4.3 服务端要配合的清单,少一条都不行
把跨域这几件事整理成一张可以对着改的清单,联调时直接甩给后端:
| 检查项 | 要求 | 缺失后果 |
|---|---|---|
Allow-Origin | 精确来源,不用*(带凭证时) | 响应被浏览器丢弃 |
Allow-Credentials | 带凭证时必须是true | Cookie 不生效 |
Allow-Methods | 包含POST和OPTIONS | 预检 405 |
Allow-Headers | 覆盖所有自定义头 | 预检失败 |
Max-Age | 建议 300 到 600 秒 | 每个请求多一次往返 |
Vary: Origin | 有缓存层时必加 | 缓存串源 |
OPTIONS返回 2xx | 建议 204 | 预检整体失败 |
还有一个常被忽略的点:错误响应也要带跨域头。有些服务端框架只在正常处理链路上加了跨域中间件,一抛异常走进全局错误处理器,跨域头就丢了。结果前端看到的是"跨域错误",实际根因是服务端 500。这种问题排查起来极其折磨,因为控制台只告诉你跨域。
5. 常见问题速查与排查实录
5.1 症状到根因的对照表
下面这张表是我这几年攒下来的,基本覆盖了 POST 请求 80% 的"看起来莫名其妙"的问题。排查顺序建议从下往上看:先确认请求有没有发出去,再看响应有没有回来,最后看内容对不对。
| 症状 | 大概率原因 | 处理方向 |
|---|---|---|
| 415 Unsupported Media Type | Content-Type与 body 实际类型不符 | 对齐头和体,或删掉手写的头 |
| 400 且提示 JSON 解析失败 | 传了对象而非JSON.stringify后的字符串 | 检查body类型 |
| 服务端收不到文件 | FormData的Content-Type被手写覆盖 | 删掉手动设置的头 |
response.ok为 true 但数据为空 | 状态 204 却调了json() | 加 204 / 空 body 判断 |
| 报 "body stream already read" | 同一个Response读了两次 | 用clone() |
| 跨域报错,但接口直接访问正常 | 预检失败或缺Allow-Credentials | 查OPTIONS的响应头 |
| 带 Cookie 的请求登录态丢失 | credentials是same-origin而非include | 改凭证策略并同步服务端 |
| 请求偶发失败,重试就好 | 没有超时控制,或网络抖动 | 加超时 + 有限重试 |
| 页面关闭前的埋点丢失 | 没用keepalive | 加keepalive: true,控制体积 |
| 中文变成乱码 | URLSearchParams场景下手写头丢了 charset | 不写头,交给浏览器 |
| 上传进度拿不到 | fetch不支持上传进度 | 换 XHR 或分片上传 |
| 服务端收到的文件名是 "blob" | FormData没传第三个参数 | append补文件名 |
5.2 三个踩过的坑,都是文档里不会写的
第一个坑,是"预检通过但正式请求带不上 Cookie"。现象很怪:OPTIONS请求一切正常,POST也在网络面板里显示 200,但服务端日志里就是没有登录用户。查了很久才发现,服务端在处理OPTIONS时返回了Allow-Credentials: true,但正式 POST 的响应里漏了——因为这两条走的是不同的代码路径,一条在跨域中间件,一条在业务处理器里手写的头。结论很朴素:跨域头应该由统一中间件负责,不要在业务代码里手写。
第二个坑,是"大文件上传偶发 413"。100MB 以内都正常,超过就打回。一开始怀疑是前端分片没做,后来抓包发现请求根本没完整发出去,是网关层的体积限制。这个坑的价值在于:413 是网关返回的,不是你的业务服务返回的,所以你在业务日志里永远查不到,只能看网关日志。遇到 413,先确认限制在哪一层,改代码没用。
第三个坑,是"进度条走到 99% 卡住"。做了一个基于ReadableStream的下载进度,累计读取字节数和Content-Length对比算百分比。问题是有压缩的时候,Content-Length是压缩后的字节数,reader读出来的是解压后的字节数,两个数根本不是一个维度,进度自然算不准,还会超过 100%。处理方式有两种:要么让服务端在压缩后仍然返回准确的原始长度(放在自定义头里),要么用"未知总长度"模式,只显示已下载量而不显示百分比。我后来选了后者,用户体验反而更稳,因为不会出现数字回跳。
5.3 怎么在不依赖控制台的情况下验证请求
控制台是首选,但有些场景它不够用:比如页面在卸载瞬间发的keepalive请求,控制台根本来不及记录;再比如跨域失败时,面板里只显示CORS error,看不到服务端实际返回了什么。
第一个办法是用curl复现。把浏览器请求的 URL、方法、头、体照着抄一遍,去掉浏览器特有的部分,直接打服务端。这一招能立刻区分"是浏览器拦的"还是"服务端返回的"——如果curl能通、浏览器不通,问题 100% 在跨域配置上。
第二个办法是在fetch外面套一层日志中间件,把请求耗时、状态码、响应体大小记下来。这比控制台有用,因为它是结构化的,能聚合、能统计。
async function tracedPost(url, data, options) { const start = performance.now(); try { const res = await post(url, data, options); console.info('[POST]', url, { status: res.status, ms: Math.round(performance.now() - start), size: res.headers.get('content-length'), }); return res; } catch (err) { console.error('[POST-FAIL]', url, { name: err.name, message: err.message, ms: Math.round(performance.now() - start), }); throw err; } }第三个办法是针对keepalive请求:用一个服务端的接收端点专门记录,页面上直接打开这个端点的日志页面,比在浏览器里找快得多。页面卸载类的请求,本质上是"发出去就不管了",所以验收标准应该是"服务端收到了没有",而不是"前端代码跑没跑通"。
最后再分享一个我在实际项目里坚持的习惯:所有 POST 请求的封装都会强制要求传一个bizTag字段,取值是"模块名.动作名"这样的短标识,最终拼进X-Trace-Id头里。上线之后排查问题时,拿着这个标识去服务端日志里 grep,能一眼看到某个操作链路上所有的请求。这个成本极低,但它把"猜测哪里出错"变成了"定位哪里出错",值回票价。