简介:面向Web前端初学者与开发者的HTML5多图片上传预览示例源码,聚焦File API与拖放API的本地文件读取和即时预览,无需服务器端交互即可完成多图选择、缩略图展示与拖拽上传交互,适合用来理解现代浏览器文件处理流程。压缩包共18个文件、约145KB,以9张PNG界面素材、5个JavaScript脚本、1个HTML页面、1个CSS样式及2个链接文件组成,整体轻量,便于直接运行和二次改造。已有1472人学习。源码已测试,完整呈现change事件遍历多文件、FileReader的readAsDataURL转换Base64数据URL、闭包保存当前文件对象、drop与dragover事件阻止浏览器默认行为等关键实现,并配有界面样式和图片资源。读者可据此快速搭建头像上传、相册预览、表单附件等场景,也能对照代码掌握多文件异步读取、预览节点动态创建和拖拽交互的排错思路。
1. 一个表单塞十几张图,问题从来不在后端
后台管理系统里加一个「商品图上传」,十有八九会先做成单张:点一次选一张,选完看不见缩略图,传上去才发现拿错了文件。其实 html5 的 File API 加上 input 的 multiple 属性,早就把「多选 + 本地预览 + 上传前压缩」这条路铺平了,全部用原生 JS 就能写完,不需要任何上传插件,也不需要把图片先传到服务器再回显。这份源码做的就是把它走完整:多选、缩略图、拖拽、单张删除、前端压缩、FormData 提交,几个静态文件用浏览器直接打开就能跑,已测过主流桌面浏览器和移动端。适合正在给后台补上传模块的前端,更适合想搞清楚 FileReader 和 URL.createObjectURL 到底差在哪的人——后者在真实项目里踩坑的概率,比多数人想象的高得多。
2. FileReader 还是 createObjectURL:两条预览路线的实测取舍
2.1 内存、时机与可回收性:两条路线的真实差别
预览的本质是「把用户本地选中的 File 对象变成一个 img 能显示的地址」。浏览器只给了两条路:FileReader.readAsDataURL()把文件内容读成一段 base64 字符串,或者URL.createObjectURL(file)让浏览器内部生成一个blob:开头的短地址。初学者一般先学会前者,因为它看得见摸得着——reader.result就是一串能直接塞进src的字符串。但这段字符串的代价被严重低估了:base64 编码会把体积放大约三分之一,而且它是一段实实在在存在 JS 堆里的字符串。用户一次选 20 张 4MB 的原图,堆上就压着 100MB 以上的字符串,用户再删掉几张,这些字符串也不会立刻消失,要等垃圾回收。页面上再挂十几个预览图节点,图片解码内存另算。
createObjectURL完全是另一个模型。它返回的字符串只有几十个字节,本质是一个指向浏览器内部 Blob 的引用,图片数据不复制、不进 JS 堆,浏览器可以把它放在更适合大对象的地方。代价是引用必须手动还回去——URL.revokeObjectURL(url),否则这个 Blob 会一直挂着,直到页面关闭。这就是典型的两条路线:一条省心但费内存,一条省内存但要自己收尾。
时机上也有差别。createObjectURL是同步的,调用完立刻能赋值给img.src;FileReader是异步的,onload什么时候触发取决于文件大小和磁盘速度。多张图一起读的时候,回调顺序完全不保证跟选择顺序一致。如果业务要求缩略图按选择顺序排列——商品图几乎都要求——就必须自己在闭包里记住索引,回调里往固定位置插,不能简单appendChild。这个细节在演示代码里极少出现,一到真实项目就翻车。
| 对比项 | FileReader → dataURL | createObjectURL → blob URL |
|---|---|---|
| 返回时机 | 异步,onload 后可用 | 同步,调用即用 |
| 内存占用 | 约为原图 1.33 倍,占用 JS 堆 | 仅一个引用,图片数据不复制 |
| 是否需手动释放 | 否,随字符串回收 | 是,必须 revokeObjectURL |
| 能否用于下载/跨页 | 能,字符串可存可传 | 不能,页面关闭即失效 |
| 适合场景 | 单图、需要把图像数据发给后端或存起来 | 多图批量预览、大图预览 |
2.2 用 FileReader 跑通最小可用版本
先把骨架写出来,一个 input、一个容器,够用就行。HTML 部分没什么花活,关键是multiple和accept两个属性——它们只是给用户和选择器的「提示」,不是安全边界,后面还要再校验一次。
<!-- index.html:最小可用的多图预览骨架 --> <input id="picker" type="file" accept="image/*" multiple> <div id="preview" class="preview-grid"></div>接着是脚本。注意e.target.files拿到的是 FileList,是个类数组对象,不能直接map/filter,先转成真数组再处理,否则后面写起来处处别扭。
// preview-filereader.js:FileReader 路线,兼容性最好 const picker = document.getElementById('picker'); const preview = document.getElementById('preview'); picker.addEventListener('change', (e) => { const files = Array.from(e.target.files || []); // FileList 是类数组,先转真数组 files.forEach((file, index) => { // accept 只是选择器过滤,用户切到「所有文件」照样能选进来,必须二次校验 if (!file.type.startsWith('image/')) { console.warn('跳过非图片文件', file.name); return; } if (file.size > 10 * 1024 * 1024) { // 单张 10MB 上限,按业务调整 console.warn('跳过超大文件', file.name); return; } const reader = new FileReader(); reader.onload = () => { const img = document.createElement('img'); img.src = reader.result; // data:image/jpeg;base64,xxxx img.alt = file.name; img.width = 120; // 缩略图尺寸交给 CSS 更好,这里兜底 img.dataset.index = String(index); // 记录顺序,排序时用得上 preview.appendChild(img); }; reader.onerror = () => console.warn('读取失败', file.name, reader.error); reader.readAsDataURL(file); // 异步执行,回调顺序不保证 }); });这段代码有三个地方值得单独说。第一处是Array.from:转成真数组之后才能安全地forEach并在回调里闭包捕获index,如果你直接用for...of遍历 FileList 也是可以的,但不要在for循环里用var或者共享变量,回调执行时变量早就变了。第二处是二次校验:accept="image/*"在桌面端只是让文件选择框默认过滤,用户手动切成「所有文件」照样能选进来一个.zip,file.type会是空的或者非 image 开头,必须在change里挡掉。第三处是reader.result的类型:readAsDataURL返回字符串,readAsArrayBuffer返回 ArrayBuffer,readAsText返回文本,别混着用,img.src只认前者的产物。
把它跑起来以后你会发现一个问题:选 10 张图,缩略图出现的顺序跟你在选择框里点选的顺序经常对不上,大的先出、小的后出是常态。这就是前面说的异步时序问题。如果顺序必须严格一致,有两个办法:一是先把所有 dataURL 读进一个 Promise 数组,Promise.all之后再统一渲染;二是回调里不直接appendChild,而是往一个按索引编号的占位容器里插。前者实现简单但会有一个空窗期,体验稍差;后者体验好,代码多二十行。这个源码用的是第二种,思路可以照着改。
2.3 换成 createObjectURL:省内存但要自己收尾
把上面那段改成 blob URL,改动其实很小,但整个生命周期管理就变了——你得额外维护一份「文件 → URL」的映射,删除某张图的时候把对应的 URL 还回去。
// preview-bloburl.js:createObjectURL 路线,内存更省,但必须手动释放 const picker = document.getElementById('picker'); const preview = document.getElementById('preview'); const urlPool = new Map(); // 记录 文件 -> objectURL,删除时用来 revoke function addPreview(file) { if (!file.type.startsWith('image/')) return null; const url = URL.createObjectURL(file); // 同步返回,不复制像素数据 const img = document.createElement('img'); img.src = url; img.alt = file.name; img.dataset.url = url; // 把 url 挂在节点上,删除时直接取 img.addEventListener('load', () => { // 图片解码完成后才能拿到真实宽高,做等比缩放要放在这里 console.log(file.name, img.naturalWidth, img.naturalHeight); }); preview.appendChild(img); urlPool.set(file, url); return img; } picker.addEventListener('change', (e) => { for (const file of e.target.files) addPreview(file); }); // 删除某一张:节点移除 + 引用归还,两件事必须一起做 function removePreview(img) { const url = img.dataset.url; img.remove(); if (url) URL.revokeObjectURL(url); for (const [file, u] of urlPool) { if (u === url) urlPool.delete(file); } }跟 FileReader 版本比,这段的收益在批量场景下非常明显:50 张图不会在内存里堆出几十兆的 base64 字符串,缩略图是同步出现的,没有回调乱序的问题。img.dataset.url那一行是关键设计——把 URL 绑在 DOM 节点上,删除逻辑就不用再回头查文件对象,节点本身就是句柄。naturalWidth/naturalHeight必须等load事件,img.width是布局宽度,跟原图尺寸没关系,这块混淆的人不少。
注意事项要落在回收上:只调remove()不调revokeObjectURL,页面上的节点没了,但浏览器内部的 Blob 引用还在,反复上传删除几次,内存曲线就会变成一条只涨不跌的台阶。这也是我在第 5 章要单独拎出来的第一条坑。两条路线怎么选,我的习惯是:单图头像上传、需要把图像数据当字符串处理(比如塞进 localStorage 或者发给只收 base64 的接口)用 FileReader;批量预览一律 createObjectURL,然后把 revoke 写进删除函数里,当成一条硬规矩。
3. 多选与拖拽落地:multiple、accept、DataTransfer 的三处硬边界
3.1 multiple 和 accept 到底约束了什么
multiple只做一件事:让系统文件选择框允许多选,用户按住 Ctrl 或者框选就能一次选中多个,选完input.files里会按选择顺序排列。它不限制数量上限,用户一次框选 200 张也照收,所以「最多 9 张」这种业务限制必须自己数。accept更弱,它只影响选择框的默认过滤规则,不是白名单,前端的真正校验一定在change里。
| 属性/字段 | 作用 | 常见误用 |
|---|---|---|
multiple | 允许一次选多个文件 | 以为能限制数量,实际不限制 |
accept="image/*" | 选择框默认过滤图片 | 当作安全校验,不再检查file.type |
capture="environment" | 移动端优先调后置摄像头 | 桌面端无效,且会导致只能拍照不能选相册 |
file.type | 浏览器按扩展名推断的 MIME | 完全信任它做服务端判断 |
实际项目里我会同时上两道校验:前端按type和size拦一遍,给用户即时反馈;后端按文件头字节再判一次,因为file.type是可以伪造的,改个扩展名就能绕过。前端这道校验的价值不在安全,在于省一次无谓的网络往返。
// 统一的准入校验,前端只做「明显不对」的拦截 const ALLOW = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; const MAX_SIZE = 5 * 1024 * 1024; // 单张 5MB,按后端网关限制来定 function validateFile(file) { if (!ALLOW.includes(file.type)) return `格式不支持:${file.type || '未知'}`; if (file.size > MAX_SIZE) return `超过 ${MAX_SIZE / 1024 / 1024}MB`; if (file.size === 0) return '空文件'; // 某些拷贝中断的文件会是 0 字节 return null; // null 表示通过 }capture这个属性要单独提醒一句:很多人为了「移动端能拍照」直接写上capture,结果用户在手机上只能调起相机,想从相册选反而不行了。真正稳妥的写法是给两个入口——一个accept="image/*"的普通 input 走相册,一个带capture="environment"的走拍照,让用户自己选。
3.2 拖拽上传:DataTransfer 与拖入文件夹
拖拽比点选体验好,但坑也多。最基础的一步是先阻止默认行为,否则浏览器会把拖进来的图片直接打开,整个页面被替换掉——这个现象很多人第一次遇到会以为是代码写错了。
// 拖拽区:四个事件都要挡默认行为,少一个都会出问题 const dropZone = document.getElementById('dropZone'); ['dragenter', 'dragover'].forEach((type) => { dropZone.addEventListener(type, (e) => { e.preventDefault(); // 不阻止,浏览器会尝试打开/下载这个文件 e.stopPropagation(); dropZone.classList.add('is-active'); }); }); ['dragleave', 'drop'].forEach((type) => { dropZone.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('is-active'); }); }); dropZone.addEventListener('drop', async (e) => { const dt = e.dataTransfer; const files = []; const items = dt.items; if (items && items.length && items[0].webkitGetAsEntry) { // 拖进来的是文件夹时会走到这里,files 里拿不到里面的内容 for (const item of items) { const entry = item.webkitGetAsEntry(); if (entry) await walkEntry(entry, files); } } else { files.push(...dt.files); // 老浏览器或不支持 entry 时走兜底 } files.forEach((f) => addPreview(f)); });真正麻烦的是拖文件夹。用户在资源管理器里拖一个图片文件夹进来,dataTransfer.files里往往只有一个条目,甚至为空,必须靠webkitGetAsEntry()拿到FileSystemEntry对象再递归。这个 API 名字带前缀,但主流浏览器都实现了,属于事实标准。递归的时候有个隐蔽的坑:reader.readEntries()一次最多返回 100 个条目,超过的部分要靠回调里继续读,不循环就会「文件夹里明明有 200 张,只进来了 100 张」。
// 递归读取拖入的目录;readEntries 一次最多 100 条,必须反复读到空为止 function walkEntry(entry, out) { return new Promise((resolve) => { if (entry.isFile) { entry.file((f) => { out.push(f); resolve(); }, () => resolve()); return; } const reader = entry.createReader(); const readBatch = () => { reader.readEntries(async (entries) => { if (!entries.length) return resolve(); // 读空了才结束 for (const e of entries) await walkEntry(e, out); readBatch(); // 继续读下一批 }, () => resolve()); }; readBatch(); }); }递归本身是异步的,drop事件里用await拿结果,处理完再统一渲染,别边读边渲染——拖一个大文件夹进来会触发大量 DOM 操作,主线程直接卡住。
3.3 FormData 组装:字段名、文件名与 Content-Type
预览做完了,最后一步是把文件送出去。用FormData是最省事的,它自带multipart/form-data边界,不需要手动拼。这里最容易出问题的是三处:字段名跟后端对不上、文件名丢了、以及手贱去设置Content-Type。
// 组装并提交:字段名、文件名、Content-Type 三处最容易错 async function submit(files, bizId) { const fd = new FormData(); files.forEach((file, i) => { // 第三个参数显式指定文件名;不传的话部分场景下会变成 blob fd.append('images[]', file, file.name); fd.append('sorts[]', String(i)); // 顺序单独传,别指望后端按数组下标猜 }); fd.append('bizId', String(bizId)); const res = await fetch('/api/upload', { method: 'POST', body: fd, // 千万不要手动设置 Content-Type,浏览器需要自己补 boundary }); if (!res.ok) throw new Error(`上传失败:${res.status}`); return res.json(); }字段名images[]这种带方括号的写法是给 PHP、部分 Java 框架用的,Node 端常见的是重复同一个键images,后端按数组接收。这个要跟后端对齐,不能自己拍脑袋。第三个参数file.name看着多余,实际上有场景会出问题:从剪贴板粘贴进来的图片、或者 canvas 转出来的 Blob,name可能是空字符串,后端按文件名存盘时会拿到一个时间戳或者报错,所以压缩之后生成的 Blob 我会主动包成new File([blob], 'xx.jpg', { type })再 append。
4. 上传前压缩:canvas + EXIF 方向校正的完整链路
4.1 为什么压缩前必须先解决方向
手机拍的照片有一个历史遗留问题:像素数据是按传感器方向存的,正确的显示方向记在 EXIF 的 Orientation 标签里。浏览器直接显示<img src>时,现代版本基本都会自动应用这个方向,所以你在预览里看到的是正的。但一旦进入 canvas 处理链路,情况就分裂了——drawImage拿到的是按像素绘制的图,老版本浏览器和部分 WebView 不做方向校正,压出来就是躺倒的。用户看到预览是正的,上传完变成歪的,这种问题查起来极其费劲,因为两端看到的都对,只有中间那一步是错的。
我的做法是尽量走createImageBitmap(file, { imageOrientation: 'from-image' }),这个选项明确告诉浏览器「解码时就把方向应用掉」,产出的 ImageBitmap 已经是正的了。不支持这个选项的环境(老一点的 WebView)回退到new Image()+URL.createObjectURL,再手动读 EXIF 前 64KB 判断 Orientation,需要的话在 canvas 上做一次旋转。这套兜底代码不长,但没有它,安卓上的老机型就是会给你惊喜。
4.2 压缩参数的取值边界
压缩不是越小越好,参数定错的后果是用户投诉「图糊了」。三个参数各有各的边界:
| 参数 | 常用取值 | 定值依据 | 调过头的后果 |
|---|---|---|---|
最长边maxEdge | 1280~1920 | 详情页最大展示宽度 × 2 倍屏 | 小于 1000 时细节文字糊成一团 |
质量quality | 0.8~0.85 | JPEG 在 0.8 以上肉眼几乎无损 | 低于 0.7 出现明显块状噪点 |
| 输出格式 | image/jpeg | 兼容性最好、体积最小 | 用 PNG 体积翻数倍;用 WebP 老 Safari 不认 |
| 透明处理 | 铺白底 | JPEG 不支持 alpha 通道 | 不铺底会变成黑块/黑边 |
maxEdge怎么定,看图片最终展示位置。列表页缩略图 1600 都嫌浪费,商品详情主图 1920 才够。质量给 0.82 是我这几年用得最顺的一个值,多数场景看不出差别,体积能压到原图的 15%~30%。还有个细节:压缩只对「本来就大」的图有意义,原图 800×600 你还走一遍 canvas,只会因为重新编码让体积变大,所以进压缩函数之前先判一下naturalWidth,小图直接原样用。
4.3 压缩结果回填:Blob 而不是 base64
输出这一步很多人顺手写成canvas.toDataURL('image/jpeg', 0.8),拿到一串 base64 再去上传,体积直接多出三分之一,前面省的全白省了。正确做法是canvas.toBlob(),拿二进制,直接 append 进 FormData。
// 压缩主函数:解码时校正方向 -> 等比缩放 -> 铺底 -> 输出 Blob async function compress(file, { maxEdge = 1600, quality = 0.82, mime = 'image/jpeg' } = {}) { // imageOrientation: 'from-image' 让浏览器在解码阶段就把 EXIF 方向应用掉 const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)); if (scale === 1 && file.size < 500 * 1024) { // 本来就小,别白折腾一次重编码 bitmap.close?.(); return file; } const w = Math.round(bitmap.width * scale); const h = Math.round(bitmap.height * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; // JPEG 无 alpha,先铺白底防透明区变黑 ctx.fillRect(0, 0, w, h); ctx.drawImage(bitmap, 0, 0, w, h); bitmap.close?.(); // 及时释放解码后的位图 const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, quality)); if (!blob) throw new Error('toBlob 返回空,可能是跨域图片污染了 canvas'); // 包成 File,保证文件名和 type 不丢,后面 append 到 FormData 才有名字 return new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: mime }); }几个参数的作用是这样的:maxEdge控制最长边,scale算出来大于 1 就说明原图比目标还小,直接返回原文件能省一次无谓的重编码;quality只对image/jpeg和image/webp有效,传 PNG 的时候会被忽略;fillRect铺底那两行,只有处理带透明通道的 PNG 才有感,但不铺的话透明区域在 JPEG 里会变成黑色,视觉上非常难看。bitmap.close()是可选的,但批量处理几十张图的时候不加,解码后的位图占的内存会一直堆着。
有一条边界必须说清楚:跨域图片会污染 canvas,toBlob会抛安全错误或者返回 null。这里处理的是用户本地选择的 File 对象,属于同源数据,不会触发;但如果你的页面还支持「从图片 URL 导入」,那就得给那个img加crossOrigin="anonymous",并且服务端要返回允许跨域的响应头,否则压缩一定失败。
5. 避坑与排查:五条翻车记录
5.1 页面开一小时,内存只涨不降
现象:上传模块上线几天后,有用户反馈「传了几十张图之后整个后台越来越卡,切走再回来才好」。用开发者工具的内存快照看,detached DOM 节点越积越多。
原因:createObjectURL生成的引用没有归还。节点remove()掉了,但浏览器内部的 Blob 引用还挂着,每个都是一个完整的图片文件大小。这个问题在只上传一两张的场景下几乎看不出来,批量场景下就是灾难。
解决:把 revoke 写进删除函数,并且用Map记录 文件→URL 的映射,节点移除和引用归还绑成一件事。如果是整个列表清空,就遍历 Map 全部 revoke 再map.clear()。我的习惯是在封装的addFiles里就不暴露裸的 URL,只暴露remove(index),让调用方没有机会漏掉回收。
5.2 一次拖入 50 张,标签页直接假死
现象:拖一个文件夹进去,页面白屏十几秒,风扇狂转,控制台没有任何报错。
原因:三个动作同时打到主线程——50 个文件的解码、50 个appendChild、以及每个img的布局重排。解码本身是异步的,但回调全部挤在同一个时间片里执行,主线程被占满,页面自然没响应。
解决:加并发闸门。用一个简单的计数池,同时只处理 4~6 个文件,处理完一个再放一个进来;缩略图先渲染一个灰色占位块,等load之后再替换。另外给每次批量操作加一个上限提示,超过 30 张直接告诉用户分批选,比硬扛着卡死体验好。这条经验是很早以前踩出来的,从那以后凡是跟文件相关的批量操作,我都会先想一遍并发数。
5.3 手机拍的照片方向是歪的、格式还不认
现象:桌面端一切正常,iPhone 用户上传后图片旋转了 90 度;另一些用户上传直接失败,抓包看到content-type: image/heic。
原因:方向问题是 EXIF Orientation 没在解码阶段应用,不同浏览器和 WebView 的处理不统一。格式问题出在accept="image/*"上——iOS 选 HEIC 格式的原图时,部分系统版本会原样交给页面,而这个格式后端和多数图片库都处理不了。
解决:解码统一走createImageBitmap(file, { imageOrientation: 'from-image' }),老环境回退到手动读 EXIF。格式上前端用白名单拦住heic/heif,给出「请在相册设置里选择兼容格式」的提示,或者在有条件时用 canvas 转成 JPEG——但注意 HEIC 在很多浏览器里连解码都做不到,能拦就拦,别指望前端转码。
5.4 上传后的文件比原图还大
现象:前端日志里明明打了「压缩完成,从 3.2MB 降到 600KB」,服务端收到的却是 4.1MB。
原因:压缩拿到的 Blob 被转成了 base64 再上传。base64 编码体积膨胀约 33%,3.2MB 的图转完就是 4.2MB,之前压的全白费。这个问题在只测小图的时候发现不了,因为小图本来就低于阈值,走的是「原样返回」的分支。
解决:压缩函数的输出类型统一成 Blob 或 File,FormData.append直接塞二进制。如果后端接口只收 base64,那没别的办法,只能接受膨胀,但至少要把压缩比例算进去,别让用户以为压缩生效了。这类问题排查起来最累,因为两端的日志都是「正常」的,只有实际收到的字节数不对。
5.5 同一个文件选第二次,什么反应都没有
现象:用户选了a.jpg,预览出来了;删掉之后想重新选同一个a.jpg,点确定没反应,change事件根本不触发。
原因:input.value还停在上一次的文件路径上,浏览器认为「值没变」,所以不派发change。这在「先删后重选同一张」的场景下必现。
解决:在处理完一批文件之后把picker.value = ''清空。但要注意清空的时机——如果change回调里还有异步的FileReader在跑,而你又用e.target.files去遍历,清空之后FileList会被一起清掉,拿到空数组。所以正确顺序是:先把e.target.files转成真数组保存下来,再清空value,后续所有操作都基于这份数组。这个顺序颠倒一次,就会出现「有时候能传有时候不能传」的玄学现象,排查成本极高。
6. 封装与验收:把预览组件收成一块可复用的代码
6.1 一个带回收的 addFiles 封装
把前面几章的东西收成一个函数,核心只有三件事:统一校验、统一回收、把「文件 → 节点 → URL」三者绑在一起,外面只暴露add和clear两个方法。
// uploader.js:把校验、回收、渲染收拢到一个闭包里 function createUploader({ container, maxCount = 9, maxEdge = 1600 }) { const items = []; // { file, url, el } function add(fileList) { const list = Array.from(fileList); for (const file of list) { if (items.length >= maxCount) break; // 数量上限在这里兜 if (!file.type.startsWith('image/')) continue; const url = URL.createObjectURL(file); const el = document.createElement('img'); el.src = url; el.dataset.url = url; el.addEventListener('click', () => remove(el)); // 点图即删,示例用 container.appendChild(el); items.push({ file, url, el }); } } function remove(el) { const i = items.findIndex((it) => it.el === el); if (i === -1) return; URL.revokeObjectURL(items[i].url); // 回收和移除必须成对 el.remove(); items.splice(i, 1); } function clear() { items.forEach((it) => URL.revokeObjectURL(it.url)); items.length = 0; container.innerHTML = ''; } // 只暴露必要的:拿文件列表和清空,别把内部数组漏出去 return { add, clear, getFiles: () => items.map((it) => it.file) }; }这个结构的好处是调用方不需要知道 URL 的存在,也就不可能漏掉回收。getFiles()返回的是原始 File 数组,压缩和提交在外部做,职责分得开。
6.2 交付前必跑的验收清单
| 检查项 | 操作 | 期望结果 |
|---|---|---|
| 同文件重选 | 选a.jpg→ 删除 → 再选a.jpg | 两次都触发预览 |
| 数量上限 | 一次框选 30 张(上限 9) | 只进 9 张,无报错 |
| 类型拦截 | 手动切「所有文件」选.zip | 被拦下且有提示 |
| 内存回收 | 反复添加删除 20 轮 | 内存曲线回落,无 detached 节点 |
| 方向校正 | 手机竖拍照片上传 | 方向正确,不躺倒 |
| 大图压缩 | 传 4000×3000 原图 | 最长边不超过 1600,体积明显下降 |
| 文件夹拖入 | 拖一个含 150 张图的文件夹 | 全部进入,不卡死 |
这七项每项都对应前面某一章的具体坑,跑一遍大概十分钟,能挡掉上线后八成的返工。这套源码我按这个顺序拆过一遍,最值得留下来的习惯是最后一条:只要涉及createObjectURL,就一定在同一屏代码里把revokeObjectURL写出来,中间不隔函数、不隔文件。从那以后我每次接上传需求,都会先把回收这一步写死再写别的。希望帮到你。
本文还有配套的精品资源,点击获取