简介:面向前端学习者的HTML5多图片上传预览完整源码包,基于File API与拖放API实现本地图片批量选择、拖拽上传与即时预览,无需服务器端处理即可完成数据URL转换,兼顾传统文件选择与拖放两种交互方式。压缩包共18个文件,压缩后大小145KB,包含演示index.html、样式表zyUpload.css、脚本demo.js、zyUpload.js、zyFile.js等5个js文件,以及9张png素材图片,并附jquery-1.7.2.js和两个常用URL快捷入口,控制与核心模块分工明确,目录结构简洁便于对照学习。已有1469人浏览/学习该源码案例。代码通过监听change事件、调用FileReader的readAsDataURL方法把图片转为Base64字符串并嵌入img标签来实时预览,同时用闭包保存当前文件对象,清晰展示了drop/dragover事件处理等关键知识点,并提供已测试的可运行环境,适合希望掌握原生JavaScript实现前端文件上传与拖拽交互的开发者快速上手、修改与复用。
1. HTML5 多图片上传预览:为什么说这是前端最容易被低估的难点
如果你没亲手写过一次多图片上传预览,你大概率会认为它不过是一个<input multiple>加几行FileReader的事。但真等你在生产环境里被连续选中同一张图、竖拍照片方向错乱、连续上传大图把手机浏览器卡白屏之后,你就会明白这套交互的水远比看起来深。本文拆的这份 html5 多图片上传预览源码,是已经跑通完整链路(选图 → 预览 → 删除 → 表单提交)的可用实现,涵盖了格式校验、数量限制、老图回显和内存释放这些实战必踩的点。适合正在做后台管理系统、移动端 H5 页面或课程设计里的上传模块的人直接参考。你可以把它当成一套可靠的地基,而不是自己从FileReader文档第一行开始踩坑。
2. 从 FileReader 到 URL.createObjectURL:两种预览方案的选型与实现
2.1 FileReader 的 readAsDataURL:兼容性最好但内存开销翻倍
几乎所有 HTML5 多图片上传预览的教程都会先告诉你用FileReader。这东西确实历史悠久,兼容性覆盖到 IE10 都没问题,但它有一个在移动端特别明显的短板:readAsDataURL会把图片转成 Base64 字符串,而 Base64 编码会让数据体积膨胀约 33%,也就是说你选了一张 5MB 的照片,浏览器内存里实际驻留了近 7MB 的字符串,如果再渲染成<img>的src,那又是一份解码后的位图缓存。手机浏览器在连续选十张图之后,页面卡顿几乎是可以预见的。
// 这是不太推荐用于多图场景的写法,单图或小图可以用 const input = document.getElementById('singleInput'); input.addEventListener('change', function () { const file = this.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function (e) { document.getElementById('preview').src = e.target.result; }; reader.readAsDataURL(file); });这段代码的逻辑很直白:监听change,拿到File对象,创建FileReader,在onload里把e.target.result(即 Base64 字符串)赋给<img>的src。readAsDataURL是异步的,所以结果只能通过回调拿。它的优点是没有任何兼容性顾虑,缺点是如果图片较大且数量多,内存会飙升。我一般只在做头像上传这种单张小图的场景才用FileReader,多图预览我会直接换下一种方案。
2.2 URL.createObjectURL:推荐方案但必须手动静默释放
URL.createObjectURL是比FileReader更现代的方案。它的机制是给浏览器内部的 Blob(或 File)生成一个临时对象 URL,形式类似blob:http://localhost:8080/xxxxxx-xxxx-xxxx。这个 URL 直接指向浏览器内存中的原始文件数据,不需要 Base64 编解码,因此内存效率高得多。代价是:这个 URL 占用的资源不会随<img>被移除而自动释放,必须手动调用URL.revokeObjectURL(url)来显式回收,否则就会形成内存泄漏。这是多图预览里最典型、也最容易被新手忽略的坑。
// 使用 URL.createObjectURL 的预览方案 const input = document.getElementById('multiInput'); const previewBox = document.getElementById('previewBox'); input.addEventListener('change', function () { const files = Array.from(this.files); // FileList 转普通数组,方便 forEach files.forEach(file => { if (!isValidImage(file)) return; const url = URL.createObjectURL(file); const img = document.createElement('img'); img.src = url; img.dataset.url = url; // 存下 URL 供后续 revoke 使用 img.dataset.name = file.name; previewBox.appendChild(img); }); this.value = ''; // 关键操作,见第四章避坑 });这里每个文件都执行了一次createObjectURL,每个生成的 URL 都存在dataset.url里,就是为了后面删除某张图时能精确调用revokeObjectURL来释放。this.value = ''这行很重要:如果不清空<input>的 value,用户第二次选择同一个文件时change事件可能不会触发,导致「同一张图选第二次没反应」。
2.3 完整代码落地:多图存储、预览渲染与表单提交
这一小节给出一个可直接复制的完整多图上传预览实现。它比前面示例多了几个关键能力:文件数组管理、删除时同步撤销预览与内存、提交时把文件列表装进FormData。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>HTML5 多图片上传预览</title> <style> #previewBox { display: flex; flex-wrap: wrap; gap: 10px; } .preview-item { position: relative; border: 1px solid #ddd; border-radius: 4px; } .preview-item img { width: 120px; height: 120px; object-fit: cover; display: block; } .preview-item .remove-btn { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,0.6); color: #fff; border: none; border-radius: 50%; width: 22px; height: 22px; cursor: pointer; line-height: 22px; text-align: center; } </style> </head> <body> <input type="file" id="fileInput" accept="image/*" multiple> <div id="previewBox"></div> <button id="submitBtn">提交</button> <script> // 文件数组:存储 { file, url, id } 三个字段 const fileList = []; const fileInput = document.getElementById('fileInput'); const previewBox = document.getElementById('previewBox'); const submitBtn = document.getElementById('submitBtn'); const MAX_COUNT = 9; // 最大9张 const MAX_SIZE = 5 * 1024 * 1024; // 单张最大5MB const ACCEPT_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; function isValidImage(file) { if (!ACCEPT_TYPES.includes(file.type)) { alert(`不支持 ${file.type || '未知'} 格式,仅支持 ${ACCEPT_TYPES.join(', ')}`); return false; } if (file.size > MAX_SIZE) { alert(`${file.name} 超过 5MB 限制`); return false; } if (fileList.length >= MAX_COUNT) { alert(`最多上传 ${MAX_COUNT} 张`); return false; } return true; } function renderPreview(file, url) { const item = document.createElement('div'); item.className = 'preview-item'; const img = document.createElement('img'); img.src = url; const btn = document.createElement('button'); btn.className = 'remove-btn'; btn.textContent = '×'; // 删除按钮:从数组移除 => 撤销URL => 移除DOM btn.addEventListener('click', function () { const idx = fileList.findIndex(f => f.url === url); if (idx !== -1) { URL.revokeObjectURL(fileList[idx].url); // 释放内存 fileList.splice(idx, 1); } item.remove(); }); item.appendChild(img); item.appendChild(btn); previewBox.appendChild(item); // 把本轮新增的图片信息存入数组 fileList.push({ file, url, id: Date.now() + Math.random() }); } fileInput.addEventListener('change', function () { const files = Array.from(this.files); files.forEach(file => { if (!isValidImage(file)) return; const url = URL.createObjectURL(file); renderPreview(file, url); }); this.value = ''; // 重置input,确保同一文件可再次触发change }); submitBtn.addEventListener('click', function () { if (fileList.length === 0) { alert('请先选择图片'); return; } const formData = new FormData(); fileList.forEach((item, index) => { formData.append(`images[${index}]`, item.file, item.file.name); }); // 这里交给你的AJAX逻辑 console.log('待提交图片数:', fileList.length); for (const pair of formData.entries()) { console.log(pair[0], pair[1].name); } // fetch('/api/upload', { method: 'POST', body: formData }) // .then(res => res.json()) // .then(data => console.log(data)); }); </script> </body> </html>这份代码已经是可运行状态,核心思路是维护一个fileList数组作为唯一数据源,renderPreview负责渲染并同步记录文件与 URL,删除按钮在处理事件时把三个地方一次性清理掉:数组、URL.revokeObjectURL、DOM。FormData.append的时候,第三个参数我们显式传了item.file.name,这样服务端收到的文件名不会变成blob那串乱码。MAX_COUNT、MAX_SIZE、ACCEPT_TYPES这三个常量是留给你的调参入口,按自己业务改数值即可。
3. 多图上传的交互闭环:预览、删除、回显与限制校验
3.1 选择、追加、删除与数组同步
上一章代码里的change事件监听用的是追加语义:每次选择文件都会在原有预览基础上继续添加,这对应了「多图片」场景的真实需求——用户往往分多次选择不同图片。但这里有一个人机交互上的细节问题:<input type="file">每次弹出的是系统文件选择器,选完一批后如果想再选一批,通常会期望这次的新文件跟上次的并存。我们要做的就是把多批文件最终归拢到一个数组里。上面代码里的fileList.push就是在完成这个归拢动作。
删除操作需要特别小心的是数组下标的稳定性。如果你用splice(index, 1),后面的元素会自动前移,这没问题;但如果你用delete fileList[index]或者fileList.length = ...这类操作,就会留下空洞,导致后续遍历时拿到undefined。我的习惯是删除时用findIndex找到目标对象在数组中的精确位置再splice,同时用URL.revokeObjectURL把对应的预览内存释放掉。另外要留意的是,fileList里存的file对象是File实例,它本质上是一个 Blob 引用,只要你不显式释放,它就会一直驻留在内存里,这也是为什么「删图后内存却没降下来」的排查点之一。
3.2 数量、格式与尺寸的拦截策略
限制数量和格式这件事,放在前端做是体验问题,放在后端做是安全问题。前端必须做,但不能只依赖前端。在这个源码里,我设置了三个拦截层次:文件类型白名单、单文件大小上限、总数量上限。这三项校验全部在isValidImage里完成,等于是在change事件触发的第一时间就拦掉了不合规的文件,避免后面渲染和提交阶段再出错。
accept="image/*"是浏览器文件选择器层面的快速过滤,它只是建议性过滤,用户仍然可以切到「所有文件」去选择非图片,所以代码里还要再做一次魔数级校验。file.type基于 MIME 类型判断,需要注意某些情况下浏览器对.jpg文件返回的type会是空字符串,尤其是从手机相册选图时偶尔会发生,所以上面代码里file.type为空时我用|| '未知'的方式给它一个降级提示,而不是直接放行。- 尺寸拦截有两个含义:一个是文件体积(
file.size),另一个是图片分辨率。体积好拦,分辨率必须等图片加载后才能知道,这个我在第五章单独讲压缩时会展开。
// 尺寸校验:等待图片加载读取真实宽高 function checkImageSize(file, maxWidth = 4000, maxHeight = 4000) { return new Promise((resolve) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = function () { const pass = img.naturalWidth <= maxWidth && img.naturalHeight <= maxHeight; URL.revokeObjectURL(url); // 校验完立即释放 resolve(pass); }; img.onerror = function () { URL.revokeObjectURL(url); resolve(false); }; img.src = url; }); }这段代码是异步的,因为Image加载需要时间,在拿到naturalWidth和naturalHeight之前不能做判断。new Image()配合src = url的方式不会污染文档里的<img>标签,因此临时 URL 用完之后立刻revokeObjectURL避免泄漏。真实项目中我把这个函数和isValidImage串成一条流水线:先同步验证类型、大小、数量,再异步验证分辨率,两条都过了才放进数组。
3.3 服务端回显:编辑场景下老图与新图的区分
很多从零写的多图预览源码只覆盖了「新增」场景,一旦接到编辑页(比如修改商品图),就露馅了。编辑页的特点是:图片列表已经存在于服务器,前端要先拉取一批老图 URL 展示,用户既能删除老图,也能追加新图,提交时还要告诉服务端哪些老图被删了。
这个源码里我给每个条目增加了一个标识字段isExisting来区分老图和新图。老图的预览 URL 直接指向服务器地址,删除时不需要也不应该调用URL.revokeObjectURL;新图的预览 URL 是blob:协议,删除时必须释放。
// 编辑页回显老图的逻辑示意 function renderExistingImages(remoteUrls) { // remoteUrls 来自后端接口,形如 ['/uploads/a.jpg', '/uploads/b.png'] remoteUrls.forEach((url, index) => { const item = document.createElement('div'); item.className = 'preview-item'; const img = document.createElement('img'); img.src = url; const btn = document.createElement('button'); btn.className = 'remove-btn'; btn.textContent = '×'; btn.addEventListener('click', function () { // 标记删除,之后提交时把 deletedIds 传给服务端 deletedIds.push(url); item.remove(); }); item.appendChild(img); item.appendChild(btn); previewBox.appendChild(item); existingFiles.push(url); // 老图统一记录 }); }可以看到老图处理逻辑里没有revokeObjectURL,因为它的 src 不是blob:协议,是服务器上的正常 URL,浏览器自己会管理缓存。提交时前端需要额外生成一个deletedIds数组,通过FormData.append('deletedIds', JSON.stringify(deletedIds))传给服务端,服务端据此决定删除哪些物理文件,同时把新图文件列表落库。这种情况是最容易把两套逻辑写混的:错误地对老图也调用revokeObjectURL不会报错,但那个 URL 从此就失效了,如果你后续还要拿老图做图片对比预览,可能一片空白。
4. 多图上传避坑:兼容性、内存泄漏与样式翻车实录
4.1 同一个文件选第二次没有反应
现象:用户第一次选了一张a.jpg预览成功,再次点击上传按钮、还是选同一张a.jpg,页面毫无反应,change事件根本不会触发。
原因:<input type="file">的value字段保存的是用户选择的文件路径(浏览器出于安全原因展示为C:\fakepath\a.jpg),如果上一次选择后没有清空,第二次选同一路径时浏览器认为值没有变化,于是不触发change事件。
解决:在任何一次change事件处理完、拿到this.files之后,手动执行this.value = '';。这条代码在第二章完整实现里已经写进去了。还有个隐藏好处是,清空 value 之后你再从fileList里恢复上一次选择的文件时,不会出现 FileList 与 value 不同步的玄学问题。
4.2 连续选图后页面越来越卡、甚至白屏
现象:在多图预览的页面上连续点了十几张几 MB 的大图,越往后操作越卡,删掉所有预览图后内存也没恢复到初始水平,部分旧版 Safari 直接白屏。
原因:URL.createObjectURL生成的blob:URL 如果没有显式调用URL.revokeObjectURL,它占用的内存不会被系统回收,哪怕对应的<img>标签已经被移除了,泄漏持续累积直至浏览器崩溃。这是多图预览场景里最常见的内存泄漏入口。
解决:删除图片时(以及提交完成后)必须遍历fileList中所有条目,逐个执行URL.revokeObjectURL(item.url)。还有一个我自己的习惯:在change事件处理里,对每一张图生成 objectURL 之前先检查当前fileList.length,如果已经接近上限,先提示用户清理,避免一次性创建过多 URL。
// 提交完成后统一释放全部预览 URL function releaseAllObjectUrls() { fileList.forEach(item => { URL.revokeObjectURL(item.url); }); fileList.length = 0; previewBox.innerHTML = ''; }注意fileList.length = 0是清数组的标准做法,比fileList = []更安全,因为后者会直接更换数组引用,如果你在闭包里有别的地方引用了旧的数组,就清不干净了。
4.3 动态 GIF 在预览时变成静态图
现象:明明选了 GIF,预览出来却只有一个画面,不会动。
原因:<img>标签本身没有禁止 GIF 动画,但你在预览区如果设了object-fit: cover配合固定宽高,这只是样式问题,不影响动画。真正的常见原因是移动端很多图片压缩模块或上传库会在预览阶段调用canvas.toDataURL('image/jpeg'),这一步会强制把动态 GIF 转成 JPEG,动画自然就没了。另一个原因是部分旧版本 Android WebView 的<img>对 GIF 动画支持不完整。
解决:预览阶段不要对原文件做任何转换,直接createObjectURL然后用<img>加载原始 GIF。在提交前如果需要压缩,你也得在压缩逻辑里对 GIF 类型单独放行——不要对它执行 canvas 重绘,直接提交原始文件。这个源码里我用的方案是:ACCEPT_TYPES允许image/gif,压缩阶段单独判断类型,GIF 跳过压缩步骤。
4.4 竖拍照片在预览时方向横过来了
现象:手机拍的照片传到电脑上看是正的,但在网页预览里却是旋转 90 度或 180 度的。
原因:现代手机拍摄的照片会在 EXIF 信息里写入Orientation字段,支持它的图片查看器会自动旋转。但浏览器中的<img>和canvas在绝大多数场景下会忽略这个字段,于是照片按原始像素方向展示,导致方向错乱。
解决:这个坑在预览阶段几乎无解——你不能通过<img>去强制应用 EXIF 方向。在压缩阶段可以用canvas配合createImageBitmap的imageOrientation选项来修正:
// 使用 createImageBitmap 读取并修正 EXIF 方向 async function loadImageWithOrientation(file) { const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); bitmap.close(); // 用完立即释放位图内存 return canvas.toBlob('image/jpeg', 0.85); }imageOrientation: 'from-image'在 Chrome 和新版 Edge 里已经支持,Safari 需要单独降级处理。如果实在要覆盖老浏览器,那就只能引入exif-js这类第三方库读取Orientation字段再做矩阵旋转,成本较高,但是唯一兼容方案。
4.5 预览图样式挤压变形,只显示一个角
现象:预览生成的图片不是等比缩放,而是横向拉宽、纵向压扁,或者只裁剪出一个局部区域。
原因:<img>标签不设置宽高时,会按原始尺寸展示,大图会撑爆布局;只设了width: 100%而不设高度或object-fit时,会导致宽高比失真。如果你用了height: 120px; width: 120px;但没有object-fit: cover,图片就会被拉伸到非等比状态。
解决:统一使用固定宽高容器 +object-fit: cover的样式方案。object-fit: cover等价于背景图的background-size: cover,它会保持图片原始宽高比,同时按容器尺寸裁剪溢出部分。如果你希望整张图完整可见而不裁剪,用object-fit: contain,代价是左右或上下会留白,类似于文档里图片「适配页面」的意思。这两种方案适合不同的 UI 需求,我用得最多的还是cover,因为大多数后台列表场景展示缩略图就是要铺满格子。
.preview-item img { width: 120px; height: 120px; object-fit: cover; border-radius: 4px; background: #f5f5f5; /* 图片加载失败时的底色 */ }5. 多图预览的性能兜底:用 canvas 强制输出指定尺寸缩略图
多图预览在 UI 上看似告一段落,但真正决定这个功能在生产环境是「能用」还是「好用」的,是选完图之后对图片数据的再处理。用户从手机相册选出来的图动辄 3MB 到 8MB,直接提交给服务端既费带宽又费存储,而且<img>直接渲染原图还会让页面卡顿。我的做法是在提交前用canvas做一次等比压缩,生成指定最大边长、指定压缩质量的缩略图,再放进FormData。
// 图片压缩:限制最大边长为 1200px,质量 0.85,输出 JPEG async function compressImage(file, maxEdge = 1200, quality = 0.85) { const url = URL.createObjectURL(file); const image = new Image(); image.src = url; await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; }); URL.revokeObjectURL(url); // 原始文件加载完成就释放,否则又是一次泄漏 const ratio = Math.min(maxEdge / image.naturalWidth, maxEdge / image.naturalHeight, 1); // 当原图小于 maxEdge 时,ratio 为 1,不放大 const w = Math.round(image.naturalWidth * ratio); const h = Math.round(image.naturalHeight * ratio); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0, w, h); // 非 GIF 图统一转 JPEG,GIF 保留原样提交 if (file.type === 'image/gif' && file.size > 1024 * 1024) { return file; // 大 GIF 不做压缩,直接交原图,避免动画丢失 } return new Promise(resolve => { canvas.toBlob(blob => { resolve(blob ? new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg' }) : file); }, 'image/jpeg', quality); }); }ratio = Math.min(maxEdge / naturalWidth, maxEdge / naturalHeight, 1)这个表达式是整个压缩逻辑的核心:取宽高两者中更接近上限的维度来计算缩放比,同时Math.min(..., 1)保证如果原图本来就小于 1200px,则不做放大。注释里我也写了这一点,因为它决定了压缩是否「等比」且是否「不放大」。toBlob比起老式的canvas.toDataURL更省内存,它直接产出二进制 Blob,不经过 Base64 字符串,后续FormData也直接收 Blob。GIF 特判那两行看起来很简单,实际是踩过坑之后才加的——第一次上线时系统把 GIF 压成了静态 JPEG,产品经理解释了五分钟为什么商品页的动图不会动了。
整个流程串起来之后大概是这样的:用户选完图 → 前端逐张做类型、大小校验 → 预览展示 → 提交前对每张非 GIF 图片运行compressImage、得到压缩后的 File 对象 → 把新对象放入FormData的files字段 → 发送请求。这套链路跑通之后,你会发现服务端收到的图片普遍只有原图的 30% 左右体积,列表页的加载速度也明显提升了。从那以后我每次做多图上传,都会强制走一遍「校验 → 预览 → 压缩 → 回显」这四步流程,哪怕需求方说只用内网不关心体积,我也会把压缩逻辑保留着——谁也不知道今天的内网会不会变成明天的公网。希望帮到你。
本文还有配套的精品资源,点击获取