ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

HTML5多图上传预览源码:File API与FormData实战

HTML5多图上传预览源码:File API与FormData实战

简介:面向前端初学者和需要实现本地图片预览功能的开发者,这份 HTML5 多图片上传预览源码围绕 File API、FileReader 与拖放 API 展开,演示如何在不依赖服务器端处理的情况下完成多选图片读取、Base64 预览和拖拽上传交互。压缩包共 18 个文件,约 145KB,包含 9 个 png 界面图片、5 个 js 脚本、1 个 css 样式、1 个 html 页面及 2 个 url 快捷方式,便于直接查看页面结构、样式组织与脚本调用关系。已有 1473 人学习下载,适合作为课堂示例或小型项目原型参考。源码保留了页面入口、示例脚本、上传控制与核心逻辑等模块,可帮助读者理解文件读取回调、闭包保存文件对象、dragover 与 drop 事件处理等关键点,并在此基础上改造成头像上传、商品图批量预览等常见场景。对于希望从原生 JavaScript 入手理解上传流程的读者,这份源码结构轻量,适合逐行阅读和二次修改。

1. 从一次选 30 张图卡死说起:HTML5 多图预览源码到底能干什么

一个后台上传页,用户一次选了三十张商品图,缩略图只出来一半,页面还卡得无法滚动。排查后发现不是接口慢,而是前端把原图直接塞进页面,blob URL 用完也没释放。这份 HTML5 多图片上传预览源码解决的正是这类问题:它把文件选择、类型与大小校验、缩略图渲染、删除重选、FormData 上传和进度反馈串成一条可运行链路,适合做商品图、工单附件、资料提交的团队直接拿来做基线。

它不依赖复杂框架,核心是浏览器原生的 File API、URL.createObjectURL、FormData 和 XMLHttpRequest。新手能照着把页面跑起来,熟手也能把预览、上传、删除拆成独立模块,接进自己的业务页面。源码不大,但上传组件最容易翻车的地方基本都覆盖到了,下面按目录结构、核心渲染、上传交互和二次开发几个层面拆开讲。

2. 技术选型与目录结构:原生 HTML5 + File API 的轻量组合

2.1 为什么不用框架:File API 与 URL.createObjectURL 的边界

多图预览这件事,浏览器原生能力已经够用。用户点选择文件后,<input type="file" multiple>会给出一个FileList,里面的每个File本质上是带文件名的Blob。前端要做的第一件事不是上传,而是把本地文件变成页面能显示的图像。常见做法有两条:URL.createObjectURL(file)生成blob:地址,或者FileReader.readAsDataURL(file)读成 base64 字符串。两条路都能显示图片,但内存和释放方式差别很大。

方式生成速度内存占用释放方式适合场景
URL.createObjectURL快,不读取文件内容较低,只持有 Blob 引用手动URL.revokeObjectURL多图、大图预览
FileReader.readAsDataURL需要完整读取文件较高,base64 字符串比原文件大约多三分之一变量置空后等垃圾回收单图、需要把图片字符串存起来

这份源码选择createObjectURL做预览,原因是多图场景下它不会把每张图都转成一大段字符串。代价是每个生成的 URL 都要手动释放,删除图片、重新渲染、离开页面时都得管。很多上传组件预览正常但越用越卡,问题就出在这里:只创建不释放,页面里堆着一批已经没人用的 blob 地址。

File API 还有几个边界要提前知道。浏览器不会把本地文件的真实路径交给 JavaScript,input.value也不能反过来赋一个本地路径;File对象是只读的,要压缩或旋转得先读进canvas再导出新的 Blob;拖拽上传拿到的文件在DataTransfer.files里,和 input 选择出来的FileList不是同一个对象,但结构相似,可以统一转成数组处理。把这些边界认清,后面写校验和渲染就不容易乱。

如果页面已经在某个前端框架里,这套逻辑也可以包成组件,但核心 API 不变:选择文件、校验、生成预览 URL、维护文件数组、组装 FormData。原生写法适合做轻量后台页,也适合作为框架组件的底层实现参考。

2.2 目录怎么摆:一个入口页、一个样式、一个上传脚本

源码的目录结构不复杂,典型摆法是一个入口页、一个样式文件、几个按职责拆开的脚本。下面这棵树是这类项目最常见的组织方式,二次开发时也方便定位问题:

