做小程序开发这几年,被 wx.request 虐过不少次,其中“微信小程序 put 请求 form-data 参数”算是很有代表性的一个坑。前两天项目里要更新用户资料,后端接口定的是 PUT /api/user/profile,请求体要求 form-data,里面既有 nickname 这种普通文本字段,又要带一个头像文件。一开始我按浏览器的思维,直接在 data 里放对象,再把 header 的 content-type 写成 multipart/form-data,结果后端死活解析不到参数,返回 400 或者空 body。后来手动拼 multipart 请求体,绕开小程序自动处理 header 的坑,才彻底解决问题。
这篇文章就把这段踩坑经历、最终方案和排查思路完整记录下来。如果你正在做小程序前后端联调,或者被 PUT + form-data 折磨过,这篇应该能帮你少走不少弯路。
1. 为什么小程序里 PUT + form-data 会这么麻烦
1.1 什么场景需要 PUT + form-data
后端接口设计里,PUT 通常表示“更新一个资源”。比如更新用户资料、更新文章内容、替换配置文件、覆盖某个云存储对象,这些都是典型的 PUT 语义。很多团队约定俗成地要求 PUT 请求的 body 格式也用 form-data,尤其是请求里既包含文本字段又包含文件字段的时候,form-data 几乎是默认选择。
举一个真实场景:用户在个人中心编辑资料,需要提交昵称、简介、头像文件。接口定义可能是:
code复制PUT /api/v1/user/profile
Content-Type: multipart/form-data
参数:nickname, bio, avatar(file)
如果后端老系统或者第三方平台接口已经定死了这种格式,前端就得配合。这个需求听起来平淡无奇,但你在小程序里如果照搬浏览器那套 new FormData() 的思路,立刻就会碰壁,因为小程序运行环境里没有 FormData 对象。这也是很多新人第一反应“浏览器能发、小程序怎么不能发”的原因——两边压根不是一个运行时。
1.2 微信小程序的默认行为:wx.request 的“坑”在哪
wx.request 是小程序最核心的网络 API。文档里写了 method 支持 OPTIONS、GET、HEAD、POST、PUT、DELETE、TRACE、CONNECT,所以 PUT 本身是没问题的,问题出在请求体上。
wx.request 的 data 参数支持三种类型:string、object、ArrayBuffer。当你传一个 object 时,小程序会根据 header 里的 content-type 做序列化:默认是 application/json,就走 JSON.stringify;如果设置成 application/x-www-form-urlencoded,会转成 key=value&key2=value2 这种查询串。但当你把 content-type 设置成 multipart/form-data 时,小程序不会帮你把 object 序列化成 multipart 结构的 body,它只会改 header。
更坑的地方在于,如果你不手动指定 boundary,小程序在请求头里会自动补一个随机 boundary。看起来请求头挺完整,但 body 里根本没有对应的 boundary 分隔内容,后端拿这个 boundary 去切分 body,自然切不出来。这就好比你给快递包裹贴了个“内有附件”的单子,但里面其实啥都没有——光有提示,没有实体。
1.3 form-data 和 JSON 到底有什么区别
要把这个问题讲透,得先理解 form-data 和 JSON 的本质差别。
JSON 是纯文本,整个请求体就是一段字符串,后端拿到后整体解析,结构清晰、体积相对可控,在前后端接口中应用最广。但 JSON 不适合直接承载文件二进制,虽然理论上可以 base64,但体积膨胀和解析开销都比较大,而且容易把接口设计搞得很难看。
form-data(即 multipart/form-data)则是一种“多段混合”的格式。它用 boundary 作为分隔线,把每个字段拆成独立的一段,每段自带一个小的头部信息,标明字段名、文件名、Content-Type 等。文本和二进制都可以往里面塞,所以它是表单上传文件时最常用的编码方式。
用一个常见的类比:JSON 像是把几份材料装进同一个信封,收件人打开信封就能看到全部内容;form-data 则像一份多章节的文档,每一章用彩色隔页纸分隔,隔页纸上写着章节名,最后一页写个“完”。后端解析 form-data,其实就是在按隔页纸(boundary)把文档拆开,同时读取每一章的内容。理解了这一点,你就能明白为什么 boundary 必须前后端一致——隔页纸的名字都对不上,拆文档的人自然没法干活。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心方案:手写 multipart/form-data 请求体
2.1 请求体的真实结构长什么样
如果你抓包看过浏览器提交的 form-data,会发现它长这样:
code复制------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n
Content-Disposition: form-data; name="username"\r\n
\r\n
zhangsan\r\n
------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n
Content-Disposition: form-data; name="avatar"; filename="avatar.png"\r\n
Content-Type: image/png\r\n
\r\n
<这里是文件的二进制数据>\r\n
------WebKitFormBoundary7MA4YWxkTrZu0gW--\r\n
有几个细节特别容易被忽略,我逐个说一下:
- 每行结尾必须是
\r\n,也就是回车换行。这不是随便写的,multipart 协议规定使用 CRLF 作为换行符。少一个\r\n都可能导致解析错位,后端看到的字段就会串行或者直接报错。 - 每个字段之间用
--boundary分隔,boundary 前后不能有多余空格,否则后端按字符串匹配 boundary 时会匹配不上。 - 普通文本字段部分是
Content-Disposition: form-data; name="字段名",然后空一行,再写字段值。 - 文件字段需要在 Content-Disposition 里额外加上
filename="文件名",还要有一行Content-Type: 文件类型,告诉后端这个字段是文件还是文本。 - 整个 body 的结尾是
--boundary--,表示“到这里结束”。如果没有这个结尾标记,后端可能会认为请求体不完整,超时等待后面的数据。
当你手动拼请求体时,实际上就是在按这个协议组装字符串,而不是随便拼几段文本就完事。协议是死的,照着格式来就不会出错。
2.2 关键一步:Content-Type 里的 boundary 不能被覆盖
先说结论:header 里的 content-type 一定要带上完整的 boundary,并且和 body 里的 boundary 保持完全一致。
为什么这么强调?因为在小程序里,如果你只写:
js复制header: {
'content-type': 'multipart/form-data'
}
小程序底层在发送请求时,会尝试帮你补一个 boundary。这个自动补的 boundary 是随机的,而你在 body 里手动拼的是另一个值。两边对不上,后端拿到请求后按 header 里的 boundary 去切 body,切出来的字段就是乱的,结果要么是参数为空,要么直接报错 400,要么后端抛异常。
正确写法是:
js复制const boundary = '----CustomBoundary' + Date.now();
header: {
'content-type': `multipart/form-data; boundary=${boundary}`
}
这样把 boundary 明确写死,小程序就不会再自动追加,body 和 header 就能对上。这个细节我特别拿出来讲,是因为网上不少教程都只写了“把 content-type 改成 multipart/form-data”,完全没提 boundary 的事,结果很多人照着做就是不行。
不同基础库版本对这个问题的处理可能有细微差别,我在开发者工具和真机上分别验证过,最稳妥的做法就是手动指定完整 content-type。如果你非要试探哪个版本不写 boundary 也能过,浪费的时间足够你把整套方案写完。
2.3 手写 body 的完整代码
这里给出一个可以复用的拼接函数。基础版先处理普通文本字段:
javascript复制function buildFormData(params) {
const boundary = '----CustomBoundary' + Date.now();
let body = '';
for (const key in params) {
const value = params[key];
body += `--${boundary}\r\n`;
body += `Content-Disposition: form-data; name="${key}"\r\n\r\n`;
body += `${value}\r\n`;
}
body += `--${boundary}--\r\n`;
return { boundary, body };
}
使用的时候:
javascript复制const { boundary, body } = buildFormData({
nickname: '张三',
bio: '写代码的小学生'
});
wx.request({
url: 'https://api.example.com/user/profile',
method: 'PUT',
data: body,
header: {
'content-type': `multipart/form-data; boundary=${boundary}`
},
success(res) {
console.log('请求成功', res.data);
},
fail(err) {
console.error('请求失败', err);
}
});
注意 data 这里传的是字符串 body,不是对象。如果你传对象,小程序会按 JSON 去序列化,那前面拼的 multipart 结构就白费了。字符串原样发送,后端才能拿到完整的 multipart 结构。这是新手最容易犯的错误之一:函数写好了、boundary 也写死了,回头 data 还是传对象,结果请求发出去变成了 JSON,后端自然解析不到 form-data 字段。
3. 实操过程:从普通参数到带文件上传
3.1 基础版本:只传普通表单字段的 PUT 请求
先用一个最简单的场景做验证:不传文件,只传 nickname 和 bio 两个文本字段。
把上面的 buildFormData 跑起来,得到 body 字符串。我习惯先把 body 打印出来看一眼,确认格式没问题再发请求。拼接结果大概是:
code复制------CustomBoundary1700000000000\r\n
Content-Disposition: form-data; name="nickname"\r\n
\r\n
张三\r\n
------CustomBoundary1700000000000\r\n
Content-Disposition: form-data; name="bio"\r\n
\r\n
写代码的小学生\r\n
------CustomBoundary1700000000000--\r\n
后端按 multipart 解析时,会拿到 nickname=张三、bio=写代码的小学生。这个版本可以作为最小可运行 demo,先验证接口通不通,再继续扩展文件字段。
实测下来,这一步能跑通,说明你对 boundary 和 header 的处理已经对了,后面的文件场景只是在同样的基础上加一段二进制内容。如果你的接口在这个阶段就失败,先别急着往后走,把 header 和后端日志对齐,大概率是 boundary 被覆盖的问题,回去看 2.2 节。
3.2 进阶版本:PUT 请求中携带文件内容
文件字段比文本字段麻烦一些,因为小程序里常见的文件读取结果是 ArrayBuffer,不能直接往字符串里拼。解决的思路是:把字符串和 ArrayBuffer 都转成字节数组,再按顺序拼起来,最后塞给 wx.request 的 data。
javascript复制function stringToUint8Array(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
bytes.push(str.charCodeAt(i) & 0xff);
}
return new Uint8Array(bytes);
}
function concatUint8Arrays(arrays) {
let totalLength = 0;
arrays.forEach(arr => totalLength += arr.length);
const result = new Uint8Array(totalLength);
let offset = 0;
arrays.forEach(arr => {
result.set(arr, offset);
offset += arr.length;
});
return result;
}
function buildFormDataWithFile(params, fileFieldName, fileBuffer, fileName, fileType) {
const boundary = '----CustomBoundary' + Date.now();
const chunks = [];
for (const key in params) {
const headerStr = `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${params[key]}\r\n`;
chunks.push(stringToUint8Array(headerStr));
}
const fileHeaderStr = `--${boundary}\r\nContent-Disposition: form-data; name="${fileFieldName}"; filename="${fileName}"\r\nContent-Type: ${fileType}\r\n\r\n`;
chunks.push(stringToUint8Array(fileHeaderStr));
chunks.push(new Uint8Array(fileBuffer));
chunks.push(stringToUint8Array(`\r\n--${boundary}--\r\n`));
return {
boundary,
body: concatUint8Arrays(chunks).buffer
};
}
读取文件可以用:
javascript复制const fs = wx.getFileSystemManager();
fs.readFile({
filePath: tempFilePath,
success(res) {
const { boundary, body } = buildFormDataWithFile(
{ nickname: '张三' },
'avatar',
res.data,
'avatar.png',
'image/png'
);
wx.request({
url: 'https://api.example.com/user/profile',
method: 'PUT',
data: body,
header: {
'content-type': `multipart/form-data; boundary=${boundary}`
},
success(res) {
console.log('上传成功', res.data);
}
});
}
});
这里有两个容易出错的地方。一是 res.data 在不同基础库版本里可能是 ArrayBuffer,也可能是字符串,最好自己判断一下,或者用 readFile 的 encoding 参数明确指定后处理。二是接口如果没放在合法域名里,真机调试时记得在开发者工具里勾选“不校验合法域名”,否则请求会被拦掉。
这段代码看起来有点繁琐,但它是把一个已知长度的二进制序列正确地嵌进 multipart body 的通用做法。字符串部分转 Uint8Array,是为了跟文件内容统一成字节数组,这样拼接顺序才不会错。
3.3 后端如何接收:Node.js + koa-body 配置示例
前端拼了半天,后端如果不会解析,等于白干。这里以 Node.js 的 Koa 框架为例,最常见的处理方式是使用 koa-body 中间件。
koa-body 默认会解析 application/json、application/x-www-form-urlencoded、multipart/form-data,但有一个坑:默认只处理 POST 请求。如果前端是 PUT,需要在配置里显式把 PUT 加进去。
javascript复制const Koa = require('koa');
const koaBody = require('koa-body');
const app = new Koa();
app.use(koaBody({
multipart: true,
includeUnparsed: true,
patchNode: true,
methods: ['POST', 'PUT', 'PATCH']
}));
app.use(async (ctx) => {
if (ctx.method === 'PUT' && ctx.path === '/user/profile') {
const body = ctx.request.body;
console.log('nickname:', body.nickname);
console.log('bio:', body.bio);
if (ctx.request.files && ctx.request.files.avatar) {
console.log('avatar file:', ctx.request.files.avatar.name);
}
ctx.body = { code: 0, message: 'success' };
}
});
注意 methods 数组里如果没有 'PUT',koa-body 就不会解析 multipart body,ctx.request.body 可能是空对象,文件对象也不存在。这个问题在本地调试时很容易碰到,建议后端同学先打印请求的 content-type 和完整 body,确认前端确实发的是 multipart/form-data,再决定是不是中间件配置的问题。
如果项目用的是 Express + multer,情况类似。multer 本身不限制 HTTP 方法,但要注意把 multer 中间件放在正确的路由上,并且确认前端传的字段名和 multer 里配置的 upload.single('avatar') 一致。字段名对不上,文件一样收不到。
4. 常见问题与排查技巧实录
4.1 后端报错“boundary 不存在”或解析出空对象
这是最常见的报错。现象是:前端明明在 header 里写了 multipart/form-data,body 也按格式拼了,但后端就是解析不到数据,报错信息经常是 “Missing boundary in multipart” 或者 “Multipart: Boundary not found”。
排查思路:先看后端日志里收到的 content-type 是什么。如果后端收到的 content-type 是 multipart/form-data; boundary=----WebKitFormBoundaryxxxxx,但这个 boundary 跟你 body 里的不一致,说明小程序自动追加了 boundary。解决方法是 header 里写死完整 content-type,就是 2.2 节说的那招。
还有一个小技巧:在开发者工具里打开 Network 面板,直接看请求预览。你会发现有些版本的开发者工具会显示微信自动加的 boundary,这时候再回头改代码就有方向了。你还能顺便确认请求体是不是以 -- 开头、以 --boundary-- 结尾,格式对不对一眼就能看出来。
4.2 中文参数乱码
如果后端收到的中文变成乱码,大概率是编码处理不一致。小程序端发送的是 UTF-8 编码字符串,后端如果用非 UTF-8 解码,就会出现乱码。
解决方式是前后端统一使用 UTF-8,另外建议中文文本字段在拼 body 之前先做 encodeURIComponent 编码,后端再做 decodeURIComponent 解码。这样即使中间有代理或网关做了编码转换,也不容易出错。
我在实际项目里遇到过一次奇怪的情况:nickname 在模拟器里正常,在真机上就乱码。后来发现真机上那台手机的输入法返回了不规范字符,经过 encodeURIComponent 转码后反而把问题暴露出来,后端拿到转码后的内容再解一次码,数据就正常了。所以统一编码不只是保险,也是排查方向的一环。
4.3 开发者工具正常,真机却请求失败
开发者工具用的是电脑网络,真机用的是手机网络,两者的请求行为并不完全一致。比如开发者工具自动补 boundary 的方式和真机可能不一样,域名白名单校验在开发者工具里可以关闭,在真机上必须配置合法域名。
我建议凡是涉及 multipart 的请求,最终都要在真机上过一遍。如果真机返回 “url not in domain list” 这类错误,去小程序管理后台把请求域名加到白名单。开发阶段可以把“不校验合法域名”选项打开临时调试,路径是:详情 -> 本地设置 -> 勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。
另外真机调试时,如果用的是 wx.getFileSystemManager().readFile 读取临时文件路径,要注意这个文件在小程序退出后可能被清掉。上传前先确认文件确实存在,读取失败时要给用户一个明确的提示,而不是默默发一个空请求。
4.4 快速定位:后端日志 + 抓包工具
排查这类问题最高效的方式,是前后端配合看原始请求。后端把请求头、请求体原始内容打出来,前端在 Network 面板看请求预览,两相对照就知道问题在哪。
如果没有后端日志权限,也可以自己抓包。开发者工具自带的 Network 面板对大多数情况够用了;如果要看更底层的数据,可以用系统代理抓包,但抓包工具需要安装 CA 证书,并且某些基础库版本下小程序会做证书校验,抓不到是正常的。更简单的办法是把后端服务地址指向本地,在本地接口里打日志,串个全流程。
我遇到过不止一次这样的情况:前端开发说“我明明发了 form-data”,后端开发说“我这里收到的就是空 body”。两边都没说谎,但就是找不到原因。最后把原始请求体打印出来才发现,前端发的 content-type 被中间层改成 application/x-www-form-urlencoded 了,body 还是 multipart 格式,后端当然解析不到。这类问题靠猜是猜不出来的,必须落到日志和抓包数据上。
以下是排查速查表,建议收藏:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 后端提示 Missing boundary | header 的 boundary 与 body 不一致 | header 写死完整 content-type |
| 后端 body 为空对象 | koa-body 未处理 PUT 方法 | methods 配置加上 PUT |
| 中文乱码 | 编码不一致 | 统一 UTF-8,字段先 encodeURIComponent |
| 真机请求失败 | 域名未配置白名单 | 管理后台添加 request 合法域名 |
| 读取文件返回字符串而非 ArrayBuffer | readFile 参数问题 | 指定 encoding 或判断类型后处理 |
| 开发者工具正常真机异常 | 基础库版本差异 | 以真机为准,必要时升级基础库 |
最后再分享一个我的个人体会:手动拼 multipart 请求体看起来确实有点“原始”,不如后端把接口改成 JSON 方便,但遇到公网接口、第三方系统接口以及老系统接口时,前端往往没有权利改协议,这时候手写 body 反而是最稳妥可控的方案。而且一旦把拼接函数封装好,不管是 POST 还是 PUT,都能直接复用。如果你们后端愿意配合,我还是更建议把文件上传和参数提交分开处理,文件用 wx.uploadFile 走 POST,业务参数用普通 JSON 请求,开发效率和可维护性都会高很多。
