上个月帮一个老系统做改造,页面里有个表单,要填三四行文本,还要传两张附件。原来的做法是<form action="/upload" method="POST">整页刷新提交,数据一多体验就很差,尤其在弱网环境,提交完页面白屏好几秒,用户以为挂了又点了一遍提交,结果数据库里多了两条记录。这个场景相信做前端的朋友多少都遇到过。
我当时的想法很简单:改成 Ajax 异步提交,把上传进度也做出来。调研了一圈,最后用了原生FormData,没引第三方库,干净利落地把问题解决了。这中间踩了不少坑,也把FormData的底细摸了个遍,今天系统地整理出来,包括 API 细节、两种构造方式的取舍、跟后端配合的姿势、各种离奇报错的排查思路,希望能让后来的人少走弯路。
1. FormData 到底是什么,它解决了什么问题
1.1 从传统表单提交说起
在没有 Ajax 的年代,表单提交就是<form action="..." method="POST">一提交,浏览器把表单数据编码后发出去,然后整页刷新,跳转到服务端返回的页面。这种方式有两个硬伤:体验差,页面会闪、会跳,用户填到一半的内容容易被清空;二是上传文件时,用户的浏览器和服务器之间发生了什么,前端完全无法感知,进度条只能靠后端的假请求或者轮询来模拟。
后来 XMLHttpRequest 出现了,前端可以异步发请求,不用刷新页面。但早期的 XHR 有个尴尬的地方:它发送的 body 格式和传统表单不一样,需要用encodeURIComponent手动拼接 key=value 字符串,文件这种二进制数据根本没法放进去。所以很长一段时间里,文件上传只能靠隐藏 iframe 这种曲线救国的方案,或者引入 Flash 控件,大家都被恶心过。
FormData出现后,这个痛点被彻底解决了。它允许你在 Ajax 请求里构造出一种和multipart/form-data格式一致的数据体,文本字段、File、Blob都能塞进去。这意味着表单和文件可以在一个请求里一起提交,不再需要先传文件再传表单字段这种拆两步的尴尬操作。在很多涉及“资料填写 + 附件上传”的场景里,这是唯一合理的方案。
1.2 构造函数和几个高频 API
FormData的构造很简单,两种姿势:
// 方式一:传入一个真实 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 id="userForm"> <input type="text" name="username" placeholder="姓名" /> <input type="email" name="email" placeholder="邮箱" /> <input type="file" name="avatar" accept="image/*" /> <button type="submit">提交</button> </form>方式一,直接把整个表单对象交给FormData:
document.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[name="username"]').value); formData.append('email', document.querySelector('input[name="email"]').value); formData.append('avatar', document.querySelector('input[name="avatar"]').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 type="file" name="photos" multiple accept="image/*" />对应的 JS 处理,注意同名append多次:
const formData = new FormData(); const files = document.querySelector('input[name="photos"]').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 + Ajax | JSON 提交 |
|---|---|---|---|
| 数据格式 | application/x-www-form-urlencoded或multipart/form-data | multipart/form-data | application/json |
| 二进制文件 | 支持,但页面会刷新 | 支持,且异步无刷新 | 不支持,需要先转 Base64 或 Blob |
| 请求体结构 | key=value 拼接,层级简单 | key=value 拼接,支持文件块 | 嵌套对象/数组,结构灵活 |
| 前端体验 | 整页跳转,无法做进度 | 无刷新,可监听进度 | 无刷新,无上传概念 |
| 服务端解析 | 框架自动解析 | 框架自动解析 | 框架自动解析 |
| 典型场景 | 老项目、跳转类页面 | 表单 + 文件混合提交 | 纯 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 编码的key=value格式完全不一样。它的核心是boundary分隔。请求体大致长这样:
POST /api/upload HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="username" 张三 ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="avatar"; filename="photo.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 为例,最常用的中间件是multer:
const 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-equiv="X-UA-Compatible" content="IE=edge">。
二是浏览器版本确实太老。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-data(JMeter 里叫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: zhangsan@example.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[type=file]拿文件,Canvas 生成的Blob、fetch回来的二进制流、甚至拖拽进来的本地文件,都可以用同样的方式 append 进去。所以它的应用边界比我一开始以为的要宽很多,很多“看似复杂”的交互,用原生FormData都能干净地实现。希望这篇文章能帮你绕开我踩过的那些坑。