☰
axios升级后Content-Type从JSON变multipart?排查与解决方案
2026/9/28 7:44:52 网站建设 项目流程

1. 先看报文再排查:升级 axios 后,JSON 怎么变成了 multipart?

去年我手头的老后台项目做依赖升级,axios 从 0.21.x 升到 1.6.x。发版当天运营群里就炸了:一批老接口开始报 400,后端同事捞日志说“请求体解析失败,拿不到参数”。我第一反应是后端改动,但 git log 翻下来后端一行没动。后来打开浏览器 Network 面板一对比才发现,升级前这些接口发的报文是 application/json,升级后变成了 multipart/form-data,后端按 JSON 来解析,能不炸吗?

1.1 现象复盘:同样的代码,不同的报文

当时那个接口的功能很简单,前端传一个对象给后端保存配置。老代码写的是:

axios.post('/api/config/save', { name: '测试配置', type: 2, options: { retry: 3 } })

升级前浏览器 Network 面板里,Request Headers 的 Content-Type 是 application/json,Payload 是一串 JSON 字符串。升级后,同样一段代码,Content-Type 变成了 multipart/form-data; boundary=----WebKitFormBoundaryxxxx,Payload 变成长长一串带分隔符的混合内容。

你可能会问:axios 不是会自动把普通对象转成 JSON 吗?确实会,但只针对“普通对象”。问题往往出在请求被拦截器或二次封装代码处理过,又或者新版本对数据类型判断的逻辑和旧版不一样。很多老项目并不是直接调 axios.post,而是在封装层里做了统一处理,比如把所有参数包成 FormData、用 qs 序列化、或者手动设置全局 headers。这些逻辑在旧版 axios 上正好能跑,升到新版后就被“重新解释”了。

这个现象最有迷惑性的地方在于:代码一个字符都没改,但线上行为变了。团队成员第一反应都是“后端是不是偷偷改接口了”“运维是不是换了网关”,没人会第一时间怀疑 axios 本身。我那次排查到最后才发现,是封装层里有一段针对旧版本写的“兼容代码”,在 1.x 版本下会强制走 multipart 格式。

1.2 根因判断:升级本身没错,错在默认行为变了

axios 从 0.x 升到 1.x 并不是简单的小版本变化。1.x 是一个大版本重构,默认行为做了不少调整,尤其是对请求体的序列化处理。虽然官方文档强调“API 向后兼容”,但实际使用中,很多对旧版本“隐性行为”有依赖的项目都会踩坑。

具体到 multipart 和 JSON 的切换,核心原因有几个:

  • 新版 axios 对 FormData、URLSearchParams 这类对象的识别更激进。只要检测到 data 是 FormData 或类似结构,就会跳过 JSON 序列化,直接交给浏览器 XHR 发送。
  • 旧版项目里如果有人手动把普通对象塞进 FormData,又设置了其他 Content-Type,新版 axios 会优先按 FormData 的逻辑走 multipart,把手动设置的 JSON 头覆盖掉。
  • 二次封装层如果用了旧版的 transformRequest 写法,比如“先把对象转成 JSON 字符串再设置 header”,新版对 header 的幂等处理逻辑不同,可能导致 header 没设置成功,最后落到 multipart。

提示:遇到升级后接口报错,第一步永远是打开 Network 面板看 Request Headers 和 Payload,对比升级前后的报文差异,而不是先去改代码。报文是客户端和后端之间唯一的沟通语言,后端按照报文头决定怎么拆包。

2. content-type 的“自动选择”背后,到底在做什么?

Content-Type 是 HTTP 协议里一个非常重要的请求头,它告诉服务端:我要以什么格式给你发送数据。对后端来说,这个头决定了解析器怎么处理请求体。可以把它类比成快递包裹外面的面单——面单上写“文件袋”,快递站就按文件袋的方式分拣;面单上写“玻璃制品”,快递站就按易碎品处理。如果面单和实际东西对不上,包裹要么被拒收,要么被拆坏。

2.1 三种主流格式的底层逻辑对比

前端开发中,遇到最多的 Content-Type 有三种:application/json、application/x-www-form-urlencoded、multipart/form-data。它们各自有对应的数据格式和后端解析方式。

