
简介面向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.srcFileReader是异步的onload什么时候触发取决于文件大小和磁盘速度。多张图一起读的时候回调顺序完全不保证跟选择顺序一致。如果业务要求缩略图按选择顺序排列——商品图几乎都要求——就必须自己在闭包里记住索引回调里往固定位置插不能简单appendChild。这个细节在演示代码里极少出现一到真实项目就翻车。对比项FileReader → dataURLcreateObjectURL → blob URL返回时机异步onload 后可用同步调用即用内存占用约为原图 1.33 倍占用 JS 堆仅一个引用图片数据不复制是否需手动释放否随字符串回收是必须 revokeObjectURL能否用于下载/跨页能字符串可存可传不能页面关闭即失效适合场景单图、需要把图像数据发给后端或存起来多图批量预览、大图预览2.2 用 FileReader 跑通最小可用版本先把骨架写出来一个 input、一个容器够用就行。HTML 部分没什么花活关键是multiple和accept两个属性——它们只是给用户和选择器的「提示」不是安全边界后面还要再校验一次。!-- index.html最小可用的多图预览骨架 -- input idpicker typefile acceptimage/* multiple div idpreview classpreview-grid/div接着是脚本。注意e.target.files拿到的是 FileList是个类数组对象不能直接map/filter先转成真数组再处理否则后面写起来处处别扭。// preview-filereader.jsFileReader 路线兼容性最好 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或者共享变量回调执行时变量早就变了。第二处是二次校验acceptimage/*在桌面端只是让文件选择框默认过滤用户手动切成「所有文件」照样能选进来一个.zipfile.type会是空的或者非 image 开头必须在change里挡掉。第三处是reader.result的类型readAsDataURL返回字符串readAsArrayBuffer返回 ArrayBufferreadAsText返回文本别混着用img.src只认前者的产物。把它跑起来以后你会发现一个问题选 10 张图缩略图出现的顺序跟你在选择框里点选的顺序经常对不上大的先出、小的后出是常态。这就是前面说的异步时序问题。如果顺序必须严格一致有两个办法一是先把所有 dataURL 读进一个 Promise 数组Promise.all之后再统一渲染二是回调里不直接appendChild而是往一个按索引编号的占位容器里插。前者实现简单但会有一个空窗期体验稍差后者体验好代码多二十行。这个源码用的是第二种思路可以照着改。2.3 换成 createObjectURL省内存但要自己收尾把上面那段改成 blob URL改动其实很小但整个生命周期管理就变了——你得额外维护一份「文件 → URL」的映射删除某张图的时候把对应的 URL 还回去。// preview-bloburl.jscreateObjectURL 路线内存更省但必须手动释放 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允许一次选多个文件以为能限制数量实际不限制acceptimage/*选择框默认过滤图片当作安全校验不再检查file.typecaptureenvironment移动端优先调后置摄像头桌面端无效且会导致只能拍照不能选相册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结果用户在手机上只能调起相机想从相册选反而不行了。真正稳妥的写法是给两个入口——一个acceptimage/*的普通 input 走相册一个带captureenvironment的走拍照让用户自己选。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 转出来的 Blobname可能是空字符串后端按文件名存盘时会拿到一个时间戳或者报错所以压缩之后生成的 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 压缩参数的取值边界压缩不是越小越好参数定错的后果是用户投诉「图糊了」。三个参数各有各的边界参数常用取值定值依据调过头的后果最长边maxEdge12801920详情页最大展示宽度 × 2 倍屏小于 1000 时细节文字糊成一团质量quality0.80.85JPEG 在 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()是可选的但批量处理几十张图的时候不加解码后的位图占的内存会一直堆着。有一条边界必须说清楚跨域图片会污染 canvastoBlob会抛安全错误或者返回 null。这里处理的是用户本地选择的 File 对象属于同源数据不会触发但如果你的页面还支持「从图片 URL 导入」那就得给那个img加crossOriginanonymous并且服务端要返回允许跨域的响应头否则压缩一定失败。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的布局重排。解码本身是异步的但回调全部挤在同一个时间片里执行主线程被占满页面自然没响应。解决加并发闸门。用一个简单的计数池同时只处理 46 个文件处理完一个再放一个进来缩略图先渲染一个灰色占位块等load之后再替换。另外给每次批量操作加一个上限提示超过 30 张直接告诉用户分批选比硬扛着卡死体验好。这条经验是很早以前踩出来的从那以后凡是跟文件相关的批量操作我都会先想一遍并发数。5.3 手机拍的照片方向是歪的、格式还不认现象桌面端一切正常iPhone 用户上传后图片旋转了 90 度另一些用户上传直接失败抓包看到content-type: image/heic。原因方向问题是 EXIF Orientation 没在解码阶段应用不同浏览器和 WebView 的处理不统一。格式问题出在acceptimage/*上——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 或 FileFormData.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写出来中间不隔函数、不隔文件。从那以后我每次接上传需求都会先把回收这一步写死再写别的。希望帮到你。本文还有配套的精品资源点击获取