
做 Web 开发的人早晚都会接到这么一个需求页面上有个表单用户要填资料还要选个文件一起传上来而且不能像老式表单那样“嗖”地一下整页跳走。我第一次处理这种需求时还挺自信直接把文件转成 base64 塞进 JSON 里结果一个几十 MB 的视频差点把浏览器卡死后来老老实实换回 FormData才明白这个 API 在设计上到底有多省心。这篇文章我不会照着文档念 API而是把我自己从“会用”到“遇到各种诡异问题”的过程捋一遍。核心就三个关键词FormData、提交表单、上传文件。适合刚接触前端不久、但已经开始写真实项目的同学也适合后端同事想搞明白“前端到底发了什么”时快速扫一眼。我会把原理、代码、服务端收到的真实内容、还有踩过的坑一次性说清楚。1. FormData到底解决了什么问题1.1 传统表单提交的死穴在 FormData 普及之前要让用户上传文件最“正统”的写法是老老实实用一个表单form action/api/upload methodPOST enctypemultipart/form-data input typetext nameusername / input typefile nameavatar / button typesubmit提交/button /form这段代码在功能上完全没毛病它确实能把用户名和头像传到后端。但问题也很明显点提交之后浏览器会直接跳转到/api/upload整页刷新用户刚填的东西要是校验没通过就全没了。你要是想在提交前弹一个二次确认或者在传文件时给个进度条传统表单很难优雅地做到因为提交动作被浏览器“接管”了。HTML5 时代之后前端有了 XMLHttpRequest Level 2、有了 fetch大家开始用 JavaScript 异步提交数据页面不用刷新了。但普通文本字段怎么发都好办把 JSON 往请求体里一放就行。麻烦的是文件——二进制数据不能直接塞进 JSON 里你总不能把文件读成超长字符串再传吧于是 FormData 就派上了用场。1.2 multipart/form-data是什么FormData 之所以能同时传递文本和文件靠的是它底层使用multipart/form-data这种编码格式。你可以把它理解成一个大快递箱箱子里面有很多独立的小隔层每个隔层都有自己的“标签”name和内容。文本字段的内容是普通字符串文件字段的内容则是一长串二进制数据。一个 FormData 请求体在网络上大体长这样------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; nameusername 张三 ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; nameavatar; filenamephoto.jpg Content-Type: image/jpeg 这里是一堆二进制的图片数据 ------WebKitFormBoundary7MA4YWxkTrZu0gW--看到没每个字段之间用一段随机生成的 boundary 字符串分隔开。这个 boundary 到底是什么值你完全不用管浏览器会自动生成并且会在发送请求时把Content-Type头设置成multipart/form-data; boundary----WebKitFormBoundary...。注意正因为 boundary 是随机生成的如果用 JavaScript 手动拼这种请求体非常容易出错。这也是 FormData 最大的价值它帮你把整个组装过程封装好了你只负责往里面塞数据。1.3 FormData是“组装台”不是“数据”很多刚入门的朋友会把 FormData 当成一种“数据格式”或“数据结构”这其实不太准确。我更喜欢把它比喻成一个“组装台”它本身不存储数据它记住的是“我要往请求体里塞哪些字段、每个字段是什么类型”。当你把它交给 fetch 或 axios 作为请求体时浏览器才会按照 multipart 规则把它真正编码成二进制流发出去。这个理解方式在排查问题时会很有用。比如你往 FormData 里append了一个字符串数字123那服务端收到的就是字符串123而不是数字 123你 append 了一个File对象服务端收到的就是一个文件块。FormData 不会替你转换类型它只是忠实地搬运。在这个阶段有些同学会遇到一个细节问题看到别人写formdata new FormData(this)这个this到底指的是什么其实就是当前那个formDOM 元素。把表单元素传进去FormData 会自动把表单里所有带name属性的控件都收进来不用你一个个手动 append。但这个写法有个隐藏坑它只会收集“表单里已有的字段”如果你在提交前临时往 FormData 里加一个验签 token那就得额外append一下。2. 基础实操从零构建一个 FormData 请求2.1 创建一个 FormData 的两种姿势第一种最省事直接从表单元素初始化const formElement document.getElementById(myForm); const formData new FormData(formElement);第二种是“空手起家”自己一个个字段往里塞const formData new FormData(); formData.append(username, 张三); formData.append(avatar, document.getElementById(fileInput).files[0]);两种方式有各自的适用场景。直接传表单元素适合页面上已经写好了完整的form里面各种input、select、textarea都有name你不想逐个去取。自己 append 则适合表单字段不固定、或者字段来自多处逻辑拼装的场景。需要特别提醒的是构造函数名称是FormData首字母大写。有些同学写成const formdata new formdata()会直接报错因为构造器是FormData。变量名你可以叫formdata但new后面的类名必须准确。我自己的一个习惯是只要页面上存在一个form我优先用new FormData(formElement)收集基础数据然后再针对特殊情况append额外字段。这样最不容易漏字段也能覆盖动态添加的表单项。如果页面根本没有form或者全是分散的输入框那就手动 append配合一个配置对象来管理字段名。2.2 FormData的增删改查FormData 不是只能append它还有几个很实用的方法const fd new FormData(); // 追加一个字段 fd.append(username, 张三); // 设置一个字段如果已存在则覆盖 fd.set(age, 25); // 读取某个字段 const name fd.get(username); // 张三 // 判断是否存在 const hasAge fd.has(age); // true // 删除字段 fd.delete(age); // 遍历所有字段 for (const [key, value] of fd.entries()) { console.log(key, value); }append和set的区别在于append允许同一个 key 出现多次适合多选场景set遇到同名 key 会覆盖。比如上传一个文件列表你就可以对同一个name连续 append 多个File对象服务端会收到一个文件数组。遍历entries()时你可能会发现里面的 value 可能是字符串也可能是File对象。你可以用value instanceof File之类的判断区分这在调试时很实用不过一般情况下不需要手动去遍历直接交请求就行。2.3 fetch与axios的提交区别代码写出来之后发送方式有 fetch 和 axios 两条路。先看 fetchconst fd new FormData(document.getElementById(myForm)); const response await fetch(/api/upload, { method: POST, body: fd }); // 不用手动设置 Content-Type浏览器会自动加上 multipart/form-data 和 boundary用 fetch 时最忌讳的一点是手动设置Content-Type。因为 fetch 看到 body 是 FormData会自动生成正确的Content-Type头里面带着那个随机 boundary。你要是手动写成Content-Type: application/json服务端拿到的是两种不相干的信息解析必然出问题。再看 axiosconst fd new FormData(); fd.append(file, fileInput.files[0]); const response await axios.post(/api/upload, fd, { headers: { Content-Type: multipart/form-data } });这里有个容易误伤的地方在浏览器环境里axios 基于 XMLHttpRequest 实现如果你不设置Content-Type它也会自动识别 FormData 并生成带 boundary 的 header。所以大多数情况下headers里那行Content-Type是可以不写的。但我见过某些项目里确实需要显式写原因是后端网关或者代理层对请求头做了强校验不带multipart/form-data就直接拒掉。这时候你就得显式设置不过需要留意 axios 版本差异。我实际项目里只要不是走 Node 端发请求我基本不主动设置Content-Type让浏览器自己来。等真遇到拿不到文件的问题再排查是不是 header 的问题。3. 文件上传单个、批量、带进度、带压缩3.1 最基础的文件上传案例文件上传的核心在于拿到File对象。最常见的获取方式就是用input typefileinput typefile idfileInput /然后读取const fileInput document.getElementById(fileInput); const file fileInput.files[0]; if (!file) { console.error(没有选择文件); return; } const fd new FormData(); fd.append(file, file); fd.append(description, 这是用户上传的图片); await fetch(/api/upload, { method: POST, body: fd });input[typefile]的files属性是一个FileList即使input没加multiple它也可能是“只有一个元素的类数组”。所以用files[0]取第一个文件是最稳的做法。这里我一般会给input加两个属性accept和capture。acceptimage/*可以在文件选择弹窗里先过滤一下但不能作为后端校验手段因为用户可以手动改成“所有文件”。移动端captureenvironment可以快速唤起相机但同样只是 UI 层面的便利。服务端绝不能依赖这个这是铁律。3.2 多文件上传与进度展示多文件上传分两种一种是多个独立文件分别传一种是一个文件分片传。前端 UI 上最常见的是前者对应的input设置multipleinput typefile idmultiFile multiple /然后代码可以循环收集const files Array.from(document.getElementById(multiFile).files); const fd new FormData(); files.forEach(file { fd.append(files, file); }); await fetch(/api/upload, { method: POST, body: fd });服务端会收到名为files的文件数组在 Node 的 multer 里需要upload.array(files)来接收。如果想要上传进度条fetch 本身不支持进度事件需要借助XMLHttpRequest的upload.onprogress。axios 内部封装了这个能力const fd new FormData(); fd.append(file, file); const response await axios.post(/api/upload, fd, { onUploadProgress: (progressEvent) { const percent Math.round((progressEvent.loaded * 100) / progressEvent.total); console.log(上传进度${percent}%); } });如果你坚持用 fetch可以先用 XHR 封装一层或者直接忍受“没有进度条”的现实。很多管理后台项目里用户上传的文件比较大进度条几乎是刚需所以我会提前问清楚需求再决定用哪套方案。3.3 文件与业务参数、请求头怎么组合文件上传很少有“只传一个文件”这么单纯的需求通常还要带业务字段比如文件所属的订单号、文件类型、上传人 ID甚至还要带一个签名参数。const fd new FormData(); fd.append(file, file); fd.append(bizType, ID_CARD); fd.append(orderId, NO202501010001); fd.append(sign, computeSign(file.name orderId));把这些字段放进 FormData 是最简单的后端可以从req.body里取普通字段、从req.file里拿文件逻辑清晰。但有些时候尤其是前后端完全分离、接口走网关鉴权的项目里请求头里还要携带 token 或其他自定义头。用 axiosheaders里加Authorization: Bearer xxx。用 fetch直接传headers: { Authorization: Bearer xxx }。这里可以放心操作因为自定义 header 不会和 multipart 的 boundary 冲突它不是请求体的内容而是单独的一部分。3.4 上传前压缩、预览的思路压缩文件是另一个大主题但既然聊 FormData 绕不开“文件怎么进 FormData”。如果你要压缩后再传思路是把压缩后的 Blob 转成 Fileconst canvas document.getElementById(previewCanvas); canvas.toBlob((blob) { const compressedFile new File([blob], compressed.jpg, { type: image/jpeg }); const fd new FormData(); fd.append(file, compressedFile); // 继续发送 fd }, image/jpeg, 0.8);File本身继承自Blob构造函数接收三个参数数据数组、文件名、包含 type 和 lastModified 的选项对象。你完全可以像这样“手工造”一个文件对象塞进 FormData。对前端来说这就意味着图片可以先在 Canvas 里压缩、裁剪、加水印再进入上传流程而用户本地那个原始文件不需要动。4. 服务端视角FormData 请求到底长什么样4.1 用Node.js一步步看请求体我从前端转后端写接口时最大的障碍是“看不到请求体长什么样”。等你真正在服务端打印一次 FormData 请求很多困惑会瞬间消失。用 Node.js 原生的方式看const http require(http); http.createServer((req, res) { let body []; req.on(data, chunk body.push(chunk)); req.on(end, () { const buffer Buffer.concat(body); console.log(buffer.toString(utf8)); console.log(Content-Type:, req.headers[content-type]); res.end(ok); }); }).listen(3000);打印出来的内容就是我在前面展示过的那串 boundary 分割文本。你可以清楚地看到文本字段和文件字段是怎么区分的。不过正常开发时不会这么裸着解析Node 生态里最常见的方案是 multer配合 Express 使用const express require(express); const multer require(multer); const app express(); const upload multer({ dest: uploads/, limits: { fileSize: 10 * 1024 * 1024 // 10MB } }); app.post(/api/upload, upload.single(file), (req, res) { console.log(req.file); // 文件信息 console.log(req.body); // 普通字段 res.json({ ok: true }); });multer 的upload.single(file)要求前端 FormData 里有一个名为file的文件字段。如果你前端写的是fd.append(files, file)服务端就得是upload.array(files)或者upload.fields([{ name: files }])。字段名对不上后端只会拿到req.body为空数组文件直接丢失这个是非常常见的“前后端联调翻车点”。4.2 为什么Nginx、网关、配置会卡住你的上传很多前端同学遇到“文件传到一半断了”或者“传到一半报 413”第一反应是前端代码有问题其实很多时候是服务端门口的“大小门卫”把请求拦了。最常见的几道门卫Nginx 的client_max_body_size默认值是 1m。也就是说你不主动改超过 1MB 的请求体直接返回 413。Node 服务所在的 Express 或有类似bodyParser的中间件默认只解析 JSON 和 urlencoded根本不碰 multipart。PHP 环境里有upload_max_filesize和post_max_size两个配置前者管单个文件后者管整个请求体。IIS 下有maxAllowedContentLength和 ASP.NET Core 里的MaxRequestBodySize。我遇到过一个很典型的问题开发环境一切顺利一部署到测试环境上传稍大一点的文件就 413。查看 Nginx 配置才发现测试环境在反代层统一加了client_max_body_size 2m开发环境没有这层。排查这类问题时我会优先看响应状态码413 就是请求体超限大概率是网关或 Web 服务器的限制如果是 500 或者连接中断那再往应用层查。先确定是哪一层的限制可以省去大量无头苍蝇式的排查时间。4.3 服务端正确配置的通用清单结合我在好几个项目里的经验一份相对可靠的配置链路大致是这样前端FormData 组装文件用 fetch 或 axios 发送不手动设 Content-Type。反向代理Nginx设置client_max_body_size大小根据业务定。传大文件时通常要有分片策略不能只靠调大这个参数糊弄。应用层根据框架选择正确的 multipart 解析中间件。存储层本地磁盘、对象存储、OSS、MinIO 都行重点是别把文件存到 Web 根目录下且带原文件名否则容易引发安全问题。提示如果你要在生产环境支持 1GB 以上的文件请认真考虑分片上传方案。服务端对请求体大小无限放开的代价很高Nginx、网关、负载均衡都可能在传输过程中因为内存占用或超时出问题。5. 踩坑实录与排查清单5.1 request aborted分片上传中断的常见原因搜索“node 分片上传文件时报错 request aborted”能发现一堆人遇到这个问题包括我自己。场景通常是这样的前端把大文件切成若干片一片一片往 Node 服务端发前几片很顺利几片之后突然上报错错误关键字是request aborted。这个报错的核心原因就两个方向客户端主动断开了连接。可能是用户切走了标签页也可能是浏览器因为长时间无操作把页面回收了更常见的是前端代码在某个 Promise reject 之后没有正确继续下一个分片导致整个上传流程中断。服务端或中间层断开了连接。Nginx 的proxy_read_timeout默认 60 秒如果你的某个分片超过 60 秒还没处理完连接就会被切断。Node 服务本身如果内存紧张或某个中间件异常也可能直接终止请求。我的排查套路是先看日志里request aborted对应的是哪个分片确认是不是某个固定位置。如果是固定位置检查服务器是不是有单连接超时或请求体大小限制。如果不是固定位置更像客户端网络抖动或浏览器行为前端加重试机制。分片大小和并发数也影响很大我常用 5MB 一片、并发 2~3 个太激进反而容易崩。前端代码方面给分片上传加上重试和断点续传是第一优先级。一个分片失败最多重试两次第三次再失败就停止整个任务并提示用户这比无限重试温和得多。5.2 老OA系统上传不兼容怎么处理“浏览器 OA 上传文件不兼容”和“浏览器上传文件不兼容”这类问题是很多做企业系统的同学逃不掉的。老 OA 系统通常基于 IE 时代的 ActiveX 或旧版控件Windows 自带 Edge 升级到 Chromium 内核之后很多老控件直接失效。如果系统还在用 IE 内核的兼容模式跑那你绕不开的是 FormData 在 IE10/IE11 的表现IE10 开始支持 FormData但FileConstructor相关能力有限。IE11 对File对象的lastModified支持不完整。老内核不支持input[typefile]配合multiple多选。更麻烦的是某些 OA 里嵌的 WebBrowser 控件还在用 IE8 内核那 FormData 根本不能用只能退回到隐藏 iframe 传统表单提交。这种场景下你要做的是先判断当前浏览器能力能走标准 FormData 就走标准不能走就降级到隐藏 iframe。我处理这类兼容问题会先做一个能力检测function isFormDataSupported() { return typeof FormData ! undefined typeof FileList ! undefined !!window.FileReader; }如果返回 false就别硬上 FormData 了直接动态创建一个form提交到隐藏 iframe让浏览器自己处理 multipart虽然会刷新页面但至少文件能传上去。5.3 文件时间戳为什么会丢Windows上传到Linux“Windows 上传文件至 Linux 时不修改文件时间”这个问题看起来和 FormData 没关系但它恰恰点出了文件上传里一个容易被忽略的事实multipart 传输的是“文件内容字节流”不包含文件系统里的创建时间、修改时间等元数据。前端表单里的文件对象确实有lastModified属性但那只是浏览器读取到的原始文件修改时间戳。你往 FormData 里 append 一个 File 对象时这个时间戳不会自动变成服务端文件的 mtime。服务端保存文件后mtime 通常是保存那一刻的当前时间。如果你希望保存原始修改时间最直接的办法是前端把时间戳当成一个普通字段传过去const fd new FormData(); fd.append(file, file); fd.append(lastModified, file.lastModified);后端保存后再用fs.utimes之类的接口把文件的 mtime 设置成这个值const { lastModified } req.body; if (lastModified) { const timestamp Number(lastModified); await fs.promises.utimes(filePath, timestamp, timestamp); }如果你上传的是 zip 压缩包服务端解压时通常会保留压缩包内的条目的时间信息那又是另一套逻辑。但“直接传文件字节”的场景时间戳不会自动保留这是需要主动做的一个点。5.4 安全校验别只看后缀名“CTFHub 上传文件”这个话题经常被搜索但我在实际项目里看到的教训是一样的文件上传接口如果只校验了前端传来的文件名后缀那几乎等于没校验。只要攻击者抓包改一下 Filename 字段就能把.php改成.jpg骗过文件类型检查然后借助服务端配置绕过执行限制。做上传功能时服务端至少要干这几件事校验文件大小拒绝超大请求。不信任前端传来的文件类型和文件名用服务端读取到的真实内容去判断类型。将文件重命名为无规律随机名不让用户控制存储路径和文件名。存储目录禁止直接执行脚本。必要时对文件内容做杀毒或白名单检查。这四点做到能挡住绝大部分批量上传攻击。我见过不少公司的内部系统对上传文件几乎是裸奔的只要前端传得上去服务端就存下来等发现问题时已经被挂了好几个 webshell。我理解 CTF 训练平台这类项目就是在展示上传漏洞的经典攻击手法对开发者来说最有价值的反思不是“怎么绕过”而是“为什么拦不住”。开发阶段把文件类型校验和服务端配置做到位才是真正把学到的东西转化成生产力。5.5 用JMeter模拟FormData上传做回归测试写完后端接口总要有人测。现在很多测试同学用 JMeter 模拟文件上传如果不知道 JMeter 里怎么拼 multipart 请求也容易卡壳。JMeter 里的做法其实很简单添加一个 HTTP Request 取样器。请求方法选 POST。选中 “Use multipart/form-data” 复选框。在 “Files Upload” 标签页里填写文件路径、参数名称对应 FormData 的字段名、MIME 类型。如果有普通文本字段比如业务 ID、签名就填在 “Parameters” 标签页里。这样 JMeter 发送出去的请求就会和浏览器里的 FormData 请求一样把文本字段和文件字段同时置于一个 multipart body 里。这里一个小提醒如果你用 JMeter 同时配置了 “Parameters” 和 “Files Upload”JMeter 会把这部分参数也一起放进 multipart form body。如果你的接口还要额外校验Content-Type头里的某些信息要记得在 HTTP Header Manager 里补上。用 JMeter 做上传接口压测时我通常会提前分析 multipart 格式重点检查 boundary 是否正确生成否则很容易出现服务端能正常处理浏览器请求、却解析不了 JMeter 请求的现象。这本质上还是“请求体格式和边界字符串必须严格匹配”的问题。5.6 登录场景里“表单提交校验失败”的怪事还有一个高频问题“登录失败表单提交校验失败请刷新后重试”。很多团队在登录页已经把表单提交改成了 FormData fetch却发现偶尔会报这个错。原因通常不在表单本身而在“校验信息过期”。比如页面里有隐藏的 CSRF token或者验证码 token它们是表单的一部分。传统表单提交时浏览器会把隐藏域一并带过去改成 FormData 后如果你是从new FormData(formElement)初始化的隐藏输入域会被自动收集一般没问题。但如果你手动构建 FormData只收集了用户名和密码忘掉收集 token后端一校验就知道请求不是从自己页面发出的直接拒绝。解决办法有两种优先用new FormData(formElement)从完整表单收集减少漏字段概率。如果必须手动构建就额外再append一次校验 token。我建议团队里统一约定凡是和用户表单交互强相关的 token、签名字段要么放在自定义请求头里要么统一用 FormData 字段携带不要一半在头一半在体容易乱。6. 我写表单上传时的一些个人习惯和体会最后分享几个我在大量表单上传需求中沉淀下来的习惯不一定适合所有人但对我来说真的能减少低级问题。第一个习惯是只要表单元素存在完整结构我永远用new FormData(formElement)作为第一步再根据业务需要增补少量字段。这样最不容易漏掉表单里加了某个隐藏字段而前端不知道的情况。第二个习惯是给 FormData 里的字段名立一套约定。文件字段用单数名file多文件用复数files业务字段用驼峰命名。别一会儿file一会儿myFile前后端联调时反复对名字真的很浪费时间。第三个习惯是对所有上传接口我在前端就做一次文件大小预判。比如超过 100MB 的走分片分片大小用 5MB并发控制在 2~3 个。小文件直接整传。不要用一个固定代码路径处理所有大小的文件文件一大网络抖动的影响会被放大很多倍。第四个习惯是永远给上传请求设置超时和错误提示。FormData 请求很容易因为网络问题静默失败用户点了上传没反应也没有任何提示体验极差。一个简单的try/catch加上超时控制就能帮用户少付出很多无谓的等待。最后一个体会是FormData 并不是多么高深的技术但它是前后端之间传递二进制数据的基础设施之一。把它弄透意味着你在处理文件上传时脑子里能时刻知道“这里传输的是一个 multipart 结构”而不是一个魔法黑盒。只要理解到这一层前面提到的大小限制、超时中断、字段名不匹配、文件类型校验等问题你就都能顺着链路一步步定位到根因了。