Content-Type请求体长什么样序列化方式后端解析方式
application/json{"name":"测试","type":2}JSON.stringify@RequestBody 或 readAsString 后 JSON.parse
application/x-www-form-urlencodedname=测试&type=2URLSearchParams 或 qs.stringify表单解析器,字段平铺
multipart/form-data带 boundary 的多段内容FormData 自动生成multipart 解析器,可处理文件和字段

日常业务里,GET 请求不带请求体,基本上不用纠结。POST 和 PUT 请求一旦携带数据,就必须明确格式。JSON 适合嵌套结构、数组、复杂对象;urlencoded 适合简单键值对,兼容性最好;multipart 适合文件上传,也支持字段和文件混合。

axios 内部的处理逻辑是:在发送请求前,根据 data 的实际类型选择一个“它觉得最合理”的 Content-Type。如果你传普通对象,它会自动 JSON.stringify 并设置 application/json;如果你传 URLSearchParams,它会设置成表单格式;如果你传 FormData,它倾向于不手动设置 Content-Type,让浏览器自己生成带 boundary 的 multipart 头。

2.2 为什么“自动帮你做”反而会坑人

axios 的自动化设计初衷是好的,能让大多数场景开箱即用。但真实项目里,很多接口是历史遗留的,后端解析格式早就写死了。比如某个老接口后端就是按表单格式解析的,前端一直手动构造 URLSearchParams 提交;升级后 axios 突然把它当成 FormData 处理,虽然两者都是表单家族,但 multipart 和 urlencoded 的解析器并不相同,后端自然就挂了。

再比如文件上传场景:有的团队为了省事,在二次封装里写了全局的headers: { 'Content-Type': 'application/json' }。普通请求没问题,但文件上传的时候,axios 检测到 FormData 后,要么覆盖掉这个 header 生成正确的 multipart,要么就出现“手动设置的 Content-Type 覆盖了浏览器自动生成的 boundary”的经典问题——后端拿到一个没有 boundary 的 multipart 头,直接报错。

我后来梳理过,升级后最容易出问题的场景基本是这三类:

  • 代码里手动设置了 Content-Type,但新版本 axios 的自动检测逻辑跑得更快,把你的设置覆盖了。
  • 代码里依赖旧版本“自动把对象转成 JSON”的行为,升级后发现它被当成别的东西序列化。
  • 拦截器里做了参数统一处理(比如把对象塞进 FormData),新版对 FormData 的检测比旧版提前,导致后面的序列化逻辑没执行。

3. 三种收尾方案:从临时止血到二次封装长效改造

排查清楚了,剩下的就是怎么改。我结合那次实际项目经验,整理了三种方案,按改造成本从低到高排列。你可以根据自己项目的情况选,不用一上来就推翻重写。

3.1 方案一:临时止血,显式指定 Content-Type

如果你的项目只依赖几个老接口,不想大动干戈,最直接的方式是在请求配置里显式指定 Content-Type,绕过 axios 的自动检测。

axios.post('/api/config/save', { name: '测试配置', type: 2, options: { retry: 3 } }, { headers: { 'Content-Type': 'application/json' } })

这个方式的好处是改动量小、精确可控。坏处是如果项目里请求非常多,挨个加 header 太琐碎,而且容易遗漏。只适合“先恢复线上服务”的应急阶段。

更稳妥的临时做法是在拦截器里按接口前缀区分。当时我为了快速止血,写了一个简单判断:如果 URL 里包含/api/legacy,就强制走 JSON;否则保持默认。上线后线上问题立刻消失,然后我再慢慢做统一改造。

3.2 方案二:统一二次封装,在拦截器里按类型判断

对于长期维护的项目,我强烈建议做一个 axios 实例的二次封装,把 Content-Type 的决策逻辑收敛到一处。这样以后再有类似的“格式切换”需求,只需要改一个地方,不需要挨个翻业务代码。

下面是我实际项目里沉淀下来的封装模板,覆盖了超时、Token、错误提示、重复请求取消和 Content-Type 自动判断:

