恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
HTML5多图片上传预览:从FileReader到Canvas压缩的完整指南
首页
资讯中心
/
HTML5多图片上传预览:从FileReader到Canvas压缩的完整指南
HTML5多图片上传预览:从FileReader到Canvas压缩的完整指南
发布时间:2026/10/11 17:28:09
简介HTML5多图片上传预览源码面向前端开发初学者利用File API与拖放API实现本地图片的读取和即时预览无需服务器端处理适用于后台管理、表单提交等多图选择场景。压缩包共18个文件大小约145KB其中包含9张界面图标所需的PNG素材、5份JavaScript脚本、1份CSS样式以及1个HTML入口页面另有辅助的快捷方式文件脚本按核心逻辑、DOM操作与工具函数划分目录结构清晰便于直接套用或二次开发。已有1470人学习下载。通过这套代码可以学习change事件、FileReader读取图片并生成dataURL、拖拽文件到页面等关键知识点也能掌握闭包传递文件对象、动态创建图片元素等实现细节整体流程完整对理解HTML5新特性与原生前端开发有实际参考价值。代码结构简洁、可直接运行便于在此基础上扩展删除、排序等交互功能。1. HTML5 多图片上传预览为什么选图回显要先在前端做完内容发布后台、电商商品编辑页、工单系统凡是表单里要一次提交多张图片都离不开这个环节用户点开系统文件选择器挑好几张图片页面不经过服务器先把这些本地图片读出来以缩略图形式呈现。HTML5 多图片上传预览要做的就是这个让用户在提交前看清自己选了哪几张、选对没有而不是等后端返回才发现传错了文件。落地成本不高一个 input 配一个 FileReader 就能跑通真正考验人的是细节张数上限、删除某一张、重复选择同一文件、大图内存占用。这里从原理、可复现源码、避坑排查、进阶技巧依次展开适合正在做后台表单、想自己掌控这段逻辑而不是直接引三方组件的读者。主流程在 Chrome、Edge、Firefox 桌面版跑过兼容性边界会在避坑章节说明。2. 多图预览的三段链路把 input 多选、FileReader、img 回显分别讲透2.1 选图multiple 属性、accept 过滤与 FileList 的只读特性先看最小结构一个 input 就打开了整个功能入口input typefile idfileInput acceptimage/* multiple两个属性各管一件事multiple 让系统文件选择器从单选变成多选acceptimage/* 让文件选择器的默认视图优先展示图片类文件。要注意accept 只是引导而不是封锁用户随时可以切到所有文件挑一个 .txt 进来所以说前端校验不能只依赖 acceptJS 里还要判断一次 file.type 是否真的是图片类型。在 change 事件里拿到的 this.files 是一个 FileList 对象它长得像数组但没有 forEach、没有 map。直接对它调用 forEach 会报 TypeError所以第一步要转成数组fileInput.addEventListener(change, function(e) { const all Array.from(e.target.files); // all 现在是普通数组后面可以放心 filter / forEach / slice console.log(all.length); });这里用 Array.from 或者展开运算符 [...e.target.files] 都行转成普通数组之后类型过滤、数量截断、遍历拼接都会顺手很多。FileList 还有一个容易翻车的隐藏属性它是只读的。Web 标准里没有提供删除其中某一项的 API所以你要是想实现用户删掉预览图后下次提交不再包含这张图不能去改 FileList只能自己维护一个独立的数组把每次选中的 File 对象收进去删除时从自己的数组里移除。后面源码实现那一章就是这个思路。选图入口为什么选 input 而不是拖拽或粘贴拖拽和粘贴是需要额外开发的交互而 input[typefile] 是兼容性最好、用户心智成本最低的入口它直接调用系统原生文件选择器桌面端移动端都有稳定表现。预览功能应该先基于它做之后想加拖拽再在组件外层补 drop 事件就行核心的文件读取和渲染逻辑完全不用动。还有一个小参数input 若带了 name传统表单提交时会把它一并提交但预览场景通常不走传统表单提交而是用 JS 组装 FormData 异步上传所以我一般不给这个 input 加 name避免和表单序列化工具意外打架。2.2 读取FileReader.readAsDataURL 的机制与另一个方案的取舍选中文件之后浏览器要在本地读文件内容才能回显这个动作由 FileReader 完成const reader new FileReader(); reader.addEventListener(load, function(e) { // e.target.result 是以 data:image/jpeg;base64 开头的完整字符串 console.log(e.target.result.slice(0, 30)); }); reader.readAsDataURL(file);readAsDataURL 把 File 对象读成 dataURL 字符串整个过程完全发生在浏览器本地不上传任何数据所以天然适合选完即见的预览。FileReader 还有 readAsArrayBuffer 和 readAsText 两个读取方法分别面向二进制处理和文本读取场景做图片预览用 readAsDataURL 是默认选择因为它直接产出 img 可用的 src 字符串不用再转换。必须强调的是FileReader 是异步的onload 里的回调最早也要等当前任务结束才执行所以绝不能在调用 readAsDataURL 之后立刻去读 e.target.result那个变量当时还是 undefined。初学容易翻车的写法是在 for 循环里共用一个 reader 实例后一次 onload 覆盖前一次结果所有预览图都变成最后一张。避免办法是每个文件单独建一个 reader或者用 forEach 加块级 const 包裹。同样常见的另一个方案是 URL.createObjectURLconst url URL.createObjectURL(file); img.src url; // 不再需要时必须手动释放 URL.revokeObjectURL(url);它返回一个 blob: 开头的临时 URL浏览器解析时直接指向本地文件读取速度比转 dataURL 快不少适合大批量、大尺寸文件的预览。但它有代价每个 URL 内部都占一份引用用完必须 revokeObjectURL 释放否则页面长时间运行内存只涨不落。两种方式的取舍方案优点缺点FileReader.readAsDataURL兼容稳定结果可缓存、可传递大图 base64 字符串长内存开销大URL.createObjectURL读取快不产生长字符串必须手动 revoke否则内存泄漏本文的预览组件最终选 readAsDataURL原因很直接可以把读取结果缓存在 File 对象上删除重建时直接取缓存不用重新调浏览器读文件。如果你只想要最小可用的临时预览createObjectURL 更轻但记得在移除预览节点时同步 revoke。2.3 回显把 dataURL 放进 img 时的渲染顺序与细节数据结构准备好之后来写渲染这段const item document.createElement(div); item.className preview-item; const img document.createElement(img); img.src e.target.result; // dataURL 直接赋给 src img.alt file.name; // dataURL 里没有文件名alt 必须自己补 item.appendChild(img); container.appendChild(item);用 createElement 而不是拼 innerHTML 字符串是考虑到 dataURL 可能很长一张 5MB 的 jpeg转 base64 后大约变成接近 7MB 的字符串如果拼进内联 HTML浏览器对该节点的解析成本会明显更高。img 的 alt 要写成源文件名因为 dataURL 本身不含文件名信息保留文件名方便后续排查和维护。第二个细节是回显顺序。多个文件并行读取时onload 回调的完成顺序并不等于文件的选择顺序——大文件读得慢、小文件读得快后选的反而先出现。只要求都显示出来乱序无所谓要求严格按选择顺序排的话就不能依赖回调里 appendChild 的自然顺序。正确做法是循环里先按下标把占位 DOM 建好等 onload 回来往对应占位里填 img源码实现的 render 方法就是基于这个策略。提示渲染顺序问题的根源是异步读取完成时机不确定而不是代码写得不够快。所有依赖并行的本地读取操作都要按占位 回调填充来设计界面。闭包问题也顺手点透在 for 循环里给 onload 赋值如果不在块级作用域里用 const 锁住变量回调触发时拿到的往往是最后一次迭代的变量表现就是所有预览图全变成最后一张。我的习惯是一律用 forEach 加 const 声明从语法上杜绝这类异步捕获问题。3. 源码实现一个可复用的多图片上传预览组件3.1 HTML 与 CSS 骨架先把骨架搭出来input 放在触发区域内部预览列表在下方div classuploader div classuploader__trigger input typefile idfileInput acceptimage/* multiple span点击选择图片最多 9 张/span /div ul classuploader__list idpreviewList/ul /divCSS 骨架负责把原生文件选择框隐藏并铺满触发区.uploader__trigger { position: relative; display: inline-block; padding: 20px; border: 1px dashed #ccc; border-radius: 6px; cursor: pointer; } .uploader__trigger input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } .uploader__list { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 12px 0 0; } .preview-item { position: relative; width: 96px; height: 96px; overflow: hidden; border-radius: 4px; background: #f5f5f5; } .preview-item img { width: 100%; height: 100%; object-fit: cover; } .preview-item__remove { position: absolute; top: 2px; right: 2px; width: 20px; height: 20px; border: 0; background: rgba(0, 0, 0, 0.6); color: #fff; border-radius: 50%; cursor: pointer; line-height: 1; }布局思路是把原生文件 input 用绝对定位铺满 trigger透明度设为 0让点击选择图片这行文字成为整个区域的可点外表。这样既保留了系统原生文件选择器的行为又能完全控制视觉样式。注意 input 要铺满整个触发区否则只有那行文字能点用户体验会明显打折。3.2 JS 核心逻辑统一用 render 方法驱动界面状态收敛到一个 selectedFiles 数组预览区每次变化都通过 render 重建。删除、追加、清空最后都走同一套输出流程/** * 多图片上传预览原生 JS 可复用组件 * param {HTMLInputElement} input 文件选择框 * param {HTMLElement} container 预览列表容器 * param {Object} options * param {number} options.maxCount 最大允许张数默认 9 * param {Function} options.onChange 文件列表变化后的回调 */ function createUploadPreview(input, container, options) { const { maxCount 9, onChange function(){} } options || {}; const selectedFiles []; function render() { container.innerHTML ; selectedFiles.forEach(function(file, index) { const li document.createElement(li); li.className preview-item; if (file.previewData) { const img document.createElement(img); img.src file.previewData; img.alt file.name; li.appendChild(img); } else { const img document.createElement(img); img.alt file.name; const reader new FileReader(); reader.addEventListener(load, function(e) { if (!container.contains(li)) { // 图片读取完成前这项已被删除或重绘丢弃本次结果 return; } file.previewData e.target.result; img.src file.previewData; li.appendChild(img); }); reader.readAsDataURL(file); } const removeBtn document.createElement(button); removeBtn.type button; removeBtn.className preview-item__remove; removeBtn.textContent ×; removeBtn.addEventListener(click, function() { selectedFiles.splice(index, 1); render(); onChange(selectedFiles.slice()); }); li.appendChild(removeBtn); container.appendChild(li); }); onChange(selectedFiles.slice()); } function handleChange(e) { const incoming Array.from(e.target.files || []); for (const file of incoming) { if (!file.type.startsWith(image/)) { continue; } if (selectedFiles.length maxCount) { console.warn(最多上传 maxCount 张图片); break; } selectedFiles.push(file); } e.target.value ; render(); } input.addEventListener(change, handleChange); return function destroy() { input.removeEventListener(change, handleChange); }; }代码逻辑说明handleChange 在用户选定文件后执行先用 Array.from 把 FileList 转数组再逐个过滤非图片类型到达 maxCount 上限后停止追加。e.target.value 这行非常关键把 input 的值重置之后下一次才能再次选择同一个文件并触发 change少了这行用户删掉预览里某张图后想再选同一张输入框会认为值没变change 事件根本不触发。render 每次先清空容器再按 selectedFiles 的顺序重建预览项。file.previewData 是第一次读取后缓存的 dataURL之后重建直接走同步分支避免重复读取大文件。removeBtn 的 click 里用 splice(index, 1) 删除对应的 File 对象随后再次 render界面和数据保持一致。组件返回 destroy 函数页面销毁或重新初始化时用它解绑事件避免同一页面里初始化多次造成事件叠加。参数说明maxCount 默认 9想改成 3 张就传 3onChange 返回的是 selectedFiles.slice()也就是当前全部 File 对象的浅拷贝数组提交表单时能拿到文件删除动作之后也能及时更新已选数量。destroy 不是必须调用但如果你在单页应用里反复进入同一个页面最好在页面卸载生命周期里调用它避免旧组件的事件还挂在 input 上。3.3 调用示例把组件接到页面上跑起来script const input document.getElementById(fileInput); const list document.getElementById(previewList); const countText document.getElementById(fileCount); const uploadPreview createUploadPreview(input, list, { maxCount: 6, onChange: function(files) { countText.textContent 已选 files.length 张; } }); // 页面销毁时调用 uploadPreview() /script这里单独说 onChange它不承担预览渲染职责只用于外部同步状态比如更新已选 N 张的文案。好处是业务代码不用读组件内部数组所有对外状态都通过回调暴露后续接 FormData 提交时也只需要从回调参数里取文件数组。如果项目里提交按钮要主动拿文件我一般还会在组件里暴露一个 getFiles 方法return { destroy: destroy, getFiles: function() { return selectedFiles.slice(); } };getFiles 返回浅拷贝外部调用方改数组不会污染组件内部状态。这样预览和提交两个环节解耦提交按钮里直接 getFiles() 组装 FormData 就行不用再绕事件。4. 避坑与排查多图预览在真实页面上的 5 个高频问题下面这几条不是理论推测是把这套组件接到不同后台页面后真实遇到的。每一条按现象、原因、解决来讲。4.1 选择同一张图后不触发 change现象第一次选了 1.jpg预览正常。删除后再去选同一个 1.jpg页面毫无反应。原因input[typefile] 的值没有被清空。浏览器认为当前文件列表和上次一样就不会再次触发 change 事件这是标准浏览器行为不是代码玄学。解决每次读完文件后把 input.value 清空。清空动作要在文件对象已经进入 selectedFiles 数组之后也就是 handleChange 内部靠近末尾的位置。清空是安全的它不会把已经存在的 File 对象弄丢因为那些引用已经保存到数组里了。4.2 预览多张高分辨率大图后页面明显卡顿现象选择几张 20MB 以上的高分辨率图片后页面操作开始迟钝任务管理器里浏览器内存持续上涨。原因readAsDataURL 会把文件转成 base64一张 20MB 的 jpeg 大约变成接近 27MB 的字符串5 张图就是一百多 MB全部塞进 DOM 内存压力很大。dataURL 字符串又不能像普通对象那样被浏览器优化只能真金白银占内存。解决在预览前加一层压缩或者先用 URL.createObjectURL 做临时预览、提交时再用原 File 对象。压缩方案放在后面的进阶技巧里至少第一步要把每张图的预览尺寸控制在 1000px 以内能省下大量内存。另外删除预览项时记得把 file.previewData 引用也删掉否则 base64 字符串仍然被数组占着内存只涨不落。4.3 预览图顺序乱现象一次选了 a.png、b.jpg、c.jpg 三张结果页面上先出现 c再出现 a、b顺序被奇怪地重排。原因三个 FileReader 并行读取小文件先完成 onload大文件后完成。依赖回调里的 appendChild 自然排队就会得到读取完成顺序而不是选择顺序。解决render 时按下标先建好占位 DOM等 onload 回来只往对应节点插入 img。只要占位顺序固定最终显示顺序就和数组顺序一致。源码实现里的 render 就是这个策略先创建 li再异步填充图片。4.4 accept 过滤失效非图片也能被选中现象accept 已经写了 image/*但用户还是通过切换所有文件选入一个 .txt页面尝试把它当图片渲染显示一个破碎的 img 图标。原因accept 只是文件选择器的默认过滤条件不是准入限制浏览器允许用户切换到所有文件JS 侧没有做二次校验。解决在 handleChange 里加类型判断不满足的直接跳过。用 startsWith(image/) 而不是等于 image/png可以兼容 jpeg、gif、webp 所有常见图片子类型。另外有些系统会给出 file.type 为空的怪文件建议一起丢弃别让后续流程摸黑处理。4.5 删除中间某张图后按钮索引错位现象按顺序选了 4 张图删除第 2 张后一切正常再删原第 3 张此刻显示在第 2 位时页面却删掉了第 4 张。原因render 里为每个删除按钮绑定的 index 是 forEach 当时的下标。如果删除时没有走完整的改数组 重绘而是直接 remove DOM 节点下一次渲染时 DOM 和数组下标就对不上了。解决所有删除操作都走先 splice 数组再 render 重建这一条路不要手动 remove DOM 节点。只要界面完全由 selectedFiles 驱动DOM 永远不会和数组错位。提示之后要给预览项加设为封面打标签等交互同样遵循这个原则先改数据再重建界面。数据驱动渲染是这类索引类 bug 的最稳解药。5. 进阶技巧用 Canvas 把大图先压缩到适合预览的尺寸真实项目里服务端接口通常有单张图片大小上限而现在的手机随手拍一张就是几 MB 到十几 MB。预览组件加一层 Canvas 压缩既降低页面内存压力也相当于给上传做了一次前端预压。常见做法是拿到 File 后用 Image 异步加载drawImage 等比缩放导出 jpeg base64再把它当作 previewData 使用function compressToDataUrl(file, maxWidth, quality) { return new Promise(function(resolve, reject) { const url URL.createObjectURL(file); const img new Image(); img.onload function() { const scale Math.min(1, maxWidth / img.naturalWidth); const canvas document.createElement(canvas); canvas.width Math.round(img.naturalWidth * scale); canvas.height Math.round(img.naturalHeight * scale); const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); resolve(canvas.toDataURL(image/jpeg, quality)); }; img.onerror function() { URL.revokeObjectURL(url); reject(new Error(图片加载失败)); }; img.src url; }); }代码逻辑说明先通过 blob URL 把本地文件交给 Image 对象加载拿到自然宽高后按 maxWidth 等比缩小drawImage 画到 canvas 上最后 toDataURL 导出压缩后的 dataURL。maxWidth800、quality0.7 是我常用的起步参数对普通屏幕预览足够也能把一张 8MB 的图压到几百 KB 的字符串。参数注意点maxWidth 控制最长边像素上限传 0 或负数会出错建议调用前做下限判断quality 只对 jpeg 导出有效导出 PNG 时该参数被忽略透明 PNG 直接导出 jpeg 会把透明区域填成黑色需要改成 image/png或者在画之前先铺一层白底再转 jpeg。压缩后的 dataURL 可以直接替换 FileReader 的产物render 里的缓存逻辑完全不用改。如果要做真正的压缩上传再把 canvas.toDataURL 转回 Blob 放进 FormData要注意这时提交的是压缩图而不是原图与仅压缩预览、上传原文件是两种不同语义交给产品确认后再定。最后留一套自测清单每写完一个预览组件我都会按这个顺序过一遍自测项预期表现失败排查选 1 张图出现对应缩略图检查 change 是否触发连续选中 3 张顺序与选择顺序一致检查 onload 占位逻辑删除中间第 2 张缩略图剩 2 张且前移检查 splice 后是否 render删除后重选同一张图片可再次预览检查 input.value 是否清空我现在的习惯是写完组件先跑完这 4 步再做样式调整。这套流程帮我省了不少功能看起来正常合到项目里却找不到图的血泪时间希望帮到你。本文还有配套的精品资源点击获取