首页 / 资讯中心 / 文章详情

前端多文件上传删除失效?FileList只读机制与解决方案

前端多文件上传删除失效?FileList只读机制与解决方案 ★ FEATURED ARTICLE
1. 表单里那个“删不掉”的文件一个前端老手也翻过车的经典场景如果你写过带文件上传的表单大概率遇到过这种让人抓狂的情况用户选了三个文件点掉其中一个的删除按钮界面上那个文件确实消失了可一点提交后端收到的还是三个文件——被“删掉”的那个原封不动地躺在请求体里。你打开控制台反复确认代码明明执行了删除逻辑数组也变了可提交上去就是不对。这个问题的根子几乎都指向同一个东西input typefile元素的FileList对象。它不是一个普通的数组而是一个只读的、由浏览器底层维护的类数组对象。你可以读取它、遍历它但无法直接修改它——不能splice不能pop不能通过重新赋值来改变它的内容。很多人的第一反应是input.files newFileArray结果发现要么报错要么静默失败文件依旧我行我素。这篇内容就是围绕这个具体场景展开的。我会把“为什么删不掉”的底层原因讲透然后给出几种真正能落地的解决方案从最简单的“重建 input”到更工程化的“维护独立文件数组 FormData 手动拼装”每种方案都配上完整代码和适用边界。如果你正在做多文件上传、图片批量选择、附件管理这类功能或者你已经被这个坑卡了半天那接下来的内容应该能帮你彻底绕开它。需要先说明一点FileList的只读特性不是浏览器的 bug而是规范刻意为之。理解这一点你才不会总想着“找个 API 把它改掉”而是转向“换一种数据组织方式”。这也是我踩过几次坑之后最大的体会——跟规范较劲不如顺着规范换思路。2. 为什么FileList改不动从规范到浏览器实现的硬约束2.1FileList到底是什么和数组差在哪先把这个对象的身份说清楚。当你在页面上放一个input typefile multiple用户选完文件后这个 input 元素上会挂一个files属性它的类型就是FileList。它长得像数组有length能用下标files[0]访问也能用for循环遍历。但它不是Array的实例Array.isArray(input.files)返回的是false。这意味着你熟悉的那些数组方法它基本都没有。push、splice、pop、shift、filter、map这些要么不存在要么返回的是新数组而不是修改原对象。很多人写input.files.splice(0, 1)发现报错splice is not a function就是这个原因。更关键的是即使某些浏览器给FileList加了类似数组的方法它底层的文件集合也是由浏览器内部和 input 元素绑定的。你改了它浏览器不知道浏览器刷新了它你的改动就没了。这种“双向不同步”是很多诡异现象的来源。2.2 只读设计背后的逻辑安全与一致性为什么规范要把FileList设计成只读这不是故意为难开发者而是有几层考虑。第一层是安全。文件选择是用户主动发起的敏感操作。如果脚本可以随意往FileList里塞文件那就意味着网页可以在用户不知情的情况下把本地任意文件“伪装”成用户选择的文件提交上去。只读设计堵死了这条路——只有用户通过文件选择对话框选中的文件才能进入FileList。第二层是一致性。FileList和 input 元素是强绑定的。你看到的files就是 input 当前“认定”的用户选择。如果允许脚本直接改files那 input 的显示比如某些浏览器会显示已选文件名和实际提交内容就会脱节表单行为会变得不可预测。第三层是实现简单。浏览器不需要为每个FileList维护一套复杂的变更通知机制它只需要在用户操作时重建这个对象即可。这也是为什么下面要讲的方案里“重建 input”往往是最省事的一种。理解了这三点你就明白任何试图原地修改FileList的方案方向都是错的。正确的思路只有两条——要么让浏览器帮你重建一个干净的FileList要么干脆不用FileList来管理提交数据。2.3 一个容易混淆的点DataTransfer能不能救场搜这个问题的时候你可能会看到有人提到DataTransfer。它的思路是创建一个新的DataTransfer对象把想保留的文件append进去然后把它赋值给 input 的files。代码大概长这样const dt new DataTransfer(); dt.items.add(file1); dt.items.add(file2); input.files dt.files;这个方案在不少现代浏览器里确实能跑通因为input.files的 setter 接受一个FileList而DataTransfer.files正好是FileList类型。但它有几个坑一是兼容性并不完美某些环境或旧版本里DataTransfer的构造函数行为不一致二是它本质上还是在“重建”只是借了DataTransfer这个中间容器三是如果文件很多频繁重建DataTransfer也有性能开销。所以我的建议是DataTransfer可以作为方案之一但不要把它当成唯一解。真正稳妥的做法是下面要讲的“独立数组 手动拼 FormData”它不依赖任何浏览器对FileList可写性的支持兼容性最好。3. 方案一重建 input 元素让浏览器给你一个干净的 FileList3.1 核心思路与完整代码这是最直观、最容易理解的方案。既然FileList改不动那我就不改它——我直接把整个 input 换掉或者把它的value清空然后重新把保留的文件“放”回去。但这里有个细节你不能直接把文件放回 input因为 input 的files还是只读的。所以真正可行的做法是用一个独立的数组来记录“当前保留的文件”每次删除后用这个数组重建一个DataTransfer再赋给 input。先看一个完整的实现// 用一个独立数组维护当前选中的文件 let selectedFiles []; const input document.getElementById(fileInput); const listEl document.getElementById(fileList); // 用户选择文件时把 FileList 转成普通数组存起来 input.addEventListener(change, (e) { selectedFiles Array.from(e.target.files); renderList(); }); // 渲染文件列表每个文件带一个删除按钮 function renderList() { listEl.innerHTML ; selectedFiles.forEach((file, index) { const item document.createElement(div); item.className file-item; item.innerHTML span${file.name} (${(file.size / 1024).toFixed(1)} KB)/span button typebutton>let selectedFiles []; const input document.getElementById(fileInput); const listEl document.getElementById(fileList); const submitBtn document.getElementById(submitBtn); // 选择文件追加到数组支持多次选择累加 input.addEventListener(change, (e) { const newFiles Array.from(e.target.files); // 去重按 name size lastModified 判断 newFiles.forEach(file { const exists selectedFiles.some(f f.name file.name f.size file.size f.lastModified file.lastModified ); if (!exists) selectedFiles.push(file); }); // 清空 input 的 value这样同一个文件还能再次被选中 input.value ; renderList(); }); function renderList() { listEl.innerHTML ; if (selectedFiles.length 0) { listEl.innerHTML p classempty还没有选择文件/p; return; } selectedFiles.forEach((file, index) { const item document.createElement(div); item.className file-item; item.innerHTML span classfile-name${file.name}/span span classfile-size${formatSize(file.size)}/span button typebutton classdel-btn>form.addEventListener(reset, () { selectedFiles []; renderList(); });第二个坑是动态添加的 input。有些表单会根据用户操作动态插入新的文件 input每个 input 都有自己的FileList。这时候你要么统一管理所有 input要么在提交前遍历所有 input 收集文件。后者更简单提交前用querySelectorAll(input[typefile])拿到所有 input把它们的files合并成一个数组再统一处理。5.2 用隐藏 input 承载最终文件集合还有一个比较巧妙的做法在表单里放一个隐藏的、真正用于提交的 input用户看到的那个 input 只负责选择文件选完后我们把文件同步到隐藏 input 上。删除时我们重建隐藏 input 的FileList。这样用户交互的 input 和提交的 input 分离逻辑更清晰。!-- 用户交互用的 input不直接提交 -- input typefile idpicker multiple !-- 真正提交用的 input隐藏 -- input typefile namefiles idsubmitter multiple styledisplay:noneconst picker document.getElementById(picker); const submitter document.getElementById(submitter); let selectedFiles []; picker.addEventListener(change, (e) { Array.from(e.target.files).forEach(f { if (!selectedFiles.some(s s.name f.name s.size f.size)) { selectedFiles.push(f); } }); picker.value ; syncSubmitter(); renderList(); }); function syncSubmitter() { const dt new DataTransfer(); selectedFiles.forEach(f dt.items.add(f)); submitter.files dt.files; }这个做法的好处是submitter的files始终和selectedFiles一致原生表单提交时读到的就是正确的集合。而picker只负责触发选择它的FileList是什么无所谓。两者职责分离调试起来也更容易定位问题。5.3 兼容性兜底当 DataTransfer 不可用时前面提过DataTransfer的构造函数在极少数环境下可能不可用。这时候的兜底方案是重建整个 input 元素。思路是把原 input 从 DOM 里移除创建一个新的同类型 input把保留的文件通过某种方式放进去。但问题是新 input 的files也是只读的你怎么把文件放进去答案是放不进去。如果DataTransfer不可用你就无法用脚本给 input 的files赋值。这时候唯一的出路就是放弃原生提交改用手动FormData。也就是说方案三在极端兼容性场景下会退化成方案二。这也是为什么我一直强调方案二最稳妥——它不依赖任何“给 input 赋值”的能力。所以我的建议是新项目直接上方案二别在原生提交上纠结。老项目如果改不动提交逻辑再用方案三并且做好“DataTransfer 不可用就降级”的准备。6. 几个容易忽略的细节去重、内存、大文件与用户体验6.1 去重判断为什么不能只看文件名前面提过去重要用name size lastModified这里展开说一下原因。假设用户有两个文件都叫report.pdf一个在桌面一个在下载文件夹大小不同。如果只按名字去重第二个会被误判为重复而丢弃用户会发现“我明明选了两个怎么只上传了一个”。加上size就能区分开大部分情况。但还有更极端的两个文件名字相同、大小也相同但内容不同比如一个是旧版一个是新版。这时候lastModified就派上用场了——文件修改时间不同就能区分。当然这三个字段组合也不是绝对可靠。理论上存在名字、大小、修改时间都相同但内容不同的文件但概率极低实际项目中不用为此过度设计。如果你真的需要严格去重可以在选择时计算文件的哈希值但那会带来明显的性能开销得不偿失。6.2 大文件场景下的内存与性能考量当用户选择几十个大文件时selectedFiles数组里存的是File对象。File对象本身不包含文件内容它只是一个引用所以数组本身占用的内存很小。真正占内存的是你读取文件内容的时候比如用FileReader读成ArrayBuffer。只要你不在选择阶段读取内容内存压力就不大。但DataTransfer重建法在大文件场景下要注意每次删除都重建DataTransfer如果文件很多这个操作会有一定开销。虽然现代浏览器优化得不错但如果你要处理上百个文件建议还是走方案二避免频繁重建。另外提交时FormData会把所有文件打包进请求体。如果文件总量很大请求可能超时或被服务器拒绝。这时候要考虑分片上传或并发上传但那是另一个话题了本文不展开。6.3 用户体验上的小技巧删除确认与撤销删除文件是个不可逆操作至少对用户来说他得重新选一次。所以加一个简单的确认提示是值得的。但不要用confirm()那个太丑且阻塞。可以用一个轻量的 toast 或者行内提示。更进一步可以做一个“撤销删除”的功能删除时不是真的从数组里移除而是标记为“待删除”界面上灰显给用户几秒钟的撤销窗口。几秒后再真正移除。这个体验会好很多实现上也不复杂——给每个文件加一个deleted标记渲染时根据标记显示不同样式提交时过滤掉标记为删除的。// 带撤销的删除思路 function markDelete(index) { selectedFiles[index]._deleted true; renderList(); setTimeout(() { if (selectedFiles[index] selectedFiles[index]._deleted) { selectedFiles.splice(index, 1); renderList(); } }, 5000); }当然这个方案要注意索引变化的问题——如果用户在撤销窗口内又删了别的文件索引会错位。更稳妥的做法是给每个文件分配一个唯一 id用 id 来定位而不是索引。这个细节在实际项目中很容易被忽略导致撤销功能出现“删错文件”的 bug。7. 排查这类问题的通用思路先分清数据源再定位同步点7.1 一张表看清“删了还在”的几种成因遇到“删除后提交还在”的问题不要急着改代码先按下面的表对号入座定位到底是哪一层出了问题。现象可能原因排查方法界面删了数组也删了提交还在提交用的是原生表单input 的 FileList 没同步检查提交逻辑是new FormData(form)还是手动拼界面删了数组没删删除事件绑错了或索引错位在删除回调里打印数组长度界面没删数组删了渲染函数没重新执行或渲染用的是旧数据检查 renderList 是否在删除后被调用删一个结果删了多个索引错位或事件冒泡导致多次触发检查>
阅读完成 · 觉得有帮助?
咨询建站