// request.js import axios from 'axios'; import qs from 'qs'; const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE || '/api', timeout: 15000 }); // 请求拦截器 service.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } // 判断 data 类型,决定 Content-Type const { data } = config; if (data instanceof FormData) { // FormData 不手动设置 Content-Type,交给浏览器生成 boundary // 这里如果设置反而是错的 return config; } if (typeof data === 'string') { // 已是序列化好的字符串,可能是上层调用者传的 config.headers['Content-Type'] = 'application/x-www-form-urlencoded'; return config; } if (data && typeof data === 'object') { // 普通对象,默认 JSON;但如果调用方指定了 headers.Content-Type,保留调用方设置 if (!config.headers['Content-Type']) { config.headers['Content-Type'] = 'application/json'; config.data = JSON.stringify(data); } } return config; }, (error) => Promise.reject(error)); // 响应拦截器,统一处理错误码 service.interceptors.response.use( (response) => { // 后端正常返回 const res = response.data; if (res.code && res.code !== 0) { // 业务错误 return Promise.reject(new Error(res.message || '业务异常')); } return res; }, (error) => { // HTTP 错误 if (error.response?.status === 401) { // 跳转登录 } return Promise.reject(error); } ); export default service;

这套封装的重点在于:FormData分支一定要最先判断,并且不做任何设置,原样返回。这是很多人踩坑的地方——拦截器里图省事,统一JSON.stringify(data),结果 FormData 被转成了{},文件直接传不上去。

3.3 方案三:multipart 文件上传的标准写法

既然提到了 FormData,顺手把文件上传的正确写法也讲清楚。这可能是使用 axios 时最容易出问题、搜到的答案又最五花八门的场景。

// upload.js import request from './request'; const formData = new FormData(); formData.append('file', file); // file 是 File 对象或 Blob formData.append('scene', 'avatar'); // 附加业务字段 formData.append('extras', JSON.stringify({ // 嵌套对象建议转成 JSON 字符串 crop: { width: 100, height: 100 } })); // 如果有进度条,用 onUploadProgress request.post('/api/upload', formData, { onUploadProgress: (progressEvent) => { const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100); console.log(`上传进度:${percent}%`); }, headers: { // 这里不要手动设置 Content-Type! // 让浏览器自动生成 multipart/form-data; boundary=... } });

标准写法的核心原则是:FormData 的 Content-Type 一定不要手动设置。因为 multipart 格式的请求体里每个段都要用 boundary 分隔,而这个 boundary 是浏览器生成 FormData 时随机生成的,也必须出现在 Content-Type 头里。你一旦手动设置Content-Type: multipart/form-data,就相当于拆掉了 boundary 信息,后端根本没法解析各个字段在哪里结束、下一个字段从哪里开始。

3.4 升级前的接口回归清单

说到底,最省事的办法还是预防。吃了一次亏之后,我养成了一个习惯:升级 axios(或其他 HTTP 库)之前,先抽几个代表性接口做报文基线。不用多,四个就够了。

场景请求代码升级前报文升级后报文是否正常
普通 JSON 提交axios.post('/save', {a:1})application/json应保持相同确认
URLSearchParams 表单传 URLSearchParams表单格式应保持相同确认
FormData 文件上传传 FormDatamultipart应保持相同确认
GET 带查询参数params 对象query string应保持相同确认

这四个场景如果升级后都一致,基本可以放心。但注意,回归测试一定要看 Network 面板的原始报文,不要只看接口返回值正常就觉得没问题——有些后端解析宽容,格式错了也能容忍,但等数据量大了或字段复杂了,隐患才会暴露。

4. 升级后常见问题速查与抓包排查技巧

把这次升级过程中踩过的坑和团队其他成员遇到的问题汇总一下,做成速查表。以后任何人遇到 axios 升级或报文格式问题,先查这张表,大概率能少走弯路。

4.1 常见问题速查表

