
上个月帮一个老系统做改造页面里有个表单要填三四行文本还要传两张附件。原来的做法是form action/upload methodPOST整页刷新提交数据一多体验就很差尤其在弱网环境提交完页面白屏好几秒用户以为挂了又点了一遍提交结果数据库里多了两条记录。这个场景相信做前端的朋友多少都遇到过。我当时的想法很简单改成 Ajax 异步提交把上传进度也做出来。调研了一圈最后用了原生FormData没引第三方库干净利落地把问题解决了。这中间踩了不少坑也把FormData的底细摸了个遍今天系统地整理出来包括 API 细节、两种构造方式的取舍、跟后端配合的姿势、各种离奇报错的排查思路希望能让后来的人少走弯路。1. FormData 到底是什么它解决了什么问题1.1 从传统表单提交说起在没有 Ajax 的年代表单提交就是form action... methodPOST一提交浏览器把表单数据编码后发出去然后整页刷新跳转到服务端返回的页面。这种方式有两个硬伤体验差页面会闪、会跳用户填到一半的内容容易被清空二是上传文件时用户的浏览器和服务器之间发生了什么前端完全无法感知进度条只能靠后端的假请求或者轮询来模拟。后来 XMLHttpRequest 出现了前端可以异步发请求不用刷新页面。但早期的 XHR 有个尴尬的地方它发送的 body 格式和传统表单不一样需要用encodeURIComponent手动拼接 keyvalue 字符串文件这种二进制数据根本没法放进去。所以很长一段时间里文件上传只能靠隐藏 iframe 这种曲线救国的方案或者引入 Flash 控件大家都被恶心过。FormData出现后这个痛点被彻底解决了。它允许你在 Ajax 请求里构造出一种和multipart/form-data格式一致的数据体文本字段、File、Blob都能塞进去。这意味着表单和文件可以在一个请求里一起提交不再需要先传文件再传表单字段这种拆两步的尴尬操作。在很多涉及“资料填写 附件上传”的场景里这是唯一合理的方案。1.2 构造函数和几个高频 APIFormData的构造很简单两种姿势// 方式一传入一个真实 DOM 表单自动收集所有字段 const formData new FormData(document.querySelector(#myForm)); // 方式二创建一个空对象手动添加字段 const formData new FormData(); formData.append(username, 张三); formData.append(avatar, fileInput.files[0]);方式一适合场景简单、表单字段固定、结构和 DOM 一一对应的页面方式二适合场景复杂、字段动态增减、或者不需要依赖具体 DOM 结构的函数式代码。我个人的习惯是如果页面上已经有一个完整的form优先用方式一少写不少append如果表单是散落在各个组件里的比如弹窗里几个 input页面顶部还有一些隐藏条件就直接new FormData()手动拼。核心方法就那么几个我整理一下方法作用注意事项append(name, value)追加一个字段同名会保存多个值不会覆盖set(name, value)设置字段同名会被覆盖有则改、无则增get(name)取字段值文件域会返回File对象getAll(name)取同名字段的全部值多文件、多选场景很有用has(name)判断字段是否存在返回布尔值delete(name)删除字段删除后该字段的所有值都没了entries()遍历所有字段配合for...of可迭代很多人在调试时会犯一个错误console.log(formData)发现打印出来是空的以为没 append 上。其实不是FormData对象内部的数据结构并不会被console.log直接展开。想看内容用formData.get(username)或formData.entries()去取。这个坑我见新人踩过很多次先说一下。2. 完整实操表单加文件一次提交2.1 构造 FormData 的两种姿势怎么选先说场景有个“添加用户”的表单包含姓名、邮箱、头像文件。HTML 长这样form iduserForm input typetext nameusername placeholder姓名 / input typeemail nameemail placeholder邮箱 / input typefile nameavatar acceptimage/* / button typesubmit提交/button /form方式一直接把整个表单对象交给FormDatadocument.getElementById(userForm).addEventListener(submit, function (e) { e.preventDefault(); const formData new FormData(this); // 接下来发送 formData });这样做的好处是表单里所有带name属性的控件都会被自动收集不需要一个个写append。文件域的files[0]也会被自动塞进去相当省事。但方式一有个隐藏的坑它会收集所有name字段如果表单里有些字段是展示用的、不该提交的也会被带上。比如一个只读的展示框name恰好是status就会不知不觉多传一个字段。遇到这种情况要么在 DOM 层面把name去掉要么用方式二手动拼const formData new FormData(); formData.append(username, document.querySelector(input[nameusername]).value); formData.append(email, document.querySelector(input[nameemail]).value); formData.append(avatar, document.querySelector(input[nameavatar]).files[0]);手动拼的另一个好处是可以在 Append 之前做数据清洗。比如用户输入了首尾空格或者某个字段想要用处理后的值提交都可以在append之前处理好不会污染 source of truth。2.2 用 XMLHttpRequest 发送并支持上传进度fetch虽然新但原生不支持上传进度事件。要进度条还得看XMLHttpRequest。下面是完整代码const form document.getElementById(userForm); form.addEventListener(submit, function (e) { e.preventDefault(); const formData new FormData(form); const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload, true); // 关键点不要手动设置 Content-Type // 浏览器会生成一个包含 boundary 的 Content-Type手动设置会丢失 boundary导致服务端解析失败 xhr.upload.onprogress function (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); // 更新进度条 UI document.getElementById(progressBar).style.width percent %; } }; xhr.onload function () { if (xhr.status 200 xhr.status 300) { const res JSON.parse(xhr.responseText); console.log(上传成功, res); } else { console.error(上传失败, xhr.status, xhr.responseText); } }; xhr.onerror function () { console.error(网络错误); }; xhr.send(formData); });这里最容易被新手搞挂的就是Content-Type。很多人在写普通 Ajax 时习惯了手动去setRequestHeader(Content-Type, application/json)然后发FormData也照葫芦画瓢设成multipart/form-data结果服务端死活解析不到文件报错各种诡异。原因很简单multipart/form-data请求体里有一个叫boundary的分隔符这个分隔符是浏览器生成FormData时随机产生的并且会拼在Content-Type里形如Content-Type: multipart/form-data; boundary----WebKitFormBoundary7MA4YWxkTrZu0gW如果你手动把Content-Type写死成multipart/form-data你就把boundary弄丢了服务端看到请求体里没有边界标记自然无法解析。所以正确做法是使用FormData作为 body 时不要手动设置Content-Type让浏览器自动带上它会自己加boundary。上传进度通过xhr.upload.onprogress获取e.loaded是已经上传的字节数e.total是总字节数两个都能拿到时就可以算百分比。如果是大文件上传这个事件会频繁触发回调里不要做太重的 DOM 操作最好用 requestAnimationFrame 或者简单的节流不然进度条会卡顿。2.3 用 fetch 的简洁写法如果不需要进度条fetch写起来更清爽const formData new FormData(form); fetch(/api/upload, { method: POST, body: formData }) .then(res res.json()) .then(data { console.log(上传成功, data); }) .catch(err { console.error(上传失败, err); });同样不要手动去指定 Content-Type。fetch发现 body 是FormData实例时会自动设置multipart/form-data和boundary。fetch上传二进制文件还有一个比较隐蔽的点如果你用 AbortController 去取消请求fetch会抛出一个AbortError要在.catch里判断一下const controller new AbortController(); fetch(/api/upload, { method: POST, body: formData, signal: controller.signal }) .then(res res.json()) .catch(err { if (err.name AbortError) { console.log(用户取消了上传); } else { console.error(上传失败, err); } }); // 需要时调用 controller.abort();2.4 多文件、拖拽文件和动态字段文件上传场景往往不止一个单文件。比如用户要一次传五张图片或者把文件从桌面拖进来。多文件的input需要加multiple属性input typefile namephotos multiple acceptimage/* /对应的 JS 处理注意同名append多次const formData new FormData(); const files document.querySelector(input[namephotos]).files; for (let i 0; i files.length; i) { formData.append(photos, files[i]); }如果你是拖拽上传从dragend事件里拿到dataTransfer.files也是同样的循环append没区别dropZone.addEventListener(drop, function (e) { e.preventDefault(); const files e.dataTransfer.files; for (let i 0; i files.length; i) { formData.append(photos, files[i]); } });这里有个小技巧append的第三个参数可以指定文件名。有时候文件对象本身的名字是undefined或者用户传的是blob比如 Canvas 生成的图片你可以用它给文件起一个合理的名字canvas.toBlob(function (blob) { formData.append(avatar, blob, avatar.png); });对于动态字段比如一个“规格列表”需要用户点按钮不断添加一行每行两个 input存到表单里时用数组命名的字段就行// 假设每行规格的名字叫 specName值为 specValue document.querySelectorAll(.spec-row).forEach((row, idx) { formData.append(specs[${idx}].name, row.querySelector(.spec-name).value); formData.append(specs[${idx}].value, row.querySelector(.spec-value).value); });服务端解析这种数组格式时按约定处理即可在 Node/Express 里用multer的fields或直接看req.body配合 multer 的array或fields配置都很方便。3. 原理深挖三种提交方式到底差在哪3.1 普通表单、FormData、JSON 提交对比很多人有一个疑问同样是提交数据为什么有的地方用application/json有的地方用FormData它们到底有什么区别维度传统表单提交FormData AjaxJSON 提交数据格式application/x-www-form-urlencoded或multipart/form-datamultipart/form-dataapplication/json二进制文件支持但页面会刷新支持且异步无刷新不支持需要先转 Base64 或 Blob请求体结构keyvalue 拼接层级简单keyvalue 拼接支持文件块嵌套对象/数组结构灵活前端体验整页跳转无法做进度无刷新可监听进度无刷新无上传概念服务端解析框架自动解析框架自动解析框架自动解析典型场景老项目、跳转类页面表单 文件混合提交纯 JSON 接口、前后端分离这里要说明一下传统表单提交和 FormData 走的是同一种底层编码方式用的都是multipart/form-data只不过传统表单由浏览器直接发请求并刷新页面FormData把数据的构造和发送拆出来交给了 JS。所以 FormData 并不是什么黑魔法它只是让异步环境下的表单编码变得可能。application/json适合纯数据的结构比如嵌套对象、数组、布尔值比较复杂的接口。但 JSON 传文件需要 Base64 编码体积膨胀三分之一服务端还要反解回字节流效率和便利性都不如 FormData 直接塞文件。什么时候用 FormData我的判断标准很简单请求里有文件或者接口是按表单格式接收的直接用 FormData请求纯结构化数据、无文件用 JSON。有文件但用 JSON 传 Base64虽然能做但是属于自己给自己找麻烦。3.2 multipart/form-data 编码原理简介multipart/form-data的请求体和 URL 编码的keyvalue格式完全不一样。它的核心是boundary分隔。请求体大致长这样POST /api/upload HTTP/1.1 Content-Type: multipart/form-data; boundary----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; nameusername 张三 ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; nameavatar; filenamephoto.jpg Content-Type: image/jpeg 文件二进制内容 ------WebKitFormBoundary7MA4YWxkTrZu0gW--看懂这个格式你就能明白几件事为什么文件域的名称要和服务端接收的字段名对上为什么文件名是放在Content-Disposition的filename参数里以及为什么手动去掉boundary会让服务端完全懵掉。这个格式也解释了为什么multipart/form-data不适用于大量嵌套数据它天然是扁平的 key/value 结构虽然有 RFC 7578 提供了嵌套的扩展但实际开发中接口约定还是以扁平字段为主。真要传大量层级数据用 JSON。3.3 服务端如何接收以 Node/Express 为例前端的 File 对象通过 FormData 发出去后服务端拿到的是一个标准的multipart/form-data请求没什么特殊。以 Node 的 Express 为例最常用的中间件是multerconst express require(express); const multer require(multer); const app express(); // 配置上传文件存内存还是硬盘限制大小 const upload multer({ storage: multer.diskStorage({ destination: function (req, file, cb) { cb(null, uploads/); }, filename: function (req, file, cb) { // 防止中文文件名乱码这里做一个重命名 const ext file.originalname.split(.).pop(); cb(null, Date.now() - Math.round(Math.random() * 1e9) . ext); } }), limits: { fileSize: 10 * 1024 * 1024 } // 10MB }); // 单文件字段 avatar app.post(/api/upload, upload.single(avatar), (req, res) { console.log(文本字段, req.body); // { username: 张三, email: ... } console.log(文件字段, req.file); // 文件对象 res.json({ ok: true, filePath: req.file.filename }); }); // 多文件字段 photos app.post(/api/upload-multi, upload.array(photos, 12), (req, res) { console.log(文本字段, req.body); console.log(文件列表, req.files); res.json({ ok: true, files: req.files.map(f f.filename) }); });multer会把 FormData 里的文本字段解析到req.body文件解析到req.file或req.files前后端的字段名必须一一对应不然服务端拿到的就是undefined。排查这类问题时你可以在服务端把req.body和Object.keys(req.files || {})打印出来看字段名是啥再回前端对比。服务端特别要注意limits.fileSize和 Nginx 的client_max_body_size。前端的 FormData 没有内置大小限制超了大文件请求会很大如果服务端或网关层不放开用户会看到请求被 413 拒绝。生产环境我见过太多次前端纠结半天字段格式结果问题出在 Nginx 默认只给 1MB 上传大小。3.4 大文件上传为什么还要分片FormData 本身没有大小限制但实际生产中一个 2GB 的文件直接丢进 FormData 会有很多问题网络中断要全部重传、浏览器内存压力大、服务端接收超时、网关限制等。所以大文件场景一般要做分片上传。分片的思想很简单把大文件切成一堆小块每一块作为一个独立的 Blob 塞进 FormData 上传。前端用File.prototype.slice切割const CHUNK_SIZE 2 * 1024 * 1024; // 2MB const file fileInput.files[0]; let start 0; while (start file.size) { const chunk file.slice(start, start CHUNK_SIZE); const formData new FormData(); formData.append(file, chunk, file.name); formData.append(chunkIndex, Math.floor(start / CHUNK_SIZE)); formData.append(totalChunks, Math.ceil(file.size / CHUNK_SIZE)); // 逐个上传注意并发控制别一次性全部发出去 start CHUNK_SIZE; }分片后服务端可以先把每个分片暂存全部传完后合并。这中间会带来一堆新的复杂问题分片顺序、存储、合并、断点续传本文不展开但要知道思路。后面讲的request aborted报错和大分片、并发控制都有直接关系。4. 真实项目里最容易踩的坑和排查实录4.1 兼容性问题老浏览器和“OA 上传文件不兼容”FormData 的兼容性其实已经很好IE10 都支持。但我在实际工作中遇到过不止一次“浏览器 OA 上传文件不兼容”的报障最后查下来基本都是这几个原因一是浏览器兼容模式。不少 OA 系统是内网系统用户用的浏览器被企业策略强制开成了 IE 兼容模式而这个兼容模式的文档模式又非常旧比如 IE7导致 FormData 或者File对象根本不存在上传自然失败。排查方式是在浏览器控制台输入typeof FormData如果是undefined基本就是兼容模式惹的祸让 IT 把站点从兼容模式列表里去掉或者在head加meta http-equivX-UA-Compatible contentIEedge。二是浏览器版本确实太老。IE9 及以下不支持FormData和FileReader这类古董环境只能换方案常见的是隐藏 iframe 模拟异步提交或者用 ActiveX 控件。遇到这种需求建议先跟客户沟通升级浏览器因为后续文件校验、进度条都会受限。三是FormData在部分旧版浏览器中不支持从 DOM 表单直接构造表现为new FormData(formElement)报错。这类浏览器必须手动append才会正常。我一直建议真遇到老旧环境直接用手动 append 最稳不依赖表单对象解析能力。4.2 用 JMeter 验证和压测文件上传接口开发完成后要验证接口能否扛住并发上传浏览器 F5 是做不到的这时候用 JMeter 很方便。JMeter 里构造 FormData 文件上传请求的步骤添加 Thread Group设置并发用户数和循环次数。添加 HTTP Request 取样器协议、服务器地址、端口、路径按实际填方法选 POST。勾选Use multipart/form-dataJMeter 里叫Use multipart/form-data for POST。添加参数表单字段的name和value填进 Param 列表。添加文件在 File Upload 区域填写文件路径、参数名比如avatar、MIME 类型比如image/jpeg。我这里贴一个简化版的 JMeter HTTP 请求配置示意Method: POST Protocol: http Server Name or IP: 127.0.0.1 Port: 8080 Path: /api/upload Use multipart/form-data: ✅ Parameters: username: zhangsan email: zhangsanexample.com Files Upload: File Path: /home/testdata/avatar.jpg Parameter Name: avatar MIME Type: image/jpeg跑完之后注意看响应体的状态码和耗时。压测文件上传时 JMeter 本身机器性能也会成为瓶颈因为要持续读文件、构造 multipart 数据所以建议分布式压测或者至少保证 JMeter 所在机器和压力源不在同一台低配开发机上。4.3 Node 分片上传报错 request aborted 的排查思路这个错误很典型场景是分片上传时某个分片请求发出去后连接被中断服务端记录到类似Error: request aborted { errorCode: runtime_error }排查方向按顺序来先看服务端日志。如果是 Nginx 在前面默认client_max_body_size是 1MB分片超过这个大小会被直接 413 或断开前端表现就是 request aborted。解决办法是在 Nginx 对应的 location 里加client_max_body_size 100m;如果 Nginx 配置没问题再确认服务端中间件的超时时间。Node 服务用 Express 时请求体如果超过中间件设置的limits也会直接报错。拿 multer 举例把limits调大或者改到存储策略再试。再看客户端。分片上传时如果发起的并发请求过多浏览器对同一域名的连接数量有限制或者后端处理不过来导致请求排队时间过长客户端设置里的 timeout 一到就主动断了。我建议分片数量控制在 3~5 个并发不要一次性把 200 个分片全发出去每片 2~5MB 比较合理。最后看代码逻辑。分片上传如果用 axios默认超时可能是 0不超时但如果库的默认配置带了 timeout分片大文件很容易触发。解决方式是给上传接口单独设置较长的超时或者把分片调小。// axios 分片上传时单独把这个请求的 timeout 调大 axios.post(/api/chunk, formData, { timeout: 60000 // 1分钟 });如果还不行抓包看请求是否到达服务端确认是客户端主动断开还是服务端断开。curl -v或者浏览器 Network 面板里看请求的 timing基本能定位到是哪一层断的。4.4 表单提交校验失败与登录场景FormData 不只是为文件上传服务的纯表单提交也可以用。一个典型场景是登录接口。很多后端接口接收的就是表单格式而不是 JSON。如果你用 FormData 提交登录表单提交前先做一遍前端校验后端校验失败时返回对应的错误提示。const formData new FormData(); formData.append(username, usernameInput.value.trim()); formData.append(password, passwordInput.value); // 前端先做基础校验 if (!formData.get(username) || !formData.get(password)) { showToast(请输入用户名和密码); return; } fetch(/api/login, { method: POST, body: formData }) .then(res res.json()) .then(data { if (!data.success) { // 登录失败展示表单提交校验错误的提示 showToast(data.message || 登录失败请稍后重试); return; } // 登录成功跳转 location.href data.redirect; });用 FormData 提交登录表单有个额外的好处如果登录时还要带设备指纹、埋点图片之类的二进制字段依然可以优雅地 append 进去后面不需要改数据结构。登录场景特别要注意防重复提交。网络慢的时候用户点了好几次登录按钮FormData 构造不会报错但服务端会收到多个几乎相同的登录请求可能造成脏数据或者验证码失效。解决办法就是在提交时置一个锁let submitting false; form.addEventListener(submit, async function (e) { e.preventDefault(); if (submitting) return; submitting true; // 要置灰按钮 / 显示 loading try { await doLogin(new FormData(form)); } finally { submitting false; // 恢复按钮 } });5. 几个容易忽略但很影响体验的小细节5.1 文件类型和大小的前端校验accept属性只是打开文件选择框时的“建议”并不强制。用户完全可以点“所有文件”选一个 .exe。所以前端一定要再校验一次const file fileInput.files[0]; const maxSize 10 * 1024 * 1024; // 10MB if (!file) return; if (file.size maxSize) { showToast(文件不能超过10MB); fileInput.value ; return; } // 类型校验按扩展名 MIME 双重判断 const allowedTypes [image/jpeg, image/png, image/webp]; if (!allowedTypes.includes(file.type)) { showToast(仅支持 JPG / PNG / WebP 格式); fileInput.value ; return; }注意.value 是有必要的否则用户选了超限文件后再重新选择同一个文件change事件可能不触发。表单校验和文件大小校验最好都放在提交前一次性做完不要提交到服务端才报错体验差距很大。5.2 文件名、中文与浏览器默认行为文件上传时File对象的name中文通常没问题但如果你直接用formData.append(file, file)而不改名服务端收到的是原始文件名。一次上传多个文件这些文件如果重名服务端存储时可能互相覆盖所以一般会在服务端重命名为随机字符串同时把原始文件名存到数据库。如果前端想在Content-Disposition里塞一个自己想要的文件名可以用append的第三个参数formData.append(file, blob, report.pdf);这个参数在服务端读取时对应file.originalname可以用于友好的下载名展示。但要注意中文文件名在 multipart 里有时会因为编码风格RFC 5987 或 legacy 编码导致服务端解析出现?UTF-8?B?...?这种乱码所以更稳妥的做法是前端提供一个额外的原始名给后端存数据库实际落盘文件名用随机生成。5.3 上传过程中的取消与异常处理文件上传一旦开始用户如果想取消XHR 可以直接xhr.abort()fetch 可以用AbortController。但要注意取消只是客户端的行为服务端可能已经把之前的数据写入了。所以取消上传前一般要发一个请求通知服务端清理已经落盘的临时文件。很多库如axios的CancelToken只处理了客户端取消容易忽略服务端清理导致临时文件堆积。上传失败的提示也要做好。网上很多示例只写了onload里的成功判断onerror和超时都没处理。实际弱网环境里上传失败的比例不低提示信息要明确告诉用户是网络断了、超时了、还是后端拒绝了。建议至少区分三层请求层错误网络中断、DNS 失败提示“网络异常请检查网络后重试”。超时错误提示“上传超时建议降低文件大小或更换网络”。服务端 4xx/5xx把后端返回的错误信息展示出来。5.4 上传按钮的 loading 状态和防重复提交最后说一个最不起眼但影响最大的点提交按钮的 loading 和防重复。用户不知道上传要多久如果按钮不置灰他们大概率会点第二次、第三次。我在实际项目里见过用户点了四次上传产生四份报表数据的情况。处理方式很简单submitBtn.disabled true; submitBtn.textContent 上传中...; // 无论成功失败finally 里恢复按钮 try { await upload(formData); } catch (err) { // 提示错误 } finally { submitBtn.disabled false; submitBtn.textContent 提交; }防重复的核心是把状态锁放到请求发出前而不是请求完成后。所有异步操作成功和失败都要回到“可再次提交”的状态这个逻辑放finally里最安全。写到最后的小建议用FormData上传文件技术上本身不难难的是把各种边角场景想全兼容模式、并发、超时、取消、防重复、服务端限制、文件校验。我做完那个老系统改造后最大的体会是这类功能几乎不用引入额外的第三方上传库原生 API 能力已经覆盖了 90% 的需求。剩下 10% 的复杂场景分片、断点续传再考虑上重型方案也不迟。还有一个容易被忽略的点FormData不光能从input[typefile]拿文件Canvas 生成的Blob、fetch回来的二进制流、甚至拖拽进来的本地文件都可以用同样的方式 append 进去。所以它的应用边界比我一开始以为的要宽很多很多“看似复杂”的交互用原生FormData都能干净地实现。希望这篇文章能帮你绕开我踩过的那些坑。