前后端传参这事,看着简单,真上手就各种花式翻车。尤其是涉及文件上传和 form-data 类型传参的时候,新手容易懵,老手也容易在边界问题上栽跟头。我自己带项目这几年,几乎每隔一段时间就能看到同事在群里面问“为什么后端收不到我的文件”“为什么 Content-Type 又是 application/json”。这些问题归根结底,是因为对 FormData 和 multipart/form-data 这套机制只停留在“会用”的层面,没搞懂它到底在传什么、后端又是怎么拆的。
所以这篇文章我打算把 FormData 从 API 特性到实际传输原理,再到前后端联调时最容易踩的坑,完整讲一遍。不管你是刚入门的前端,还是被上传功能折磨过两天的全栈,看完之后应该都能把这部分内容彻底理顺,碰到 form-data 类型传参的报错也能自己定位问题。
1. 先搞明白:为什么会有 form-data 这种传参方式
很多人第一次接触 form-data,是因为要做文件上传。这时候如果后端接口要求 Content-Type 是 multipart/form-data,前端一般就会写一个 FormData 对象往里塞文件,然后丢给请求库发出去。但你要问他 form-data 和 JSON 传参到底差在哪,为什么文件不能直接用 JSON 传,他可能就说不清楚了。
1.1 前端传参三种主流格式的本质区别
日常前后端联调,最常见的三种传参格式分别是 application/json、application/x-www-form-urlencoded 和 multipart/form-data。它们本质上都是 HTTP 请求体里 Body 的编码方式,区别在于 Body 里这段数据的“排版规则”不一样。
- application/json:把整个数据对象序列化成一段 JSON 字符串,键值对结构清晰,支持嵌套对象、数组,是目前纯数据接口的绝对主流。
- application/x-www-form-urlencoded:把键值对拼成 key1=value1&key2=value2 的字符串,Key 和 Value 会做 URL 编码。它天生是扁平的,嵌套对象和数组表达起来很别扭,早期表单页面的默认提交格式。
- multipart/form-data:请求体被切分成多个 Block,每个字段一块,块与块之间用一段随机字符串 boundary 隔开。每块内部可以带自己的 Content-Disposition、Content-Type,所以既能传普通的字符串字段,也能传二进制文件内容。
这里有个很重要的点:urlencoded 和 form-data 虽然不是同一种编码,级别上却是“同辈”——都是 HTML 表单派生出来的编码格式。而 JSON 是后起之秀,因为结构表达能力强,逐渐成了常规接口的首选。可一旦涉及文件,JSON 就有它天生不擅长的地方了。
1.2 文件传输为什么绕不开 form-data
理论上文件也能用 JSON 传,做法就是把文件转成 Base64 字符串,塞进 JSON 的某个字段里,后端收到了再解码。我自己也这么干过,小文件凑合能用,但一旦文件体积上来了就有三个问题:
第一,Base64 编码会让体积膨胀大约 33%。原本 1MB 的文件编码完变成 1.33MB,白白增加带宽消耗。第二,文件需要整体读进内存再转字符串,几百 MB 的大文件前端直接卡死甚至崩溃。第三,后端要额外做 Base64 解码,还容易因为转义、长度限制出幺蛾子。
而 multipart/form-data 不一样,它的二进制块是流式的,文件内容是直接作为原始字节传输,不需要额外编码。浏览器原生通过 FormData 构造这种请求体非常高效,文件多大就走多少流量,还能配合上传进度事件做实时进度条。官方对文件上传的标准建议,以及绝大多数后端框架对文件上传的默认支持,都是 multipart/form-data,所以这不是我们“想选它”,而是它天然就是干这个的。
2. FormData 的核心细节与隐藏行为
FormData 在浏览器里是一个全局对象,专门用来构造 multipart/form-data 格式的请求体。API 本身其实没几个方法,但很多细节藏在使用习惯里,一个不留神就会踩坑。
2.1 方法不多,每个都有讲究
FormData 的实例主要提供这几个方法:append、set、get、getAll、delete、has、keys、values、entries。前六个是增删改查逻辑,后三个是遍历用的。
先说 append 和 set 的区别。append 是“追加”,同一个字段名你可以调用多次,每次追加一个值,最终请求体会出现多个同名 Block。比如多文件上传,你经常这么写:
const formData = new FormData(); files.forEach((file) => { formData.append('files', file); });而 set 是“设置”,如果之前已经有同名字段,它会把之前的全部替换掉。可以理解为 append 是 push,set 是整体赋值。
再看 get 和 getAll。get 只取同名字段的第一个值,getAll 返回同名字段所有值的数组。多文件上传场景里,后端一般希望你把同名字段收集成一个数组,这时候 getAll 在处理响应或者做 FormData 内容调试时就很有用了。
还有个经常被忽略的点:FormData 的值在附加时会自动转成字符串,唯一例外就是 File 和 Blob 对象。也就是说,你 append 一个数字 9527,实际传输的时候它已经被转成了字符串 "9527"。所以前端不需要先 stringify,直接 append 就行,如果 append 了一个对象,你大概率会发现后端拿到的是 "[object Object]"。
2.2 文件字段与普通字段混传的正确姿势
实际项目里,很少有只传一个文件的接口。通常还要带用户 ID、备注、类型这些业务字段。很多人的第一反应是把这些字段和文件分开放,一个 FormData 装文件,一个 JSON 对象放字段,再考虑怎么合并在一起发出去。其实完全不必,FormData 本身就支持普通字段和文件字段共存,但顺序上有讲究。
标准的做法是先把普通字段全部 append 完,最后再 append 文件:
const formData = new FormData(); formData.append('userId', '10086'); formData.append('remark', '这是备注信息'); formData.append('file', fileInput.files[0]);为什么建议字段在前、文件在后?主要是为了让后端在解析时优先拿到业务字段,便于在存储文件之前快速校验业务参数是否合法。有些后端框架虽然不强制,但这样做会减少一些没必要的文件流处理错误。当然了,如果你要传多个文件,用刚才讲到的同名 append 方式,就是后端老手都熟悉的files数组格式。
从后端角度看,普通字段会出现在 multipart 的普通部分,文件会出现在文件部分,两者是分开的。所以 Spring Boot 里常见的接收方式是 @RequestParam 接普通字段、@RequestPart 接文件;Node.js 的 multer 则是用req.body接字段、req.file接文件。只要前端字段名和后端参数名对得上,解析就很顺畅。
2.3 Blob、File、Base64 之间的转换技巧
有些场景下,你没有直接从 input 拿到 File 对象,而是拿到了一个 Blob 或者一段 Base64 字符串。比如图片裁剪、canvas 导出、接口返回的 Base64 图片。这时候就得手动转换成 File 再塞进 FormData。
File 继承自 Blob,所以 Blob 天然可以放进 FormData。但如果后端明确要求字段类型是文件,那最好还是包成 File:
// 从 canvas 导出图片数据 canvas.toBlob((blob) => { const file = new File([blob], `screenshot-${Date.now()}.png`, { type: 'image/png', }); const formData = new FormData(); formData.append('file', file); }, 'image/png');如果手里是一段 Base64,像下面这样转就行:
function base64ToFile(base64, filename, mimeType = 'image/png') { const byteCharacters = atob(base64.split(',')[1] || base64); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += 512) { const slice = byteCharacters.slice(offset, offset + 512); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } byteArrays.push(new Uint8Array(byteNumbers)); } return new File(byteArrays, filename, { type: mimeType }); }这段代码其实就是把 Base64 二进制化、切片、组装成 File,切片是为了避免一次性创建超大数组造成内存峰值。实际调用时const file = base64ToFile(base64Str, 'avatar.png'),然后 append 进 FormData 就行。
2.4 FormData 不能被 JSON.stringify 直接序列化
有一类问题在工作中很常见:页面提交报错了,你打算把 FormData 打印出来看内容,结果console.log(formData)输出一个空对象,JSON.stringify(formData)也输出{}。这不是你把数据 append 丢了,而是 FormData 内部的键值对不在普通枚举属性里,JSON.stringify 默认只能序列化可枚举属性,所以自然什么都拿不到。
调试 FormData 内容的正确姿势是用 entries 或者 forEach 遍历:
const formData = new FormData(); formData.append('name', '张三'); formData.append('file', file); for (const [key, value] of formData.entries()) { console.log(key, value); // 非文件字段打印字符串,文件字段打印 File/Blob 对象 }如果你需要把 FormData 和普通对象的字段合并,也别想直接展开,老老实实遍历普通对象一个个 append。之前有个同事想用formData.append('data', JSON.stringify(someObject))传递嵌套对象,后端拿到的是字符串还要自己 parse,这种方式不能说错,但设计上不够优雅。除非后端就这样约定,否则建议要么把对象展平,要么后端单独开一个字段接收 JSON 字符串。
3. 前后端联调:FormData 请求的完整实战
讲了这么多原理和细节,还是要落到实际代码上。这里我分别用原生 XHR、fetch 和 axios 三种方式演示 FormData 提交,并给出后端接收示例和一套可复用的封装思路。
3.1 原生 XMLHttpRequest 实现带进度条的上传
很多项目里,上传进度条的需求是很常见的。用 axios 虽然也有 onUploadProgress,但原生 XHR 其实是最直观的,从底层看懂之后再用库会更顺手。
function uploadWithProgress(file, onProgress) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload', true); xhr.upload.onprogress = (event) => { if (event.lengthComputable && onProgress) { const percent = Math.round((event.loaded * 100) / event.total); onProgress(percent); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(`上传失败,HTTP ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('网络异常')); xhr.send(formData); }); } uploadWithProgress(fileInput.files[0], (percent) => { progressBar.style.width = percent + '%'; });这段代码里有三个细节值得注意:第一个是xhr.open的第三参数 true,代表异步,开发时建议显式声明,别依赖默认值。第二个是进度事件挂在xhr.upload上而不是xhr上,只有上传过程的事件才在这里。第三个是千万不要手动设置Content-Type,让浏览器在send(formData)时自动生成带 boundary 的完整 Content-Type,这是绝大多数新手出错的地方。
3.2 fetch 发送 FormData 时的注意事项
fetch 发送 FormData 看起来代码量少,但有一个绕不开的坑就是 Content-Type。用 fetch 的时候,如果你完全不设置请求头,浏览器会自动为 FormData 带上正确的 multipart Content-Type 和 boundary;但只要你手贱设了一个Content-Type: application/json,完了,整个请求体前后端就接不上了。
async function uploadWithFetch(file) { const formData = new FormData(); formData.append('file', file); formData.append('description', '这是一张封面图'); const response = await fetch('/api/upload', { method: 'POST', // 不要手动设置 Content-Type,让浏览器自动生成 body: formData, }); if (!response.ok) { throw new Error(`上传失败,HTTP ${response.status}`); } return response.json(); }这个 “不设置 Content-Type” 的原则不仅仅适用 fetch,对于原生 XHR 和 axios 同样适用。手动设置 Content-Type 就像给收件人写错了信封格式,系统虽然能识别一部分,但 multipart 解析很可能直接失败。
3.3 axios 中 FormData 的正确打开方式
axios 是老牌请求库了,不同版本对 FormData 的处理策略其实有调整。旧版本(1.x 之前)如果你不手动设置 Content-Type,axios 可能默认给 JSON 类型,后端收到以后无法解析 multipart 请求体。新版本 axios 内部已经加了一段逻辑:当检测到请求体是 FormData 实例时,会自动删除已设置的 Content-Type 头,让它走浏览器的自动填充。这个特性在 GitHub 上曾经是一个被反复讨论的 issue,我之前还因为版本问题排查了一下午。
所以在 axios 中提交 FormData,我建议你这样写:
import axios from 'axios'; async function uploadWithAxios(files, extraParams = {}) { const formData = new FormData(); Object.entries(extraParams).forEach(([key, value]) => { formData.append(key, value); }); if (Array.isArray(files)) { files.forEach((file) => formData.append('files', file)); } else { formData.append('files', files); } const response = await axios.post('/api/upload', formData, { // 老版本 axios 需要手动指定;新版本可以省略,设置了也会被自动处理 headers: { 'Content-Type': 'multipart/form-data', }, timeout: 60000, onUploadProgress: (progressEvent) => { const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total); console.log(`上传进度:${percent}%`); }, }); return response.data; }注意这里如果文件是一个数组,统一用同一个字段名 append,后端接的时候按数组处理。我在实际项目里经常在接口层把「单文件」「多文件」「带参数的文件」统一封装,上层只需要传文件数组和额外字段对象,就非常省心。
3.4 后端如何正确解析 form-data 请求
前端要搞定,后端的配合也得知道。后端这里我以 Node.js + multer 为例,因为这是最常见的组合,MySQL 那些就先不展开,只看文件接收逻辑:
const express = require('express'); const multer = require('multer'); const app = express(); // 配置存储方式,这里用内存存储,方便后续转存云存储或者做校验 const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024, // 限制 10MB }, }); app.post('/api/upload', upload.array('files', 9), (req, res) => { // 普通字段在这里 const { userId, remark } = req.body; // 文件数组在这里 const files = req.files; if (!files || files.length === 0) { return res.status(400).json({ code: 1, message: '未收到文件' }); } // 这里可以做大小、类型校验,然后转存或上传到对象存储 res.json({ code: 0, data: { userId, remark, fileCount: files.length, fileNames: files.map((f) => f.originalname), }, }); }); app.listen(3000, () => console.log('server running at 3000'));upload.array('files', 9)的意思很直白:前端带过来同名字段 files,最多收 9 个文件。如果你的前端是多个不同字段名各带一个文件,那用upload.fields([{ name: 'mainFile', maxCount: 1 }, { name: 'subFiles', maxCount: 4 }])会更准确。
如果是 Spring Boot 后端,接收方式也简单,接口方法签名大概长这样:
@PostMapping("/api/upload") public Result upload( @RequestParam("userId") String userId, @RequestParam("remark") String remark, @RequestPart("files") MultipartFile[] files) { // 处理文件 return Result.success(); }只要前端字段名对得上,后端几乎不需要额外特殊处理。
3.5 封装一个可复用的 FormData 提交工具
每次上传都写一遍 FormData 的组装逻辑实在太烦了,所以我习惯封装一个小的工具函数,专门负责把普通对象和文件列表组装成 FormData 再提交。这样整个项目所有涉及 form-data 类型传参的地方都能统一维护。
import axios from 'axios'; function toFormData(params = {}, files = {}) { const formData = new FormData(); // 普通字段整体拼接 Object.entries(params).forEach(([key, value]) => { formData.append(key, value); }); // 文件字段组装,支持单文件和多文件 Object.entries(files).forEach(([key, fileList]) => { const list = Array.isArray(fileList) ? fileList : [fileList]; list.forEach((file) => formData.append(key, file)); }); return formData; } export function postFormData(url, params = {}, files = {}, config = {}) { const formData = toFormData(params, files); return axios.post(url, formData, { timeout: 60000, ...config, }); }使用的时候就是:
const res = await postFormData( '/api/upload', { userId: '10086', remark: '封面图' }, { files: [file1, file2], cover: coverFile } );这个封装很轻量,但能统一处理字段名、文件数组、请求超时等问题。如果哪一天后端要求字段结构变化了,你也只需要改这一处,而不是去项目里到处翻上传代码。
4. 常见问题与排查技巧实录
FormData 这块的问题,很多不是发生在「不会用」上,而是发生在「用的时候没注意边界情况」。我在实际项目里至少帮人排查过十几次这类问题,把常见的问题汇总成了表格,后面再给几条好用的排查心得。
4.1 高频问题速查表
下面这张表基本覆盖了我在实际项目中见过的大部分 form-data 传参问题:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 后端报 HTTP 415 错误 | 手动设置了 Content-Type 为 application/json,或没有让浏览器自动生成 multipart 头 | 删掉手动设置的 Content-Type,让浏览器生成带 boundary 的请求头 |
| 后端收到的是空对象,没有任何字段 | FormData 被 JSON.stringify 序列化后发送了 | 直接把原生 FormData 作为 body 发送,不要 stringify |
| 后端能收到字段,但文件是 undefined | 前端 append 的文件字段名与后端参数名不一致 | 核对字段名,尤其注意大小写和下划线,比如files和file不是一回事 |
| 多文件上传只收到一个文件 | 前端用了 set 而非 append,或者后端只接收单文件 | 多文件用 append 同名多次,后端用数组类型接收 |
| 中文文件名或字段值乱码 | 部分网关/框架对 filename* 解析不友好 | 给 File 对象重命名时尽量用英文/数字,或后端做 RFC 5987 解码 |
| 上传大文件时前端崩溃或卡死 | 一次性把文件整体读成 Base64 再请求,导致内存膨胀 | 直接用 FormData 传原始 File,避免转 Base64 |
| 手动设置了 multipart/form-data 但没 boundary,后端解析失败 | 手动写死了 Content-Type,丢失了 boundary 参数 | 去掉手动设置,交给浏览器或请求库自动补全 |
这里最典型的坑就是手动设置 Content-Type。很多人觉得上 multipart 就要自己加请求头,其实这是最大的误解。multipart/form-data 请求头必须带一个边界字符串 boundary,这个 boundary 是由 FormData 生成请求体时随机产生的,你手动设置基本不可能拿到和请求体一致的 boundary,最终只能得到一个残缺的请求体。
4.2 几个少有人提但非常实用的细节
第一,后端对 multipart 请求里的普通字段类型几乎都是字符串。你 append 一个布尔值 false,后端收到的是字符串 "false",这在某些强类型校验框架里会转成 Boolean 的 false,但也可能因为字符串非空而判成 true。如果你遇到过「明明传了 false,后端非说没传」的诡异问题,多半就是这原因。稳妥做法是后端用 Boolean.valueOf 或前端只传字符串 '0' 和 '1'。
第二,FormData 里的嵌套对象需要手动展平。FormData 没有原生的嵌套结构,如果你直接 append 一个对象,它只会调用 toString 变成[object Object]。要么后端约定子字段名user[name]、user[age]这种写法,要么前端在 append 之前做一层展平:
function flattenObject(obj, prefix = '', result = new FormData()) { Object.entries(obj).forEach(([key, value]) => { const fieldKey = prefix ? `${prefix}[${key}]` : key; if (value !== null && typeof value === 'object' && !(value instanceof File) && !(value instanceof Blob)) { flattenObject(value, fieldKey, result); } else { result.append(fieldKey, value); } }); return result; }这个函数会把{ user: { name: '张三', age: 18 } }变成user[name]和user[age]两个字段,后端如果用了 Spring Boot 或 NestJS 的 DTO,往往能直接绑定。
第三,上传取消和超时也要考虑进去。FormData 请求本身受 AbortController 控制,如果用户在上传过程中点了取消,可以直接controller.abort(),axios 里会捕获到取消错误。不要等到上传完成才发现页面卡住,这种交互细节在小带宽场景下非常影响体验。
第四,Node.js 环境里也有 FormData,但和浏览器不是同一个实现。如果你在 Node 端发请求,需要引入form-data这个 npm 包,或者用 undici 内置的 FormData。区别在于 Node 端的 FormData 不会自动从 Blob 读取文件内容,通常还要配合fs.createReadStream接入流。这一点在做 Node 脚本上传文件到第三方平台时尤其常见。
5. 关于 Content-Type 的最终提醒
说到最后,还是想单独把 Content-Type 拎出来强调一遍,因为它是 form-data 类型传参最容易出问题的环节,没有之一。
在浏览器里边,当你创建一个 FormData 并且通过 XHR、fetch 发送时,浏览器会自动在请求头里生成完整的Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxxxx。这个 boundary 是随机生成的,并且和请求体里分块的分隔符严格对应。后端解析 multipart 的时候,第一步就是靠 boundary 切分请求体。如果你手动设置了一个不带 boundary 的 Content-Type,或者 boundary 和请求体实际用的不一致,后端必然解析失败。
还有一点容易被忽略:如果你用的是 axios,建议确认一下项目里的 axios 版本。老版本需要你手动指定Content-Type: multipart/form-data才能走对格式,新版本可以自动识别,但即使你手动设置了,新版本也会帮你处理掉冲突。最稳的方案是:统一升级到新版本,请求体传 FormData,并且不手动设置 Content-Type;如果公司项目锁了老版本,那就在请求拦截器里约定:只要 data 是 FormData 实例,就强制设置Content-Type: multipart/form-data。
有时候后端确实会遇到一种情况:前端明明没设置 Content-Type,浏览器也自动生成了,为什么后端还是报错?这种通常是跨域预检请求(OPTIONS)或者网关层把 multipart 头给剥掉了。排查思路是先打开浏览器 Network 面板看请求的 Request Headers,确认 Content-Type 是否存在、是否有 boundary。如果请求头发出去之后,后端日志里完全没收到相关字段,再去查网关配置和 Nginx 的 client_max_body_size。
我自己平时排查这类问题,第一步永远是看 actual request。很多时候后端同事说这个问题是前端的问题,前端同事说后端接口有问题,打开 Network 一对照,答案就立刻出来了。
6. 一段实战总结:从报错到修复的完整过程
讲一个我印象挺深的真实排查记录。项目里要做批量上传资质文件,前端用 axios 提交 FormData,后端是 Java Spring Boot 的接口。结果联调的时候后端一直报Current request is not a multipart request。
当时前端代码长这样:
const data = new FormData(); data.append('companyId', companyId); data.append('files', [...fileList]); axios.post('/api/qualification/upload', data);乍一看好像没问题,字段、文件都塞进去了。但打开 Network 一看,请求头的 Content-Type 竟然还是 application/json,浏览器根本没有生成 multipart 头。原因就是项目里 axios 实例的默认 headers 在创建时被统一设置成了Content-Type: application/json,这个默认配置优先级太高,导致 axios 没有正确识别出 FormData。
解决方式有几种,最彻底的是在创建 axios 实例时不要全局硬编码 Content-Type,而是通过请求拦截器判断:
service.interceptors.request.use((config) => { if (config.data instanceof FormData) { // 新版 axios 会自动处理,这里是为了老版本兼容 config.headers['Content-Type'] = 'multipart/form-data'; } return config; });这个判断看起来简单,但能同时兼容新老版本,也解决了全局默认头覆盖的问题。改完之后请求头变正常,后端也顺利拿到了文件。像这种问题,如果不懂 form-data 底层原理,可能会来回调一整天接口。
所以很多时候,熟练运用 FormData 不等于能解决 form-data 类型传参问题。真正的关键是理解 Content-Type 的生成时机、boundary 的机制、FormData 与普通对象的区别,以及前端请求库在背后做了什么。把这些点串起来,再碰到任何「为什么 multipart 收不到」的问题,你都能稳稳对症下药。
最后根据我自己的经验,还有两点建议:一个是前端上传功能一定要做统一的封装,不要在页面里东一个 FormData 西一个 FormData,否则后续加鉴权字段、加超时处理,你会在每个页面里找半天;另一个是调试时多用formData.entries()打印实际内容,这个动作能帮你省下大量猜测的时间。