问题现象根本原因解决方案
接口报 400,日志显示请求体解析失败Content-Type 与请求体格式不匹配,后端按错误解析器处理对比升级前后报文,恢复旧版的 Content-Type
后端拿到不到参数,但请求体有内容后端按 JSON 解析,实际收到的是表单格式显式指定 Content-Type 为 application/json
文件上传报 boundary missing手动设置了 multipart/form-data,导致边界丢失让浏览器自动生成 Content-Type,不手动设置
上传成功但后端文件字段为空FormData 里字段名和后端注解不一致,或用了错误的 key和后端确认字段名,注意大小写
嵌套对象传过去变成 [object Object]表单格式不支持嵌套对象,被强转字符串嵌套对象用 JSON.stringify 转成字符串字段
请求变慢,出现预检请求 OPTIONS升级后新增了自定义 Header(如 Authorization),触发跨域预检确认后端网关已放行 OPTIONS,或减少非必要自定义头
拦截器里设置了 Content-Type 但未生效新版 axios 使用 AxiosHeaders 包装,普通对象设置会被覆盖用 config.headers.set('Content-Type', xxx) 或判断后设置

4.2 抓包排查三步走

遇到类似问题,别急着翻代码,按下面三步走:

第一步,在浏览器 Network 面板找到那个失败的请求,展开 Request Headers,看 Content-Type 到底是什么,再看 Payload 的原始格式。这一步能确定问题到底在“客户端发的格式不对”还是“后端解析不了”。

第二步,如果确认是客户端问题,复制那条请求的 cURL 格式,在命令行里手动改 Content-Type 重新发送,用最快的方式验证后端到底期望什么格式。比如:

curl -X POST http://api.example.com/api/config/save \ -H "Content-Type: application/json" \ -d '{"name":"测试配置","type":2}'

如果这样调通,说明后端没问题,问题就锁定在 axios 封装层或版本行为差异上。如果调不通,再和后端对接口文档,看是字段名问题还是地址问题。

第三步,把请求链路里的每个环节拆开看:调用方传参 → 请求拦截器 → 序列化/转换 → 发送。重点检查拦截器里有没有对 data 做“隐式修改”。我那次最后定位到的问题,就是二次封装里有一段“兼容旧版”:用qs.stringify处理 URLSearchParams,同时设置了表单头。新版 axios 检测到 data 是普通对象后,先转成 JSON,再走拦截器,结果拦截器又强行设成表单格式,两套逻辑互相拉扯,最后报文变成了一个四不像。

4.3 二次封装时的三个避坑建议

最后分享三条我在实操中总结的避坑经验,不针对 axios 版本,而是适用于所有 HTTP 封装层设计。

一是不要全局写死 Content-Type。很多人喜欢在 axios.create 配置里写headers: { 'Content-Type': 'application/json' },省事是省事,但遇到文件上传就成了定时炸弹。正确做法是在请求拦截器里按 data 类型动态判断,没把握就让 axios 自己决定。

二是改造封装层时,先做兼容测试再拆老逻辑。我见过有的同事看到旧代码不顺眼,升级的时候顺手“优化”了封装层,把旧的 transformRequest 逻辑删了。结果升级换来的不是新特性,而是全线接口报错。升级依赖和重构业务代码应该分开做,一次只做一件事。

三是把报文对比做成自动化脚本。我后来写了一个简单的 Node 脚本,用 axios 的旧版本和新版本分别发请求,记录 headers 和 body,再 diff 差异。这样下次升级依赖,几分钟就能扫出哪些接口会受影响,不用人工一个个点。

写在最后:先抓包再动手,永远不要靠猜

这次升级踩坑让我最大的体会是:不管什么库升级,出了兼容性问题,第一件事永远是确认“报文到底变成什么样了”,而不是对着代码猜。浏览器 Network 面板、命令行 curl、代理抓包工具,这些工具用熟了,能帮你省掉大半天排查时间。

如果你正准备升级 axios,我的建议是:升级之前,先花十分钟把项目里的请求分个类,挑出 JSON、表单、文件上传各一个代表接口,把升级前的报文截图存下来。升级之后再发一次,对比差异。如果一致,放心升;如果不一致,这类接口往往就是你最容易踩雷的地方,提前修复成本远低于线上事故再说。

另外一个实际的小技巧:锁版本的时候不要只锁一个大版本号,要精确到 minor。axios 的 1.x 里,个别小版本的默认行为也有调整。用 package.json 里的"axios": "1.6.7"这种写法的团队,明显比用"axios": "^1.0.0"的团队省心得多。剩下的坑,就留着在实际项目里一个个踩吧——踩过一次,下次就知道先看报文了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询