multi-image-upload/ ├── index.html # 入口:文件选择、拖拽区、预览列表、上传按钮 ├── css/ │ └── upload.css # 预览网格、删除按钮、拖拽高亮、进度条样式 ├── js/ │ ├── config.js # 上传地址、字段名、数量与大小限制 │ ├── preview.js # 文件校验、缩略图渲染、删除与重选 │ └── upload.js # FormData 组装、XHR 上传、进度与失败处理 └── README.md # 运行方式、接口字段约定、常见问题
文件主要职责二次开发常改点
index.html页面骨架,放 input、拖拽区、预览容器改按钮文案、加业务说明、调整布局
css/upload.css预览项尺寸、删除按钮位置、拖拽态改成表格布局、适配移动端
js/config.js上传接口、字段名、最大数量、单文件大小换上传地址、改字段名、调限制
js/preview.js文件过滤、生成预览、删除重选加压缩、加排序、换预览样式
js/upload.js组装 FormData、发请求、更新进度加并发队列、加签名、改重试策略

我一般会把config.js单独抽出来,因为上传地址、后端接收字段名、最大数量这三项在不同项目里几乎必改。预览逻辑和网络逻辑分开,排查时也清楚:缩略图不出来就看preview.js,接口报错就看upload.js,不用在一大坨代码里来回翻。

预览容器建议用div网格,每个预览项里放img、文件名和删除按钮。文件名不要用innerHTML直接拼,用户可以把文件名改成带尖括号的字符串,拼进 HTML 会变成注入点。用textContent或 DOM API 创建节点更稳。

2.3 运行方式:本地静态服务与后端接口占位

源码是静态页面,启动不需要构建工具。进入目录后起一个本地静态服务即可:

cd multi-image-upload python3 -m http.server 8080 # 或者用 Node 环境下的静态服务命令 npx serve .

然后在浏览器访问http://localhost:8080。不建议直接双击index.html用file://打开,部分浏览器在本地文件协议下对 XHR、跨域和 File API 的限制不一样,预览可能正常,上传却失败,排查起来很绕。本地静态服务更接近真实页面环境。

上传地址在config.js里留成占位,接自己的接口时先改这里:

// js/config.js window.UPLOAD_CONFIG = { url: '/api/upload', // 换成自己的上传接口 fieldName: 'images[]', // 必须与后端接收字段一致 maxCount: 9, // 一次最多保留多少张 maxSize: 5 * 1024 * 1024, // 单文件大小上限,单位字节 accept: 'image/*' // 文件选择器的默认过滤 };

url是上传接口地址,前后端同域时写相对路径最省事;fieldName要和后端约定一致,很多“文件传上去了但后端收不到”的问题就是字段名对不上;maxCount和maxSize是前端第一道限制,后端仍然要再校验一次;accept只影响系统文件选择器的默认过滤,不能当成安全校验。

跑通验证可以按这个顺序:先选两三张图,看缩略图是否正常;点删除,看列表和文件数组是否同步;点上传,在浏览器开发者工具的 Network 面板里看请求体是不是multipart/form-data,字段名和文件数量对不对;最后把maxCount改成 2,再选 5 张,看限制是否生效。接口失败时先看状态码,413 多半是请求体超过网关限制,400 多半是字段或参数不对,500 才去查服务端日志。

提示:accept="image/*"只影响文件选择器的默认过滤,用户仍可能通过“所有文件”或拖拽带进非图片文件,JavaScript 里的类型校验不能省。

3. 核心模块拆解:从 input change 到缩略图渲染的完整链路

3.1 文件选择与多选约束:multiple、accept 与 FileList 转换

页面上的文件选择入口通常是一个隐藏的input加一个可见按钮,点击按钮触发 input 的点击事件。multiple决定能不能多选,accept决定选择器默认显示哪些文件:

<input id="fileInput" type="file" name="images[]" accept="image/*" multiple hidden> <label for="fileInput" class="upload-btn">选择图片</label> <div id="dropzone" class="dropzone">也可以把图片拖到这里</div> <div id="previewList" class="preview-list"></div>

