1. 从一个让人抓狂的表单Bug说起
多文件上传这个功能,做过前端表单的人基本都碰过。表面上看逻辑特别简单:用户选几个文件,渲染一个列表,每个文件后面挂个删除按钮,点一下从列表里移除,最后提交表单。但真正动手写的时候,很多人会卡在一个非常反直觉的地方——你明明在页面上把文件删掉了,提交表单时后端收到的却还是原来那一堆文件。
这个问题的根源,藏在input[type=file]元素的FileList对象里。FileList是一个只读的类数组对象,它和文件输入框绑定在一起,你没法直接对它做splice、pop或者重新赋值。换句话说,用户通过文件选择器选中的文件,一旦进入FileList,就相当于被"焊死"在了那个 input 上。你在 Vue 或 React 的 data/state 里维护的数组删得干干净净,但真正跟着表单提交走的,还是那个 input 里的原始FileList。
这篇文章就是围绕这个坑展开的。我会把FileList为什么改不了、常见的几种错误写法、以及真正能落地的解决方案(包括DataTransfer重建、FormData 手动组装、以及配合组件库时的处理方式)完整拆一遍。适合正在做上传功能的前端开发者,尤其是用 Vue、React 配合 Element Plus、Ant Design 这类组件库的同学。看完之后,你不仅能修掉这个 bug,还能理解表单提交时文件到底是怎么被带上去的。
2. 为什么 FileList 删不掉:核心机制拆解
2.1 FileList 到底是什么
先把这个对象的本质说清楚。当你在页面上写一个:
<input type="file" multiple id="upload" />用户选中文件后,通过document.getElementById('upload').files拿到的就是一个FileList对象。它长得像数组,有length,能用下标访问files[0]、files[1],但它不是数组,没有push、splice、filter这些方法。
更关键的一点:FileList是只读的。规范里明确定义了它的属性都是只读的,你没法这样写:
// 这些全都是无效操作 input.files.length = 0; input.files[0] = newFile; input.files = myArray;最后一行input.files = myArray看起来好像能赋值,实际上浏览器会直接忽略或者抛错,因为files这个属性的 setter 在大多数实现里是不接受普通数组的。
2.2 表单提交时文件是怎么被带走的
理解这个问题的关键,是要搞清楚表单提交时到底读的是什么。
当你用原生的<form>提交,或者用new FormData(formElement)去序列化一个表单时,浏览器会去遍历表单里的每一个表单控件。对于input[type=file],它读取的就是这个 input 当前的files属性——也就是那个FileList。
所以整个链路是这样的:
- 用户选文件 → 浏览器把文件塞进 input 的
FileList - 你在 JS 里维护一个数组用于渲染列表 → 这个数组和
FileList是两份独立的数据 - 用户点删除 → 你只删了 JS 数组,
FileList纹丝不动 - 提交表单 → 浏览器读
FileList→ 被"删除"的文件还在
这就是为什么页面上看起来删干净了,后端却收到了全部文件。你操作的根本不是同一份数据。
2.3 为什么框架的响应式也救不了
用 Vue 的同学可能会想:我把files放在data里,用v-for渲染,删除的时候splice一下,响应式更新不就行了?
问题在于,你v-for渲染的那个数组,往往是你自己从FileList里"拷贝"出来的一个普通数组,比如:
handleChange(e) { this.fileList = Array.from(e.target.files); }这个fileList确实可以随便删,页面也会正确更新。但它和 input 的FileList已经没有任何关系了。提交的时候如果用的是new FormData(form),读的还是 input 的FileList,你的fileList根本没参与提交。
React 同理,useState里存的数组删得再干净,只要提交走的是原生表单序列化,一样白搭。
3. 三种典型错误写法,看看你中了几个
3.1 错误写法一:直接改 FileList
这是最直接的一种,新手很容易写出来:
// 想删除第一个文件 input.files.splice(0, 1); // 报错:splice is not a function input.files.length = 0; // 静默失败,length 改不动FileList没有数组方法,length也是只读的。这种写法要么报错,要么毫无效果,但页面上的列表如果用的是另一份数据,你会误以为删除成功了。
3.2 错误写法二:只删渲染数组,提交走原生表单
这种最隐蔽,因为页面上一切正常:
<input type="file" multiple @change="handleChange" ref="fileInput" /> <ul> <li v-for="(file, index) in fileList" :key="index"> {{ file.name }} <button @click="removeFile(index)">删除</button> </li> </ul>handleChange(e) { this.fileList = Array.from(e.target.files); }, removeFile(index) { this.fileList.splice(index, 1); }, submit() { const formData = new FormData(this.$refs.form); // 提交 formData,里面带的是 input 的原始 FileList }fileList删得干干净净,FormData里却还是全部文件。这个 bug 在测试的时候如果只测"选文件→提交",不测"选文件→删除→提交",很容易漏掉。
3.3 错误写法三:以为清空 input.value 就能清空文件
有人会尝试:
input.value = '';这行代码确实能清空 input 显示的文件名,也能让input.files变成空的FileList。但它是全部清空,不是删除某一个。如果你想保留一部分、删掉另一部分,这招没用。而且有些浏览器对value赋空字符串的行为处理不一致,不能作为精确删除的手段。
4. 真正能用的解决方案
4.1 方案一:用 DataTransfer 重建 FileList
这是最"正统"的做法。DataTransfer对象有一个items属性,可以通过DataTransferItemList来增删文件,最后把整个DataTransfer.files赋值给 input。
核心思路是:维护一个自己的文件数组,每次删除后,用剩下的文件重新构造一个DataTransfer,再把它塞回 input。
// 维护的文件数组 let selectedFiles = []; function handleChange(e) { // 把新选的文件合并进来 selectedFiles = Array.from(e.target.files); renderList(); } function removeFile(index) { // 从自己的数组里删 selectedFiles.splice(index, 1); // 重建 FileList 并塞回 input syncToInput(); renderList(); } function syncToInput() { const dt = new DataTransfer(); selectedFiles.forEach(file => { dt.items.add(file); }); input.files = dt.files; }这里的关键是input.files = dt.files这一行。注意,只有DataTransfer.files才能赋值给 input.files,普通数组不行。这也是为什么必须绕一圈用DataTransfer。
DataTransfer的兼容性现在很好,主流浏览器都支持。dt.items.add(file)可以接受File对象,添加进去之后dt.files就是一个新的、包含你指定文件的FileList。
注意:
DataTransfer在部分老版本浏览器里构造后items可能不可用,如果项目需要兼容很老的浏览器,建议做特性检测,降级到方案二。
4.2 方案二:放弃原生表单序列化,手动组装 FormData
如果你不想折腾DataTransfer,还有一个更省心的思路:提交的时候不用new FormData(form),而是自己手动 append。
function submit() { const formData = new FormData(); // 手动把其他表单字段加进去 formData.append('username', this.username); formData.append('title', this.title); // 只 append 你数组里剩下的文件 selectedFiles.forEach(file => { formData.append('files', file); }); // 发送请求 axios.post('/api/upload', formData); }这个方案的好处是彻底摆脱了FileList的束缚。你的selectedFiles数组就是唯一的数据源,删了就没了,提交时也只 append 剩下的。缺点是需要手动处理所有表单字段,如果表单字段很多,写起来会有点啰嗦。
实际项目里我一般推荐这个方案,因为它逻辑最清晰,不容易出幺蛾子。尤其是配合 axios 或 fetch 手动发请求的场景,本来就不走原生表单提交,用这个方案最自然。
4.3 方案三:配合组件库的处理方式
如果你用的是 Element Plus 的el-upload或者 Ant Design 的Upload,情况会好一些,因为这些组件内部已经帮你维护了文件列表,删除逻辑也是组件自己处理的。
以 Element Plus 为例:
<el-upload ref="uploadRef" :auto-upload="false" :file-list="fileList" :on-change="handleChange" :on-remove="handleRemove" multiple > <el-button>选择文件</el-button> </el-upload>function handleChange(file, fileList) { this.fileList = fileList; } function handleRemove(file, fileList) { this.fileList = fileList; } function submit() { const formData = new FormData(); this.fileList.forEach(item => { // el-upload 的 file 对象里,原始文件在 raw 属性上 formData.append('files', item.raw); }); // 提交 }这里的关键是:不要依赖组件内部的 input 去提交,而是从fileList里拿raw(原始 File 对象)自己组装FormData。组件库的fileList是响应式的,删除后会自动更新,你只要在提交时读它就行。
Ant Design 的Upload类似,fileList里的每一项有originFileObj,提交时用它。
踩坑提醒:Element Plus 的
el-upload在auto-upload=false时,file-list是受控的,你必须自己维护它,否则删除后组件内部状态和你的数据会对不上。这一点官方文档写得比较隐晦,我第一次用的时候也绕了半天。
5. 完整实操:从选文件到提交的闭环
5.1 需求拆解与数据结构设计
假设我们要做一个"多文件上传 + 可删除 + 带其他表单字段"的功能。先把数据结构定清楚:
selectedFiles:一个普通数组,存File对象,这是唯一的数据源formData:其他文本字段,比如标题、描述- 提交时:手动组装
FormData,把selectedFiles里的文件逐个 append
这个设计的好处是,selectedFiles完全由我们控制,增删改查都是普通数组操作,不涉及任何只读对象。
5.2 选文件与去重处理
用户可能会分多次选文件,也可能重复选同一个文件。这里要做合并和去重:
function handleFileChange(e) { const newFiles = Array.from(e.target.files); // 按 name + size + lastModified 去重 const existingKeys = new Set( selectedFiles.map(f => `${f.name}_${f.size}_${f.lastModified}`) ); newFiles.forEach(file => { const key = `${file.name}_${file.size}_${file.lastModified}`; if (!existingKeys.has(key)) { selectedFiles.push(file); existingKeys.add(key); } }); // 清空 input,保证下次选同一个文件还能触发 change e.target.value = ''; renderList(); }这里有两个细节值得说。第一,去重的 key 用name + size + lastModified组合,比单纯用name靠谱,因为不同文件可能重名。第二,处理完后把e.target.value清空,这样用户下次选同一个文件时change事件还能触发——否则浏览器会认为文件没变,不触发事件。
5.3 删除逻辑与 FileList 同步
删除就是普通的数组splice,但如果你还想让 input 的FileList保持同步(比如某些场景下需要 input 参与原生提交),就要用DataTransfer重建:
function removeFile(index) { selectedFiles.splice(index, 1); syncInputFiles(); renderList(); } function syncInputFiles() { const dt = new DataTransfer(); selectedFiles.forEach(file => dt.items.add(file)); fileInput.value.files = dt.files; }如果你走的是方案二(手动组装 FormData),syncInputFiles这步可以省掉,因为提交根本不读 input。
5.4 提交时的 FormData 组装
async function submit() { if (selectedFiles.length === 0) { alert('请至少选择一个文件'); return; } const formData = new FormData(); formData.append('title', title); formData.append('description', description); selectedFiles.forEach(file => { formData.append('files', file); }); const res = await fetch('/api/upload', { method: 'POST', body: formData }); // 处理响应 }注意formData.append('files', file)里的'files'是后端接收的字段名,多个文件用同一个字段名 append,后端就能拿到一个数组。这一点要和后端约定好,有的后端期望files[],有的期望files,别搞错了。
5.5 提交后重置状态
提交成功后,记得把selectedFiles清空,input 也清空:
selectedFiles = []; fileInput.value.value = ''; renderList();如果不清 input 的 value,用户再次选同一个文件可能不触发 change。这个细节很多人会忘。
6. 常见问题与排查速查表
6.1 问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 删除后提交,文件还在 | 提交读的是 input 的 FileList | 改用手动组装 FormData |
input.files.splice报错 | FileList 没有数组方法 | 用普通数组维护,或 DataTransfer 重建 |
input.files = array无效 | 只能赋值 FileList | 用DataTransfer.files |
| 删除后页面没更新 | 渲染数组和 FileList 不同步 | 统一数据源,删除后重新渲染 |
| 重复选同一文件不触发 change | input value 未清空 | 处理后e.target.value = '' |
| 组件库删除后提交仍有文件 | 提交走了组件内部 input | 从 fileList 取 raw 手动组装 |
| 后端收到的文件数量不对 | append 字段名不一致 | 和后端确认字段名 |
6.2 几个容易忽略的坑
坑一:DataTransfer构造后 items 为空。有些浏览器在new DataTransfer()之后,items需要先 add 才能用,直接读dt.files是空的。这是正常的,add 完再读就行。
坑二:File对象的lastModified在某些场景下不稳定。比如从剪贴板粘贴的文件,lastModified可能是当前时间。去重时如果发现异常,可以退化成只用name + size。
坑三:大文件场景下不要频繁重建 DataTransfer。每次删除都重建一次,如果文件很多很大,会有性能开销。可以做个防抖,或者只在提交前同步一次。
坑四:FormData里 append 的 File 对象,如果被后续操作修改了引用,可能出问题。一般不会,但如果你在 append 之后又去改selectedFiles,要注意FormData里存的是引用还是拷贝。实测下来,append之后FormData会持有文件的引用,所以提交前不要再动数组。
6.3 一个实用的调试技巧
如果你不确定提交时到底带了哪些文件,可以在发请求前打印一下:
for (const [key, value] of formData.entries()) { if (value instanceof File) { console.log(key, value.name, value.size); } else { console.log(key, value); } }这样能清楚看到FormData里实际有哪些字段和文件,比在 Network 面板里翻要直观。我排查这类问题时基本都靠这一招,几秒钟就能定位是数据源的问题还是提交的问题。
7. 我个人的几点实操体会
做过多文件上传之后,我现在的习惯是:永远不依赖 input 的 FileList 作为数据源。input 只负责触发选择事件,选完之后立刻把文件转成普通数组,后续所有增删改查都在这个数组上做,提交时手动组装FormData。这样虽然多写几行代码,但逻辑清晰,不会出现"页面删了、提交还在"这种诡异问题。
另外,DataTransfer这个方案虽然正统,但实际项目里我用得不多,因为它解决的是"让 input 的 FileList 和我的数组同步"这个问题,而如果我压根不走原生表单提交,这个问题就不存在。所以选方案的时候,先问自己一句:我到底走不走原生表单序列化?不走的话,方案二最省事。
最后提一个容易被忽略的点:删除文件时最好给用户一个确认,尤其是文件比较大的时候。用户误删一个大文件,重新选一遍很烦。这个不算技术问题,但体验上差别很大。