简介:一套基于HTML5文件读取接口与拖放功能的多图片上传预览源码,面向前端开发初学者与Web交互爱好者,可直接运行验证,解决本地图片批量选择与即时预览需求。资源包共18个文件,包含1个HTML入口页面、1个CSS样式表、5个JavaScript脚本(含jQuery与自定义上传插件)、9张界面切图,压缩包仅145KB,结构精简、上手门槛低。目前已有1470人学习/下载。核心实现利用FileReader的readAsDataURL方法将图片转为Base64数据URL,并借助闭包保留每次循环的文件对象,避免预览错乱;同时监听文件输入框的change事件,并在预览区域处理drop和dragover事件,实现点击多选与拖拽上传两种方式。demo.js与index.html配合清晰展示了事件监听、闭包处理和多图渲染逻辑。源码已实测通过,附带脚本之家与服务器软件快捷方式,便于本地环境配置参考。学习后可深入理解HTML5文件读取和拖放相关接口的实际应用,并快速移植到自己的项目中,也可作为课程设计或功能演示的参考。
1. 多图片上传预览:html5 的一个“小功能”为什么总在细节上翻车
第一次在后台系统里做商品图上传,我以为给 input 加上 multiple 就算完成。后来才知道,多图片上传预览看着是一个 html5 小功能,实际的水都在 File API 的边角逻辑里:选完图要在本地立刻显示、删掉一张后数据和视图要同步、连续选同一张图要能再次触发、几十张原图同时预览不能把页面拖垮。这个标题里说的“源码,已测试”,重点不在输入框本身,而在这些细节和内存管理。它适合商品主图、证件照、聊天附件这类“先选图、再确认、后提交”的场景。本文给出一套已跑通的完整实现,并把这几个最容易翻车的位置逐一拆开,方便直接照着改。
2. 原理先行:html5 File API 是怎么做到“选完就看见”的
做预览前,先把 File API 这条链路上的三个关键点理顺。很多代码写到最后发现各种怪异行为,都是因为只记住了 API 名字,没搞清它们各自的边界。
2.1 三个支柱:multiple 属性、FileList、ObjectURL
第一个支柱是multiple属性。给<input type="file">加上multiple之后,文件选择器才允许一次选中多张图。这个属性只影响选择交互,不改变后续读取方式。很多人以为多选很复杂,其实它就差这一个单词。
第二个支柱是 FileList。用户选定文件后,input.files拿到的是一个 FileList 对象,不是数组。FileList 虽然支持下标访问和length,但没有数组的map、forEach、filter方法。新手最容易在这里报错:直接在input.files上调用.forEach,浏览器直接抛 TypeError。常见做法是用Array.from(e.target.files)转成真数组,后面所有操作统一按数组处理。
第三个支柱是URL.createObjectURL(file)。这个 API 会给传入的 File 对象生成一个blob:http://xxx形式的临时地址,把这个地址赋给img.src,浏览器就能直接读取本地文件内容并渲染图片,整个过程完全不经过服务器。它和<a>标签的下载、<video>标签的视频预览用的是同一套机制,所以在多个场景都通用。
用一小段代码验证这三个点,比死记概念更快:
// 在浏览器控制台运行,选几张图观察输出 const input = document.createElement('input'); input.type = 'file'; input.multiple = true; document.body.appendChild(input); input.addEventListener('change', () => { const files = input.files; console.log(files instanceof Array); // false console.log(Array.from(files).map(f => f.name)); // 正常输出文件名数组 Array.from(files).forEach(file => { console.log(URL.createObjectURL(file)); // blob:http://... }); });这里先验证 FileList 是“类数组对象”而非数组,再用Array.from转换,最后为每个文件生成 ObjectURL。理解这三步之后,预览的整体思路就清楚了:change 事件触发后拿 FileList,转数组,生成 ObjectURL,再塞进<img>标签。
2.2 ObjectURL 与 FileReader 的取舍:内存、兼容性、释放时机
本地方案还有一条路是FileReader.readAsDataURL,把文件完整读成 base64 字符串,同样可以赋给img.src。两条路都能预览,但取舍点完全不同,选错在后期会很痛。
ObjectURL 的最大优势是“轻”。它只是创建了一个指向底层文件的引用,不会立刻把整张图片的像素数据读进内存。渲染大图时,浏览器按自己的策略解码,不会一次性把原图所有分辨率都算进来。代价是必须主动释放:调用URL.revokeObjectURL(url)。如果一直不释放,连续选择、删除几轮后,浏览器里会堆积大量已经不在页面上的 blob 引用,表现为页面越用越卡。
FileReader 的优势则在于“省心”。读取完成后浏览器自动释放相关内存,不需要手工 revoke,而且获取的是 base64,可以直接塞进表单字段或 JSON。坏处也很明显:读大图时整张图会被完整读进 JS 内存,转成 base64 后体积再膨胀约 33%。一次性选多张大图,低端移动设备很容易白屏或卡死。
| 对比项 | URL.createObjectURL | FileReader.readAsDataURL |
|---|---|---|
| 内存占用 | 低,引用文件而非整包读取 | 高,完整读入内存 |
| 生成结果 | blob 临时地址 | base64 字符串 |
| 主动释放 | 需要 revokeObjectURL | 自动回收 |
| 体积变化 | 不变 | 膨胀约 33% |
| 兼容下限 | 现代浏览器稳定 | 更老的环境可用 |
我的习惯是:本地预览一律用 ObjectURL;表单提交一律用FormData携带原始File对象;只有后端接口明确要求 base64,或者目标环境老到不支持 ObjectURL 时才退回 FileReader。混用两种方案没有意义,反而会让释放逻辑变得混乱,排查问题也更难。
2.3 三个容易误判的操作:清空 value、同步数组、释放时机
这套方案代码量不大,但有三处认知特别容易跑偏,提前在心里划好线可以省掉很多测试时间。
第一,input.value必须清空。change 事件触发依赖文件选择器的值发生变化。如果上一次选了 A.jpg,却没有清空input.value,下一次再选 A.jpg 时浏览器会认为值没变,change 事件不触发。这就是“同一张图第二次选不上”的根源。
第二,DOM 里删了一张图,数据数组里也必须删同一张。预览格子和 File 数组之间要保持一一对应,删除时如果只把 DOM 节点移除,后面提交的时候,那个已经被删掉的文件还在数组里,会被一起传到后端。常见做法是同时维护files和urls两个平行数组,删除时同步 splice。
第三,ObjectURL 的释放要绑定“这张图不再展示”的时机,而不是绑定“页面卸载”。尤其在单页应用里,页面不会销毁,组件却可能反复挂载。正确做法是把 revoke 放到删除函数里,组件销毁时再兜底清理一遍。不要在其他位置“感觉没用了”就提前 revoke——img 还没加载完时释放临时地址,预览会直接白屏。
注意:
accept="image/*"只是文件选择器层面的推荐筛选,不是安全边界。用户仍然可能选择非图片文件,后端必须再次校验文件类型、大小和内容。
3. 完整实现:一个已跑通的多图片上传预览组件
下面这套代码是我在实际项目里拆出来的最小可用版本,没有引入任何依赖,直接复制到一个 HTML 文件里就能跑。组织结构上分成 HTML、JavaScript、CSS 三部分,删图用事件委托,避免每张图单独绑监听器。
3.1 HTML 骨架:只写一个 input 和一个容器
页面结构只要两个关键元素:隐藏的文件输入框和预览容器。文件输入框放在<label>里,用自定义样式的按钮触发选择,避免默认的文件选择按钮丑且不可控。
<label class="upload-btn"> <input type="file" id="fileInput" accept="image/*" multiple hidden> <span>选择图片</span> </label> <div id="previewList" class="preview-list"></div>这里multiple是必须的,不加它就只能单选。accept="image/*"用来在多数系统里过滤图片类型,但要注意它不是强校验,部分环境下用户仍然可以切换筛选条件选择非图片文件,所以后面 JS 里还要再做一次类型判断。hidden让原生 input 不可见,点击<label>会直接触发它,这是表单控件里最常用的隐藏触发方式。
3.2 核心 JS:从 FileList 到预览格子的最小逻辑
核心逻辑分四块:监听 change、读取文件、渲染预览、删除同步。我用两个平行数组state.files和state.urls管理数据,前者存原始 File 对象用于提交,后者存 ObjectURL 用于渲染。
const input = document.getElementById('fileInput'); const previewListEl = document.getElementById('previewList'); const state = { files: [], // 原始 File 对象,提交表单时使用 urls: [] // 与 files 一一对应的 ObjectURL }; // change 事件:读文件,更新数据,渲染 input.addEventListener('change', (e) => { const selected = Array.from(e.target.files || []) .filter(file => file.type.startsWith('image/')); selected.forEach(file => { state.files.push(file); state.urls.push(URL.createObjectURL(file)); }); render(); input.value = ''; // 清空,允许再次选择同一张图 }); // 渲染:根据数组全量重建预览格子 function render() { previewListEl.innerHTML = state.urls.map((url, index) => ` <div class="preview-item">.upload-btn { display: inline-block; padding: 8px 16px; background: #409eff; color: #fff; border-radius: 4px; cursor: pointer; } .preview-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; } .preview-item { position: relative; width: 96px; height: 96px; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; } .preview-item img { width: 100%; height: 100%; object-fit: cover; display: block; } .remove-btn { position: absolute; top: 4px; right: 4px; background: rgba(0, 0, 0, 0.55); color: #fff; border: none; border-radius: 4px; padding: 2px 6px; cursor: pointer; }关键是object-fit: cover,它让任意比例的图片填充满整个格子,同时裁掉多余部分,不会把图片压扁。display: block能消除 img 作为行内元素带来的底部空隙。删除按钮用绝对定位放在右上角,背景半透明,避免图片背景干扰辨识。flex-wrap: wrap保证多图自动换行,gap控制格子间距,比手写 margin 干净。
如果希望预览区横向滚动而不是换行,把.preview-list改成flex-wrap: nowrap; overflow-x: auto;,并把.preview-item的宽度固定成flex: 0 0 96px,效果类似常见图片管理器的横向拖拽条。
3.4 拖拽上传:把 drop 事件并入同一套处理
多图片上传的另一个常见入口是拖拽。html5 的drop事件会给出event.dataTransfer.files,结构和input.files完全一致,所以处理逻辑可以直接复用。常见做法是在预览容器上监听 dragover、dragleave 和 drop。
const dropZone = document.getElementById('previewList'); ['dragover', 'dragenter'].forEach(type => { dropZone.addEventListener(type, (e) => { e.preventDefault(); // 阻止浏览器默认打开图片 dropZone.classList.add('dragging'); }); }); dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('dragging'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('dragging'); const dropped = Array.from(e.dataTransfer.files || []) .filter(file => file.type.startsWith('image/')); dropped.forEach(file => { state.files.push(file); state.urls.push(URL.createObjectURL(file)); }); render(); });dragover里必须调用preventDefault,否则浏览器会直接在当前页面打开这张图片,上传流程直接被中断。drop事件回调同样要preventDefault,防止叠加一层默认行为。dataTransfer.files和e.target.files在文件对象层面没有区别,所以这套代码可以和 change 事件共用一个“添加文件”函数,避免重复逻辑。
4. 性能防护:图片一多、一大,体验是怎么崩的
预览功能在 3 张图时看不出问题,到了 9 张原图、每张 5MB 以上的场景,内存和渲染压力会同时暴露。这一章的三个手段,建议至少选两个做进项目里。
4.1 数量失控:最多 9 张的限制要写在两个层面
后台表单最常见的需求是“最多传 9 张”。如果前端不加限制,用户无限选图,内存会线性增长。限制不能只写在 UI 提示上,要在数据入口真正截断。
const MAX_COUNT = 9; function appendFiles(newFiles) { const remain = MAX_COUNT - state.files.length; if (remain <= 0) { console.warn('图片数量已达上限'); return; } const accept = newFiles.slice(0, remain); // 只补充剩余名额 accept.forEach(file => { state.files.push(file); state.urls.push(URL.createObjectURL(file)); }); render(); }newFiles.slice(0, remain)是按“剩余名额”裁剪的关键。例如当前已有 7 张,再一次性拖入 5 张,只取前 2 张,其余直接丢弃。这个逻辑同时适配 input 选择和拖拽,因为两者都先转成数组再交给appendFiles。上限校验必须放在数组入口,不能只靠accept或 UI 层提示,否则用户拖拽一次就能绕过限制。
4.2 大图预览:canvas 压缩其实没有很复杂
手机拍摄的原图经常是 4000×3000,直接赋值给img.src,浏览器解码的是一次全尺寸大图,即使预览格子只有 96px,内存消耗也不小。常见做法是预览前做一次尺寸压缩,把长边压到 1280px 左右,品质控制在 0.8,视觉几乎没有损失,内存开销却小一个量级。
function compressImage(file, maxSide = 1280, quality = 0.8) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { const scale = Math.min( maxSide / img.width, maxSide / img.height, 1 ); if (scale === 1) { // 原图本身小于阈值,直接用原文件 resolve(file); return; } const canvas = document.createElement('canvas'); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { if (!blob) { // 压缩失败就退回原文件 resolve(file); return; } const compressed = new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg', lastModified: Date.now() }); resolve(compressed); }, 'image/jpeg', quality); }; img.onerror = () => resolve(file); img.src = e.target.result; }; reader.onerror = () => resolve(file); reader.readAsDataURL(file); }); }scale同时限制宽高,取两个方向缩小的最小值,并和 1 做比较,避免把小图放大。canvas.toBlob输出 JPEG 格式,质量参数 0.8 是通用选择,压缩后的文件还是标准的 File 对象,可以直接放进 FormData 提交。使用前在appendFiles里改成await compressImage(file)即可。
提示:压缩会读取一次 base64,这个过程有一次性内存消耗,但比直接渲染原图省得多。如果业务上还要保留原始高清图,压缩只用于预览,原始文件单独存一份,提交策略要区分。
4.3 并发渲染:几十张图同时展示时给主线程让路
如果用户一次性选 50 张图,循环创建 ObjectURL 很快,但浏览器要同时加载 50 个 img、解码 50 张图片,首帧必然变慢。常见优化是分帧渲染,把全量 DOM 构建拆成每帧一小段。
function renderByBatch(start = 0, batchSize = 6) { const all = state.urls; if (start >= all.length) return; const fragment = document.createDocumentFragment(); all.slice(start, start + batchSize).forEach((url, i) => { const index = start + i; const item = document.createElement('div'); item.className = 'preview-item'; item.dataset.index = index; item.innerHTML = `<img src="${url}" alt="${state.files[index].name}" />`; fragment.appendChild(item); }); previewListEl.appendChild(fragment); requestAnimationFrame(() => renderByBatch(start + batchSize, batchSize)); }每次只处理 6 张,用requestAnimationFrame把下一次构建延到下一帧,避免主线程一次性被耗光。DocumentFragment先把小批次 DOM 组装好,再一次性追加到容器,减少页面回流次数。这种写法对几十张图很有效,但对几百张图仍然不够,真正的解法要在上传入口限制数量和压缩尺寸,分帧只是体验优化,不是兜底方案。
5. 多图片上传预览的高频坑:现象、原因、解决
这一章把我在实际测试里撞过的问题按“现象、原因、解决”列出来,每条都在代码层面给了针对性处理。看完可以对照自己的实现逐条排查。
5.1 同一张图第二次选不上
现象:第一次选择 A.jpg,预览正常。删除后再次选择同一个 A.jpg,页面毫无反应,像没点过一样。
原因:input.value没有清空。文件选择器认为当前值仍然是 A.jpg,再次选择相同的文件时值没有变化,change 事件不触发。
解决:在每次处理完文件后立即执行input.value = ''。这一步让 input 回到未选择状态,下次选同一个文件也能正常触发。它应该放在 change 事件回调的最后,不在初始化阶段,也不在渲染函数里。
5.2 删除中间一张后,预览和提交数据错位
现象:5 张图片里删掉第 3 张,页面上显示的是 4 张,但点击第 4 张的删除按钮,删掉的却是另一张,提交时数据顺序也乱了。
原因:删除只操作了 DOM,没有同步更新state.files数组。或者渲染时用的是 DOM 序号,而不是数据数组的索引。两者不同步就会在后续操作中越错越远。
解决:删除入口统一从数据层处理。先 splice 掉state.files和state.urls里的对应项,再全量调用render()。因为每次渲染都按数组重建 DOM,只要数据正确,页面就正确。不要在删除函数里直接找 img 节点并 remove,这样会绕过数据同步。
5.3 预览突然白屏
现象:图片正常显示,某些操作后变成空白格子,控制台没有明显报错。
原因:ObjectURL 被提前 revoke 了。常见是在组件内某个清理函数里对所有 url 执行了revokeObjectURL,但某个 img 还在显示,或者浏览器还没来得及完成解码。一旦 revoke,再看这个地址就是空内容。
解决:谁负责删除图片,谁负责 revoke。删除函数里 splice 之前释放对应资源;组件销毁时再统一释放一遍。除了这两处,不要在其它地方随意调用 revoke。尤其是做拖拽排序、隐藏格子这类操作时,只更新样式,不动 ObjectURL。
5.4 页面越用越卡,卡到失去响应
现象:反复选择、删除、再选择多轮之后,页面操作开始明显卡顿,内存占用居高不下。
原因:ObjectURL 只创建不释放,浏览器积累了大量 blob 资源。另一个可能是每次新增图片都全量innerHTML重建整个容器,积累了太多已失效的 DOM 节点和监听器。
解决:删除时同步 revoke,组件卸载时兜底清理全部 url。渲染侧,如果单次数十张图以上,改用DocumentFragment或分批渲染,不要在每次新增时用innerHTML覆盖整个容器。对于大图,按第 4 章的方案做压缩预览,降低单张解码成本。
5.5 上传请求慢或报 413,问题在 base64
现象:本地预览正常,点击提交后请求特别慢,有时直接被网关拒绝,报 413 或请求体过大。
原因:实现里用了 FileReader 把图片读成 base64 再提交。base64 体积比原文件膨胀约 33%,多张图叠加之后,请求体很容易超过接口限制,浏览器和服务端的传输时间都会显著增加。
解决:预览用 ObjectURL,提交用FormData.append('file', file)直接带原始 File 对象,不上传 base64。如果后端接口强依赖 base64,就在前端压缩后再转 base64,并且明确帮后端控制单图体积上限。
6. 进阶:把预览组件封装成可复用的上传控件
6.1 工厂函数:把散落的代码收进一个 init
项目里多个页面都要传图片,直接把上一章的代码复制粘贴是最差的做法。我一般会把整套逻辑封装成工厂函数,返回清理方法和当前文件列表,业务页面只关心回调。
function createUploader({ input, container, maxCount = 9, onChange } = {}) { const state = { files: [], urls: [] }; input.addEventListener('change', handleFiles); container.addEventListener('click', handleRemove); function handleFiles(e) { /* 解析 FileList,走 appendFiles */ } function handleRemove(e) { /* 事件委托,删除同步 */ } function render() { /* 全量渲染 */ } return { getFiles: () => state.files.slice(), clear() { state.urls.forEach(url => URL.revokeObjectURL(url)); state.files = []; state.urls = []; render(); }, destroy() { state.urls.forEach(url => URL.revokeObjectURL(url)); input.value = ''; input.removeEventListener('change', handleFiles); container.removeEventListener('click', handleRemove); } }; }这样每个页面只需传入对应的 input 和容器,getFiles()取提交数据,destroy()在页面离开时清理监听器和内存。封装后,拖拽、压缩、数量限制都变成新增选项,而不是在页面里堆代码。
6.2 编辑回显:本地预览与已有图片的共存
编辑页会遇到“已经有一批图片,需要同时展示原图和新增图”的情况。原图是服务端地址,新图是本地 ObjectURL,两者不能混在一个数组里处理。正确做法是分开管理:existingImages放服务端地址,newFiles放本地新选文件,提交时旧图只传 id,新图走 FormData。渲染时两类数据都放进同一个容器,各自打上 type 标记,删除时再走不同的清理逻辑。这个结构一旦在初期理清,后面接排序、裁剪、主图标记都自然得多。
6.3 上线前按这份清单过一遍
无论在任何环境,我都会按固定顺序检查一遍再交付:
- 选 9 张 3MB 以上的照片,观察预览首帧和删除后的响应速度。
- 删除中间一张,反复操作 6 次,确认 DOM 顺序和数据顺序始终一致。
- 选择同一张图两次以上,确认第二次仍能触发 change。
- 打开浏览器内存面板,连续执行“选图-删除”十轮,观察内存是否回落。
- 断网状态下完成选择、预览、删除,确认本地预览完全不依赖网络。
多图片上传预览代码量不大,属于典型的“简单但坑多”功能。我自己的习惯是一直维护一份和 DOM 一一对应的 File 数组,预览只管显示,提交只从数组里取,不在 DOM 上做任何状态管理。把这条原则守住,后面接拖拽排序、进度条、裁剪都不会塌。希望帮到你。
本文还有配套的精品资源,点击获取