multiple加上后,用户可以在文件选择器里按住 Ctrl 或 Shift 多选。accept="image/*"会让选择器默认过滤图片,但它不是强制拦截,拖拽和“所有文件”都可能带进别的类型。真正校验要在change事件里做。

FileList是类数组对象,不能直接调map、filter,常见做法是先Array.from转成数组,再做类型、大小、数量校验:

const input = document.querySelector('#fileInput'); const state = { files: [], // 当前保留的 File 对象 urls: [] // 对应的 blob 预览地址,后面统一释放 }; input.addEventListener('change', (event) => { const picked = Array.from(event.target.files || []); const valid = []; for (const file of picked) { // 类型可能为空,用扩展名兜底更稳 const isImage = file.type.startsWith('image/') || /\.(jpe?g|png|gif|webp|bmp)$/i.test(file.name); if (!isImage) continue; if (file.size > UPLOAD_CONFIG.maxSize) continue; valid.push(file); } // 追加而不是覆盖,剩余名额按 maxCount 截断 const remain = UPLOAD_CONFIG.maxCount - state.files.length; state.files.push(...valid.slice(0, Math.max(remain, 0))); renderPreview(); input.value = ''; // 允许第二次选择同一张图时再次触发 change });

这段代码做了三件事:把FileList转成数组,过滤非图片和超大文件,把合格文件追加进state.files。参数maxSize的单位是字节,5MB 写成5 * 1024 * 1024;maxCount控制总保留数量,已经选了 6 张、上限 9 张时,下次最多再收 3 张。input.value = ''是重复选择同一文件的关键,浏览器会把 input 的值和上次比较,值没变就不触发change。

注意:文件名、类型、大小都是客户端可以伪造的,前端校验只负责体验和第一道过滤,后端仍要检查 MIME、扩展名、文件头和存储大小。

3.2 预览渲染:createObjectURL 和 FileReader 怎么选

预览渲染的核心是把state.files映射成页面节点。每次文件数组变化时重新渲染一遍,但重新渲染前要先释放上一轮的 blob URL,否则旧地址会一直占着内存:

function renderPreview() { const oldUrls = state.urls; // 先保存旧地址 state.urls = []; previewList.replaceChildren(); // 清空旧节点 oldUrls.forEach((url) => URL.revokeObjectURL(url)); state.files.forEach((file, index) => { const url = URL.createObjectURL(file); state.urls.push(url); const item = document.createElement('div'); item.className = 'preview-item'; item.dataset.index = String(index); const img = document.createElement('img'); img.src = url; img.alt = file.name; img.loading = 'lazy'; // 图片多时延迟加载 const name = document.createElement('span'); name.className = 'file-name'; name.textContent = file.name; // 不用 innerHTML,避免文件名注入 const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'remove-btn'; btn.dataset.index = String(index); btn.textContent = '删除'; item.append(img, name, btn); previewList.appendChild(item); }); }

这段代码的顺序有讲究:先保存旧 URL 数组,清空容器,再统一revokeObjectURL。如果先生成新 URL 再释放旧 URL,容易把还在页面上的图片地址提前释放,出现缩略图裂开。img.loading = 'lazy'在图片多时能减少一次性解码压力;textContent写文件名比innerHTML安全;>previewList.addEventListener('click', (event) => { const btn = event.target.closest('.remove-btn'); if (!btn) return; const index = Number(btn.dataset.index); if (!Number.isInteger(index) || index < 0 || index >= state.files.length) return; state.files.splice(index, 1); renderPreview(); // 以 state.files 为准重新渲染,索引自然更新 });

这段逻辑的关键是“单一数据源”:state.files是唯一事实,页面只是它的投影。删除时只改数组,然后整体重渲染,不要手动去删某个 DOM 节点再自己维护下标。否则删掉第一张后,第二张的>async function uploadAll() { if (!state.files.length) return; const formData = new FormData(); state.files.forEach((file) => { formData.append(UPLOAD_CONFIG.fieldName, file, file.name); }); formData.append('bizType', 'goods'); // 业务字段按需添加 const xhr = new XMLHttpRequest(); xhr.open('POST', UPLOAD_CONFIG.url, true); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); updateProgress(percent); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { const result = JSON.parse(xhr.responseText || '{}'); console.log('上传成功', result); } catch (error) { console.error('响应不是合法 JSON', xhr.responseText); } } else { console.error('上传失败', xhr.status, xhr.responseText); } }; xhr.onerror = () => console.error('网络错误或跨域失败'); xhr.send(formData); }

formData.append(fieldName, file, file.name)里的第三个参数决定后端收到的文件名,不传时浏览器会用默认文件名,某些后端拿到的名字会变成blob。bizType是示例业务字段,实际项目里可能是订单号、资料类型、用户标识,按接口文档加。xhr.upload.onprogress只监听上传阶段,event.lengthComputable为true时loaded / total才有意义。

注意:用 FormData 上传时不要手动设置Content-Type,浏览器需要自己生成带 boundary 的请求头,手动写死反而会让后端解析失败。

批量上传和逐张上传要按业务选。批量提交一次请求文件多,进度是整体进度,单文件失败不好定位;逐张上传可以给每张图单独显示状态和重试,但请求数多。常见折中是限制并发数,比如同时最多传 3 张,其余排队。

4.2 拖拽上传:dragenter/dragover/drop 的事件顺序

拖拽区要让浏览器知道“这里可以放文件”,关键是dragover里调用preventDefault,否则drop不会触发:

const dropzone = document.querySelector('#dropzone'); ['dragenter', 'dragover'].forEach((type) => { dropzone.addEventListener(type, (event) => { event.preventDefault(); event.stopPropagation(); dropzone.classList.add('is-over'); }); }); ['dragleave', 'drop'].forEach((type) => { dropzone.addEventListener(type, (event) => { event.preventDefault(); event.stopPropagation(); dropzone.classList.remove('is-over'); }); }); dropzone.addEventListener('drop', (event) => { const dropped = Array.from(event.dataTransfer.files || []); const images = dropped.filter((file) => file.type.startsWith('image/')); addFiles(images); // 复用前面 change 里的校验和追加逻辑 });

dragenter和dragover都要阻止默认行为,只写drop是不够的。dragleave在鼠标经过子元素时会频繁触发,导致高亮闪烁,简单做法是给拖拽区套一层计数器,进入加一、离开减一,减到零再移除高亮。drop里从event.dataTransfer.files取文件,拖文件夹时这个列表可能为空,要提示用户拖具体图片文件。

拖拽和 input 选择最后都走同一个addFiles校验入口,避免两套逻辑规则不一致。移动端浏览器对桌面拖拽支持有限,移动端还是以点击选择为主,拖拽区可以隐藏或降级成提示文案。

4.3 进度与失败重试:xhr.upload.onprogress 的可用范围

上传进度用 XHR 最稳,fetch在不少浏览器里拿不到上传阶段进度。重试策略要区分错误类型:网络中断、超时、5xx 可以重试,4xx 通常重试也没用,反而增加无效请求:

function uploadWithRetry(file, retry = 2) { return new Promise((resolve, reject) => { const attempt = (left) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('image', file, file.name); xhr.open('POST', UPLOAD_CONFIG.url, true); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.responseText); } else if (xhr.status >= 500 && left > 0) { attempt(left - 1); } else { reject(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => { if (left > 0) attempt(left - 1); else reject(new Error('network error')); }; xhr.send(formData); }; attempt(retry); }); }

retry是额外重试次数,传 2 表示首次失败后最多再试两次。xhr.status >= 500才重试,401、403、413、422 这类状态码直接失败并提示用户。并发上传时不要把所有文件同时send,浏览器对同域并发连接有限制,太多请求会互相排队,进度条也会跳。常见做法是维护一个长度 3 左右的队列,每完成一个再取下一个。

如果后端支持分片或断点续传,重试可以只传失败分片;普通图片上传没必要上分片,先把单文件大小限制和压缩做好更实际。

5. 避坑与排查:图片预览正常但上传失败的 5 个现场

5.1 现象:缩略图裂开,控制台没有明显报错

现象是选图后预览区出现破图图标,刷新或重新选择又偶尔正常。原因多半是 blob URL 被提前释放:在重新渲染时先生成了新 URL,又把旧 URL 全部revoke,如果旧节点还没被替换掉,正在显示的图片地址就失效了。另一种情况是删除图片后只移除了 DOM,没有同步释放 URL,下一次渲染复用了已经释放的地址。

解决顺序要固定:先保存旧 URL 数组,清空预览容器,再统一释放旧 URL,最后生成新 URL。删除、重选、离开页面都走同一个清理函数。排查时在createObjectURL和revokeObjectURL两边打日志,看某个 URL 是不是在图片还在页面上时就被释放了。

5.2 现象:第二次选择同一张图,change 不触发

现象是用户删掉一张图后,又从本地选择同一张图,预览没有增加。原因是 input 的 value 还停留在上一次的文件名,浏览器认为值没变,不再派发change。解决很简单,在change处理完后执行input.value = '',把 input 重置为空,下次选择同一文件也会触发。

如果页面用了表单提交而不是 FormData,重置 input 会影响原生表单字段,这种情况可以每次点击选择按钮时动态创建新的 input,或者用DataTransfer重新组装文件列表。多数上传组件用 FormData 提交,直接清空 input 最省事。

5.3 现象:一次选 50 张后页面内存暴涨、滚动卡顿

现象是图片数量一多,页面滚动明显掉帧,任务管理器里内存持续上涨。原因通常有三个:blob URL 只创建不释放;预览直接渲染原图,几千像素的大图被塞进小缩略图;用 FileReader 把每张图转成 base64,字符串常驻内存。解决方向是限制数量、生成缩略图、及时释放。

常见做法是在上传前用canvas把大图压到最长边 1600 像素左右,再导出 Blob 参与预览和上传。压缩代码要注意图片方向,部分手机拍的照片带 EXIF 旋转信息,直接画到 canvas 可能方向不对。前端不处理的话,后端转码时统一纠正也可以。

5.4 现象:接口返回 413,或者后端说收不到文件

413 是请求体超过限制,先查反向代理、网关和应用服务器的上传大小配置,再看前端有没有把多张原图一次性塞进同一个请求。字段收不到则常见于三种情况:前端append的字段名和后端接收名不一致;手动设置了Content-Type,导致 boundary 丢失;append文件时没传文件名,后端按普通字段解析。

排查时打开 Network 面板看请求头是不是multipart/form-data; boundary=...,再看请求体里每个 part 的name和filename。字段名带[]的写法要和后端约定一致,有些框架按同名多值接收,有些按数组接收。不要靠猜,直接拿浏览器发出的请求和后端接口文档对。

5.5 现象:移动端拍照方向不对,或者某些格式选不了、传不了

现象是手机拍的照片在预览里横着,或者用户选了一些特殊格式的图片,前端预览失败、后端也不识别。原因是照片文件里带方向标记,浏览器渲染时不一定按标记旋转;部分图片格式在浏览器里的解码支持不一致,accept过滤只是选择器行为。

解决方向分两层:前端展示时按方向信息旋转,或者上传前用 canvas 重绘一遍;后端统一转成常见格式并纠正方向。对于浏览器不支持的格式,前端可以在选择后检测解码是否成功,失败时提示用户换图,不要等上传完才报错。限制accept只能减少误选,不能代替解码检测。

6. 二次开发:把预览组件接到真实业务里的 4 个改造点

拿到源码后,先别急着加功能,把四个地方改对就能接进大多数业务:

改造点改哪里验证方式
上传地址与字段名js/config.js的url、fieldNameNetwork 里看请求体和后端接收参数
数量与大小限制maxCount、maxSize选超限文件,看是否被拦截并提示
上传前压缩preview.js或独立的compress.js对比压缩前后file.size和清晰度
并发队列upload.js的uploadAll一次选 9 张,看同时请求数是否受控

压缩这块我一般放在生成预览之前:先读文件到Image,按最长边缩放画到 canvas,再canvas.toBlob导出新文件,把新 Blob 塞回state.files。这样预览和上传用的都是压缩后的文件,内存压力小,接口也不容易 413。压缩质量别一上来就压到 0.5,商品图、证件照对清晰度要求不同,按业务调。

验证顺序也有讲究。我后来养成一个习惯:上传组件先断网测一次失败提示,再限速测一次进度条,最后拿一张 20MB 的大图压一遍才提测。断网能暴露重试和错误提示的问题,限速能看出进度是不是真的在动,大图能试出内存和接口限制。这套源码不大,但预览、删除、上传三段拆得比较清楚,拿到后先改config.js和字段名,再按业务加压缩和队列,基本就能落地。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表