简介:面向Web前端初学者与需要实现本地图片预览功能的开发者,这套HTML5多图上传预览源码围绕File API与拖放API展开,演示如何读取用户选择的图片并即时显示缩略图,同时处理拖拽上传与基础交互,帮助理解浏览器端文件读取、事件监听和DOM更新的完整流程。压缩包共18个文件,包含HTML页面、CSS样式、5个JavaScript脚本以及多张PNG图片素材,另有少量说明链接,整体约145KB,目录按页面、脚本、样式和控件模块组织,便于按功能查阅。已有1473人学习下载。源码可直接运行测试,重点展示FileReader读取文件、数据URL预览、闭包保存文件对象以及拖放事件拦截等关键写法,也能看到上传控件的封装思路,适合用作课程示例、功能原型或二次开发参考。
1. 从「选完图一片空白」说起:html5 多图片上传预览到底解决了什么
后台表单里放一个<input type="file" multiple>,用户从相册勾了十几张图,选完只看到一串文件名——图有没有选反、是不是重复、体积多大,全靠提交后赌。html5 多图片上传预览的价值就在这一步:在提交前把 FileList 变成可见的缩略图,顺手完成删除、排序、压缩和格式过滤。它不是炫技,而是把「上传失败后返工」提前成「选完就能改」。这套源码用原生 FileReader、Blob URL、Canvas 和 FormData 串起一条不依赖框架的链路,适合做后台管理、工单附件、商品图、证件收集这类多图场景;新手能照着接,熟手可以只看状态管理和释放时机那几段。
2. 预览链路拆开看:FileReader 与 createObjectURL 怎么选
多图预览的第一道分水岭不在样式,而在「浏览器给你的文件对象怎么变成 img 的 src」。很多人上来就readAsDataURL,单张小图看不出问题,一到十几张手机原图就开始卡;也有人用了URL.createObjectURL,预览很轻快,但删除后不释放,切几个页面内存就上去了。先把 FileList、DataURL、Blob URL 这三件事拆清楚,后面的删除、排序、提交才有稳定的地基。
2.1 input multiple 拿到的是什么:FileList 不是数组
<input type="file" multiple>的change事件里,e.target.files是一个 FileList。它看起来像数组,有length,能下标访问,但没有map、filter、splice。常见做法是先Array.from(e.target.files)转成真数组,再做过滤和遍历。FileList 里的每个 File 对象带name、size、type、lastModified,这些字段足够做基础筛选,但不要拿name当唯一键——用户完全可能选两张同名同大小的图。
accept="image/*"只是给系统文件选择器的提示,不是安全校验。用户仍然可以在部分环境里选到非图片文件,后端也仍然要验类型。前端能做的是先挡一层:file.type.startsWith('image/')。如果type为空,比如某些 HEIC 或特殊相机文件,不能直接放行,也不要直接判死,常见做法是看扩展名并把这类文件交给后端转码,前端预览失败时给一张占位图。
还有一个高频问题:连续两次选同一个文件,第二次不触发change。原因是 input 的value没变,浏览器认为没有变化。解决办法是在change处理完后把picker.value = ''置空。这个动作要放在读取完files之后,否则会把刚拿到的 FileList 一起清掉。
2.2 两种预览方式:DataURL 与 Blob URL 的取舍
FileReader 的readAsDataURL返回 base64 字符串,可以直接塞进img.src,也可以存进 localStorage、随 JSON 发给后端。代价是体积膨胀约三分之一,而且大图读取和 base64 编码会占用主线程时间。十几张 12MP 照片同时 readAsDataURL,页面卡顿并不意外。
URL.createObjectURL(file)返回一个短字符串,浏览器内部持有对 File 的引用,img 解码时直接读 Blob,内存路径短得多。它的代价是生命周期要手动管:不用了必须URL.revokeObjectURL(url),否则 File 一直被引用,内存不释放。两种方式没有绝对优劣,选择看用途。
| 方式 | 产物 | 体积影响 | 释放 | 适合 |
|---|---|---|---|---|
| FileReader.readAsDataURL | base64 字符串 | 约 +33% | 无引用后由 GC 处理 | 单张小图、需要持久化字符串 |
| URL.createObjectURL | blob: 短 URL | 不膨胀 | 必须手动 revoke | 多图预览、大图、临时展示 |
我一般会默认用 Blob URL 做预览,只有在「预览图要跟着草稿存到 localStorage」这种场景才用 DataURL,并且会限制尺寸后再读。源码里也是按这个思路走的,预览和提交分离,提交时拿的是原始 File 或压缩后的 File,不拿 base64。
2.3 最小可用预览:从 change 事件到缩略图网格
先看一个能跑的最小闭环。HTML 只需要一个多选 input 和一个预览容器:
<input id="picker" type="file" accept="image/*" multiple> <div id="preview" class="preview"></div>JS 里不要用字符串拼innerHTML插文件名,文件名里出现尖括号会把结构弄乱。用 DOM 创建节点,文本走textContent:
const picker = document.querySelector('#picker'); const preview = document.querySelector('#preview'); // 状态数组:DOM 只负责显示,数据以这里为准 const state = []; function createId(file) { // 文件指纹 + 随机尾巴,避免同名同大小同时间被误判为同一张 const base = `${file.name}-${file.size}-${file.lastModified}`; const rand = Math.random().toString(36).slice(2, 8); return `${base}-${rand}`; } function createThumb(item) { const fig = document.createElement('figure'); fig.className = 'thumb'; fig.dataset.id = item.id; const img = document.createElement('img'); img.src = item.url; img.alt = item.file.name; const cap = document.createElement('figcaption'); cap.textContent = item.file.name; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'remove'; btn.textContent = '删除'; fig.append(img, cap, btn); return fig; } picker.addEventListener('change', (e) => { const files = Array.from(e.target.files || []); for (const file of files) { if (!file.type.startsWith('image/')) continue; const item = { id: createId(file), file, url: URL.createObjectURL(file), }; state.push(item); preview.append(createThumb(item)); } // 允许再次选择同一个文件 picker.value = ''; });这段代码的逻辑很直:先把 FileList 转数组,过滤非图片,给每个文件生成一个 id,创建 Blob URL,把{id, file, url}推进 state,同时创建一个带>preview.addEventListener('click', (e) => { const btn = e.target.closest('.remove'); if (!btn) return; const fig = btn.closest('.thumb'); const id = fig.dataset.id; const index = state.findIndex(item => item.id === id); if (index === -1) return; const [item] = state.splice(index, 1); fig.remove(); URL.revokeObjectURL(item.url); // 释放 Blob URL,避免 File 被长期引用 });
这里的关键参数是id,不是index。findIndex找到的是当前 state 里的真实位置,splice取出后立刻 revoke。如果这张图已经压缩过,state 里可能还有compressedFile和另一个 URL,删除时要把相关 URL 都释放。常见做法是给 state 项加一个urls: []数组,所有由这个文件派生出来的 Blob URL 都放进去,删除时统一 revoke。
还有一个容易忽略的点:删除后如果正在上传,要决定是取消上传还是让它传完。简单做法是上传前冻结一份快照,删除只影响下一次提交;更严谨的做法是给每个上传任务一个AbortController,删除时 abort。源码里默认走快照,逻辑简单,适合大多数后台表单。
3.2 拖拽排序与拖拽上传:DataTransfer 的两种用法
拖拽有两个完全不同的场景:把文件从桌面拖进页面是「上传」,把已预览的缩略图拖动是「排序」。两者都用 DataTransfer,但数据来源不同。拖文件时读e.dataTransfer.files;拖缩略图时用e.dataTransfer.setData('text/plain', id)传一个 id,放下时按 id 找位置。
先看拖拽上传。必须阻止dragover的默认行为,否则浏览器会直接打开图片,页面跳走:
const dropZone = document.querySelector('#dropZone'); ['dragenter', 'dragover'].forEach(type => { dropZone.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('is-over'); }); }); ['dragleave', 'drop'].forEach(type => { dropZone.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('is-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = Array.from(e.dataTransfer.files || []); addFiles(files); // 复用 change 里的添加逻辑 });addFiles就是把上一章 change 里的循环抽成函数,入参是 File 数组。这样 input 选择和拖拽上传走同一条路径,不会出现两套过滤规则。拖拽排序则要在缩略图上设draggable="true",dragstart时写入 id,dragover时计算插入位置,drop时移动 state 和 DOM。移动端对 HTML5 拖拽支持不稳定,排序功能建议加左右移动按钮作为降级,别把宝全押在拖拽上。
3.3 FormData 提交:字段名、多文件同名、附加参数
多图提交用 FormData 最稳。多个文件要 append 到同一个字段名,后端才能按数组接收;如果每个文件用不同字段名,后端要写一堆动态字段,不现实。下面这段把 state 里的文件统一放到images字段,附加参数走extra:
async function upload(extra = {}) { const fd = new FormData(); for (const item of state) { // 第三个参数指定文件名,避免后端拿到 blob 这种默认名 fd.append('images', item.file, item.file.name); } Object.entries(extra).forEach(([key, value]) => { fd.append(key, value); }); 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是后端约定的字段名,必须和接口文档一致;item.file.name作为第三个参数传给 append,保证后端收到的是真实文件名;extra里放业务字段,比如工单 id、分类、备注。最常见的翻车是手动写了headers: { 'Content-Type': 'multipart/form-data' },结果 boundary 丢失,后端解析失败。用 fetch 传 FormData 时不要设这个头。
如果需要上传进度,fetch 目前对上传进度支持有限,常见做法是换 XMLHttpRequest:
function uploadWithProgress(fd, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload'); xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText)); else reject(new Error(`HTTP ${xhr.status}`)); }; xhr.onerror = () => reject(new Error('网络错误')); xhr.send(fd); }); }进度条在多图场景有意义,尤其是几十兆的工单附件。注意e.total是整个请求体的长度,包含所有文件和字段,不是单张图。并发上传时不要把整个 FormData 拆成多个请求还共用一个进度条,进度会跳;要么一次提交,要么每个文件独立任务独立进度。
3.4 粘贴上传与移动端相册:补两个真实入口
桌面端用户经常截图后直接 Ctrl+V,加一个 paste 监听能省一步「保存到桌面再选择」。clipboardData里既有files也有items,优先读files,没有就遍历items找kind === 'file':
document.addEventListener('paste', (e) => { const files = Array.from(e.clipboardData?.files || []); if (!files.length) return; e.preventDefault(); addFiles(files.filter(file => file.type.startsWith('image/'))); });移动端要注意capture属性。<input type="file" accept="image/*" capture="environment">会倾向直接调起后置摄像头,而不是相册;如果业务要相册多选,就不要加capture,保留multiple。iOS 的 HEIC 文件在部分浏览器里type为空或image/heic,img可能解不出来,预览要做失败兜底:img.onerror时换成占位图,并在 state 里标记needsConvert,提交后由后端转码。不要把「前端预览失败」直接当成「文件损坏」,这是两回事。
4. 大图与兼容:压缩、EXIF 方向、对象 URL 释放
多图预览在 PC 上跑得欢,一到手机实拍就原形毕露:十几张 12MP 照片同时解码,低端安卓直接卡死;照片方向横过来;PNG 转 JPEG 透明区域变黑;反复删除重选后内存不降。这些问题不是玄学,都是解码、编码和引用生命周期的事。把压缩、方向、释放三件事处理掉,组件的稳定性会上一个台阶。
4.1 为什么原图直传会翻车:尺寸、体积与内存
一张 4000×3000 的照片,解码成位图大约占4000 × 3000 × 4字节,接近 48MB。十张同时 decode,内存压力不是线性叠加那么简单,浏览器还要为每张图保留解码缓存。低端机 GPU 纹理上限也有限,超过 4096 的图在某些设备上会直接渲染失败。所以多图场景不要一次性把所有原图都createImageBitmap或img.decode(),要么限制并发,要么先压缩再预览。
压缩不是越小越好。后台商品图要看清细节,最大边 1600、质量 0.82 通常够用;证件照要保留文字边缘,质量可以到 0.9,但最大边别低于 1200;聊天缩略图可以压到 800、0.75。关键是把这个参数做成可配置,而不是写死在代码里。源码里把maxEdge、quality、mime三个参数暴露出来,接入方按业务改。
4.2 canvas 压缩的参数:最大边、质量、输出格式
压缩的核心是算缩放比、画到 canvas、再toBlob。缩放比用Math.min(1, maxEdge / Math.max(width, height)),只缩小不放大,避免小图被拉糊。toBlob的第三个参数是质量,只对image/jpeg和image/webp有效,PNG 会忽略。输出格式建议默认 JPEG,透明图要特殊处理。
async function compressImage(file, { maxEdge = 1600, quality = 0.82, mime = 'image/jpeg', } = {}) { let source; try { // 现代浏览器支持从文件直接解码,并按 EXIF 方向摆正 source = await createImageBitmap(file, { imageOrientation: 'from-image' }); } catch { // 兼容分支:老浏览器退回 img 解码 const url = URL.createObjectURL(file); const img = new Image(); img.src = url; await img.decode(); URL.revokeObjectURL(url); source = img; } const scale = Math.min(1, maxEdge / Math.max(source.width, source.height)); const width = Math.round(source.width * scale); const height = Math.round(source.height * scale); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); // PNG 转 JPEG 时透明区域会变黑,先铺白底 if (mime === 'image/jpeg') { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); } ctx.drawImage(source, 0, 0, width, height); const blob = await new Promise(resolve => { canvas.toBlob(resolve, mime, quality); }); if (!blob) return file; const newName = file.name.replace(/\.\w+$/, mime === 'image/png' ? '.png' : '.jpg'); return new File([blob], newName, { type: mime, lastModified: file.lastModified, }); }这段代码里,maxEdge控制最长边,quality控制 JPEG 质量,mime控制输出类型。createImageBitmap的imageOrientation: 'from-image'让手机照片按 EXIF 方向摆正,减少后续方向问题。兼容分支用img.decode(),大多数现代浏览器的 img 解码也会遵循 EXIF 显示方向,但老环境不保证,所以方向问题不能只靠这一段,下一节单独说。toBlob可能返回 null,比如 canvas 被污染或尺寸为 0,这时退回原文件,不要让整个上传链路断掉。
4.3 EXIF 方向:手机照片为什么横过来
手机拍照时,传感器方向和握持方向不一定一致,照片里会写一个 EXIF Orientation。浏览器在<img>显示时通常会自动摆正,但 canvas 的drawImage早期不认这个方向,导致压缩后照片躺倒。现在的createImageBitmap加了imageOrientation: 'from-image'可以按文件方向解码,属于比较省事的做法;如果目标环境不支持,常见做法是读 EXIF 的 Orientation 值,再对 canvas 做旋转或镜像。
判断是否需要额外处理,可以做一个很小的探测:用一张已知带 Orientation 的测试图,走一遍压缩,看输出方向。不要凭感觉写旋转代码,不同浏览器、不同图片来源行为不一致。源码里保留了一个方向探测的开关,接入时先用真实手机照片跑一遍,再决定要不要引入额外的 EXIF 解析。另一个边界是 HEIC:浏览器解不出来时,createImageBitmap会直接 reject,走兼容分支也拿不到图,这时应该显示占位图并标记「需后端转码」,而不是反复重试。
4.4 对象 URL 与 FileReader 的释放时机
Blob URL 是引用计数式的,创建时不占太多内存,但背后的 File 一直被引用。删除时要 revoke,组件卸载时也要 revoke。只在删除时 revoke 不够,用户直接关页面或路由切换,state 里的 URL 还挂着。常见做法是封装一个releaseAll,在pagehide或组件卸载时调用:
function releaseAll() { for (const item of state) { URL.revokeObjectURL(item.url); if (item.previewUrl && item.previewUrl !== item.url) { URL.revokeObjectURL(item.previewUrl); } } state.length = 0; preview.innerHTML = ''; } // pagehide 比 beforeunload 更适合移动端 window.addEventListener('pagehide', releaseAll);注意不要重复 revoke 同一个 URL,虽然多数浏览器不会报错,但逻辑上容易掩盖问题。压缩后如果生成了新的 Blob URL,旧的预览 URL 要么立刻 revoke,要么记在 state 里等删除时统一释放。我一般会在 state 项里维护一个urls数组,创建时 push,释放时遍历 revoke 并清空,避免漏掉派生 URL。FileReader 的 DataURL 没有 revoke 概念,但大字符串会一直占内存,用完及时把引用置空。
5. 避坑与排查:多图预览最容易翻车的 5 个点
多图上传的代码量不大,翻车点却很密。下面这 5 条是我在不同项目里反复遇到的,按「现象 → 原因 → 解决」写,每条都能直接对照源码排查。血泪经验是:先把这 5 条跑通,再谈样式和动画。
5.1 第二次选同一个文件没反应
现象:用户选了一张图,删除后再选同一张,预览不出现,change也不触发。
原因:input 的value还是上一次的文件路径,浏览器认为值没变,不派发change。
解决:在change处理完、拿到 FileList 之后执行picker.value = ''。注意顺序,先Array.from(e.target.files)再置空,否则 FileList 可能被清掉。如果用了拖拽上传,拖拽路径不依赖 input,不受影响,但 input 和拖拽共用addFiles时要把置空放在 input 的回调里,不要放进addFiles,否则拖拽也会去清 input。
5.2 预览图裂开或白屏
现象:缩略图刚显示正常,删除别的图或重新渲染后,某张图变成裂图;控制台可能报net::ERR_FILE_NOT_FOUND。
原因:Blob URL 被过早 revoke,或者重复 revoke 后又被重新赋值给 img。常见于「删除时 revoke 了所有 URL」或「压缩后把旧 URL 释放了,但 DOM 还在用旧 URL」。
解决:URL 的生命周期跟着 state 项走,删除哪一项只 revoke 哪一项的 URL;压缩后生成新 URL,要把 img.src 换成新 URL,再释放旧 URL。排查时在 revoke 前打一条日志,确认这个 URL 是否还在 DOM 里被引用。不要用「页面卸载时统一 revoke」代替删除时的释放,删除后不释放会积累内存。
5.3 删除后提交多了或少了一张
现象:界面显示 3 张,提交了 4 张;或者删了第 2 张,提交时第 3 张变成了第 2 张的内容。
原因:DOM 和 state 不同步,或者用数组下标当唯一标识。删除 DOM 后 state 没删,提交时按 state 走就多一张;用下标删 state,后面的元素前移,异步任务拿旧下标就传错。
解决:所有删除、排序都按id操作 state,DOM 只做投影。提交前遍历 state,不要遍历querySelectorAll。如果上传任务是异步的,提交时对 state 做一次浅拷贝快照,避免上传过程中用户继续删除导致数组变化。快照里的 File 引用不会丢,只是顺序固定。
5.4 十几张手机原图直接把页面卡死
现象:选完 10 张以上照片,页面白屏几秒,低端安卓甚至崩掉;滚动卡顿,删除按钮点不动。
原因:一次性对所有原图createObjectURL并让 img 解码,同时可能并发跑压缩,解码内存和 canvas 内存叠加。图片尺寸超过设备纹理上限时,渲染也会失败。
解决:限制并发,压缩任务用 2~4 个并发跑,不要Promise.all全量铺开;预览可以先显示低分辨率缩略图,原图延迟解码;maxEdge设一个上限,比如 1600,避免 4000 以上的图直接进 canvas。排查时用 DevTools 的 Performance 看 decode 和 canvas 绘制耗时,确认瓶颈在解码还是压缩。
5.5 FormData 提交后端收不到文件
现象:前端state里有文件,fetch返回 400 或后端说字段为空;浏览器 Network 里请求体看起来有内容,但后端解析不到。
原因:字段名和后端约定不一致;手动设置了Content-Type: multipart/form-data导致 boundary 丢失;多个文件用了不同字段名,后端只读了一个;或者把 File 转成了 base64 字符串又按文件字段传。
解决:确认后端接收的字段名,多个文件用同一个字段名 append;用 fetch 传 FormData 时不要手动设 Content-Type;检查 Network 里的Content-Type是否带 boundary。如果是跨域,确认服务端 CORS 允许该头和 POST。实在排查不出来,先用一个最小 HTML 表单enctype="multipart/form-data"提交同样字段,确认后端链路本身没问题,再回头查前端。
6. 进阶与验证:把预览组件压到可复用的三条线
这套源码要长期用,不能只靠「看起来能跑」。我一般会做一张验证表,每次改完上传逻辑按表跑一遍,比凭感觉可靠。验证的重点不是样式,而是状态一致性、内存释放和边界文件。
| 验证项 | 操作 | 预期 |
|---|---|---|
| 同文件重选 | 选图 A,删除,再选图 A | 第二次仍能预览 |
| 删除后提交 | 选 3 张,删第 2 张,提交 | 只提交 2 张,顺序正确 |
| 大图并发 | 一次选 12 张 12MP 照片 | 页面不卡死,压缩并发可控 |
| 方向 | 用手机竖拍照片上传 | 预览和压缩后方向一致 |
| 透明图 | 选一张透明 PNG,输出 JPEG | 透明区域为白底,不是黑底 |
| 失败兜底 | 选一个 HEIC 或损坏文件 | 有占位图,不阻塞其他图 |
代码层面可以再加一条「文件指纹去重」的开关。不要默认开,商品图场景重复图可能是故意的。指纹用name + size + lastModified做粗筛,命中后再让用户确认,别静默删掉。另一个实用技巧是把压缩和提交拆成两个阶段:预览阶段只生成小图 URL,提交阶段才真正压缩并替换 File。这样用户删图时不会白白压缩一堆最后不用的图,移动端体验会好很多。
还有一点是验证压缩参数不要拍脑袋。拿业务里最常见的三类图各跑一遍:手机直出、截图、带文字的证件照。看压缩后的文件大小和文字边缘,再定maxEdge和quality。我吃过一次亏,质量压到 0.7,商品图上的小字全糊了,后来把默认值调到 0.82,最大边 1600,才稳定下来。从那以后我每次接上传需求,都先跑「同文件重选、删除后提交、12 张大图」这三件套,再谈样式和动效。需要对照源码的,按简介里的入口取,先跑完这张验证表再改业务字段,能少走不少弯路。希望帮到你。
本文还有配套的精品资源,点击获取