尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

多图片上传预览与压缩:从HTML5 File到FormData的完整实现

发布时间:2026/9/25 1:58:03

资讯中心
01
ARTICLE

多图片上传预览与压缩:从HTML5 File到FormData的完整实现

多图片上传预览与压缩:从HTML5 File到FormData的完整实现
简介前端开发中文件上传是最常见的需求之一多图片预览更是后台管理系统和表单页面的高频功能。实现这一功能的关键在于理解浏览器提供的文件处理能力HTML5的File接口用于获取文件对象URL.createObjectURL可以生成轻量的本地预览地址比FileReader转base64占用更少内存。在此基础上通过canvas对图片进行压缩和格式统一能有效解决手机大图上传慢、后端无法识别HEIC等问题同时需要处理EXIF方向信息避免iOS照片在压缩后翻转。最终配合FormData和XMLHttpRequest实现带进度条的多文件提交并注意内存释放与兼容性陷阱。掌握这条链路即可脱离插件在原生环境下构建可靠的多图上传方案。1. 多图片上传预览HTML5 原生就能做但没你想的那么简单做后台管理系统、报名表单或者运营后台的时候「一次选多张图、先预览再提交」是高频需求。很多人第一反应是搜插件拖一个 jQuery 上传组件进来结果样式和业务耦合得乱七八糟也有不少人试过原生input typefile multiple发现选完图竟然一点反应都没有于是得出「还得上插件」的结论。实际上HTML5 的File接口加URL.createObjectURL()这条路二十行代码就能把多图预览跑起来真正花时间的是后面这四件事预留在删除时释放内存、处理手机拍照的图片方向、把大图压到能传的体积以及避开那些「现象很明显、原因很隐蔽」的兼容性坑。这篇文章就按我自己在项目里验证过的完整方案来讲核心代码都可以直接抄走改。适合刚接触input[typefile]的前端新手也适合要给旧系统套一个图集上传能力的全栈工程师。看完你不仅有一套能跑的源码还能知道它为什么这么写以及上线前该测哪些点。2. 从文件选择框到缩略图最小可用的多图预览链路2.1 给 input 放行多张图multiple 与 accept 的取舍先看最基础的文件选择框input typefile idpicker acceptimage/* multiple /multiple让选择框允许同时选中多张图这是多图预览的前提acceptimage/*在绝大多数桌面浏览器里会把非图片文件置灰在移动端会直接弹出相册/拍照选择。但这里有个容易误判的点accept只是「建议」不是「保险」。用户仍然可以从下拉框切到「所有文件」选一个 PDF 进来Android 部分 ROM 还会无视image/*的限制直接放行任何文件。所以前端校验类型这步不能省。const picker document.querySelector(#picker); picker.addEventListener(change, () { const imageFiles Array.from(picker.files).filter((f) f.type.startsWith(image/) ); // 只处理 imageFiles非图片文件单独给提示 });Array.from(picker.files)这一步不是多余的——FileList是类数组对象直接.filter会报错。另外移动端经常要用到capture属性来区分「拍照」还是「选相册」但要注意它的行为在不同浏览器里不一致后面避坑章节会展开。我的习惯是列表页保持acceptimage/*不带capture让系统自己弹选项只有明确的「拍证件照」场景才加captureenvironment。2.2 两条预览路线FileReader 与 URL.createObjectURL 怎么选拿到File对象之后把它变成浏览器能显示的图片业界有两条路线// 路线 AFileReader 把图片读成 base64 data URL function readAsDataUrl(file) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload () resolve(reader.result); reader.onerror () reject(reader.error); reader.readAsDataURL(file); }); } // 路线 BURL.createObjectURL 拿一个 blob: 地址指向本地文件 function makeObjectUrl(file) { return URL.createObjectURL(file); }两条路线都能给img的src用但差别很大我一般按这张表来选维度FileReader DataURLURL.createObjectURL内存占用base64 字符串体积约为原文件 1.33 倍多图时飙升很快只是一个内部引用内存占用小得多使用方式异步读取结果可以直接存进数组缓存同步拿到地址但地址有生命周期释放方式没有显式释放GC 自动回收必须调用URL.revokeObjectURL(url)手动释放适用场景需要把图片数据交给后端的场景较少本地预览、canvas 处理前的临时展示结论很明确做多图预览优先用URL.createObjectURL。FileReader不是不好而是 base64 扩大的体积在「一次选 9 张手机原图」的场景下非常致命——一张 5MB 的照片变成 6.7MB 字符串渲染九个img会让低端机明显卡顿。FileReader我一般只保留给「后端要求 data URL 直传」或者「需要读取图片二进制内容做分析」的场景。2.3 把缩略图铺到页面渲染与事件委托删除有了 objectURL剩下就是 DOM 渲染的体力活。我用最简单的方式直接拼 HTMLconst listEl document.querySelector(#previewList); const pendingFiles []; // 维护一份数组镜像存 { file, url } function renderOne(file, url) { const li document.createElement(li); li.dataset.index pendingFiles.length; li.innerHTML img src${url} altpreview button typebutton classremove-btn删除/button ; listEl.appendChild(li); } picker.addEventListener(change, () { for (const file of Array.from(picker.files)) { const url URL.createObjectURL(file); pendingFiles.push({ file, url }); renderOne(file, url); } });每个删除按钮都绑监听器当然可以但九张图绑九个闭包代码冗余不说动态追加时还得反复绑。更干净的做法是事件委托把监听器挂到父容器上listEl.addEventListener(click, (e) { const btn e.target.closest(.remove-btn); if (!btn) return; const li btn.closest(li); const index li.dataset.index; const removed pendingFiles[index]; URL.revokeObjectURL(removed.url); // 及时释放内存 pendingFiles.splice(index, 1); // 从镜像数组移除 li.remove(); // 重新编号剩余 li 的 dataset.index保持映射一致 Array.from(listEl.children).forEach((li, i) { li.dataset.index i; }); });这里有两个细节值得说。第一dataset.index在删除后要重排否则下一次删除按下标取pendingFiles会取到错位的数据第二revokeObjectURL必须在删除的同时调用等到下次刷新页面再清理是来不及的——你会在 DevTools 的 Memory 面板里看到不断上涨的 JS 堆。2.4 一个隐蔽的数据模型问题FileList 不是数组很多人做到删除这一步就翻车了因为他们想直接改input.files// 错误写法想从 FileList 里删掉一张 delete picker.files[0]; picker.files[0] null; // 两者都无效FileList是只读的你无法借由修改它来改变「已选文件」的状态。所以正确做法是放弃以input.files为准改用一个自己维护的数组上面代码里的pendingFiles作为唯一的“状态源”。renderOne、删除、提交时都从pendingFiles取数。input.files只负责在change时产生一次初始数据。这套「input 出数据、数组管状态」的思路对整个上传链路都适用后面做压缩和提交也一样。顺带提醒change事件在选择了文件后如果又选了一模一样的文件比如用户删掉又重新选同一张不会触发。解决办法是每次处理完后把picker.value清空picker.addEventListener(change, () { // ...处理逻辑 picker.value ; // 允许下次选择同一文件时再次触发 change });这条是「已测试」结论里最容易被忽略的一环放到避坑章节再展开。3. 预览只是开始大图压缩、方向修正与 FormData 上传3.1 大图不崩的底线canvas 压缩参数怎么设多图预览跑通之后下一个必踩的坑是「大图」iPhone 随手拍一张 4032×3024体积 4-7MB。不做压缩直接预览低端机直接掉帧不做压缩直接上传服务端post_max_size分分钟被打爆。所以生产环境里预览图和上传图最好都是压缩后的产物。function compressImage(file, { maxSide 1280, quality 0.72 } {}) { return new Promise((resolve, reject) { const img new Image(); img.onload () { let { width, height } img; const scale Math.min(1, maxSide / Math.max(width, height)); width Math.round(width * scale); height Math.round(height * scale); const canvas document.createElement(canvas); canvas.width width; canvas.height height; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, width, height); canvas.toBlob( (blob) { if (!blob) { reject(new Error(canvas.toBlob returned null)); return; } resolve(blob); // 返回的是一个 Blob可直接用于预览和上传 }, image/jpeg, quality ); }; img.onerror () reject(new Error(Image load failed)); img.src URL.createObjectURL(file); }); }关键参数说明maxSide 1280是「网页展示够用文件体积可控」的平衡点。1280px 宽的 JPEG 在quality 0.72下通常只有 100-300KB而视觉损失在普通屏幕上几乎看不出。quality建议在 0.7-0.8 之间低于 0.6 会出现明显的色块和边缘锯齿。还有个细节image/jpeg会让 PNG 的透明通道变成黑底如果你的业务里有透明图需求这里要改成image/png并且去掉quality参数。有一点必须讲清楚压缩是异步的而预览是立刻要显示的。如果你先显示原图、压缩完再换用户会看到图片「闪一下再变清晰」观感很差。我的做法是先显示一个轻量的 loading 占位等压缩完成直接用压缩后的 Blob 生成 objectURL 做预览async function handleFiles(fileList) { for (const file of fileList) { const blob await compressImage(file); // 压缩后的 Blob const url URL.createObjectURL(blob); pendingFiles.push({ file: blob, originName: file.name, url }); renderOne(url); } }这样内存里同时存在的只有压缩后的小图原始大图在压缩完就可以被 GC 回收整套链路的内存曲线会平缓非常多。注意pendingFiles里存的是blob上传时也要用这个 blob。3.2 EXIF 方向iOS 照片在预览和压缩里的翻转坑讲一个我当年翻车最狠的坑iPhone 横着拍的照片在电脑上看是正的但在浏览器预览里是横的压缩之后也还是横的。原因是手机拍的照片会在 JPEG 里写一个 EXIF 方向字段Orientation 6 表示需要顺时针旋转 90°浏览器img标签在部分引擎里会读这个字段自动扶正但canvas.drawImage()绘制时不会读它。这就是「原生img显示正常一进 canvas 就歪掉」的玄学现场。Chrome 87 之后给了个救急手段CSSimage-orientation: from-image但 Safari 老版本和不少 Android WebView 仍不认。要稳妥只能在压缩前手动读方向并旋转。读取方向最简单的方式是exif-js或者用现成的loadImage库。这里给出旋转修正的核心逻辑function fixOrientation(img, orientation) { const canvas document.createElement(canvas); const ctx canvas.getContext(2d); const { width, height } img; let sw width; let sh height; // 先按方向决定画布宽高需要 90°/270° 旋转时宽高互换 if (orientation 5 || orientation 6 || orientation 7 || orientation 8) { canvas.width height; canvas.height width; } else { canvas.width width; canvas.height height; } switch (orientation) { case 3: // 180° ctx.translate(width, height); ctx.rotate(Math.PI); break; case 6: // 顺时针 90° —— 手机横拍最常见的值 ctx.translate(height, 0); ctx.rotate(Math.PI / 2); break; case 8: // 逆时针 90° ctx.translate(0, width); ctx.rotate(-Math.PI / 2); break; default: // 1 和其他值直接画 break; } ctx.drawImage(img, 0, 0); return canvas; }在compressImage里等img.onload之后先执行方向修正再拿去drawImage压出来的图就是正的。你要是图省事也可以用带方向的createImageBitmap(file, { imageOrientation: from-image })它能把方向直接吃进位图里但兼容性需要单独测我自己的代码里两种都写了分支。3.3 带进度条的上传FormData 与 XMLHttpRequest压缩完成pendingFiles里存的是一个个 Blob最后一步就是提交。这里注意一个常被误解的点fetch天然支持 multipart 上传但它拿不到上传进度Upload 进度只有XMLHttpRequest有。要做进度条就得用 XHRfunction uploadFiles(files) { const form new FormData(); files.forEach((item, i) { // 第三个参数保留原始文件名服务端用它识别扩展名 form.append(files[], item.blob, item.originName); }); return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload); xhr.upload.onprogress (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); // 更新进度条 DOM比如 progressBar.style.width percent % } }; xhr.onload () { if (xhr.status 200 xhr.status 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(Upload failed with status ${xhr.status})); } }; xhr.onerror () reject(new Error(Network error)); xhr.send(form); }); }两个坑我提前说。第一不要手动给 XHR 或 fetch 设置Content-Type: multipart/form-data浏览器会自动生成带 boundary 的头你自己写反而会把 boundary 弄丢后端直接报「未找到 multipart 边界」。第二form.append的第三个参数是文件名如果你的压缩函数把 Blob 改名了比如统一叫compressed.jpg这里一定要传originName把原始名带过去否则服务端拿到的文件名全是blob扩展名判断整个失效。3.4 服务端必须同步调整的参数前端压得再好后端不配合也白搭。常见的后端接收配置按环境整理如下建议直接发给后端同学对照确认后端环境关键参数建议值PHPupload_max_filesize建议 8M 起前端已压缩通常 2M 足够PHPpost_max_size必须大于upload_max_filesize乘以最大文件数建议 20MPHPmax_file_uploads建议 20否则第 11 张图开始被静默丢弃Java (Spring)spring.servlet.multipart.max-file-size2MB 起Java (Spring)spring.servlet.multipart.max-request-size按文件数乘以上限建议 20MBNginx 反代client_max_body_size如果前端报 413检查这项默认只有 1m任意后端文件类型二次校验必须校验文件头和 MIME不能只信前端前端已经压缩到平均 150KB 一张九个文件撑死 1.5MB本来不用太高的配额。但如果你跳过压缩直接传原图上面这些 2M、20M 的配额分分钟就要上调所以前端压缩不只是优化体验也是在保护后端资源。4. 多图预览避坑清单5 个现象、原因与解决路径4.1 同一个文件选第二次change 事件不触发现象用户选了一张图删掉后再点选择框选同一张图页面毫无反应组件像死掉一样。原因这是input typefile的固有行为——只要input.files里还有内容下一次选择同一个文件时change不会触发浏览器认为「值没变」。删除操作只动了我们的pendingFiles和 DOM没动input.files所以第二次选同一个文件被视为「相同值」。解决在change事件处理完后立刻清空输入框的值让input回到初始状态picker.addEventListener(change, handleSelect); // 在 handleSelect 的最后追加 picker.value ;4.2 页面用着用着变卡DevTools 内存曲线只涨不降现象连续选图、删除、再选图操作几十次后页面明显卡顿performance.memory.usedJSHeapSize持续上升。原因每次URL.createObjectURL(file)都会在内存里持有一个对文件数据的引用。如果只删了 DOM、不调revokeObjectURL这个引用永远不会释放浏览器 GC 也收不掉最终堆内存被吃满。这是多图预览最容易犯的内存泄漏。解决删除逻辑里li.remove()之前必须先URL.revokeObjectURL(removed.url)。另外在页面卸载前把pendingFiles里所有存活 URL 一次性吊销window.addEventListener(beforeunload, () { pendingFiles.forEach((item) URL.revokeObjectURL(item.url)); });4.3 iOS 上传 HEIC 格式后端根本不认识现象iPhone 用户选了照片预览正常但后端收到文件后提示格式不支持或者图片无法解析。原因iPhone 默认照片格式是 HEIC后端没有 HEIC 解码能力。浏览器里img能显示是因为系统自带解码但 canvas 的toBlob输出 JPEG 后如果你没有统一转格式就直接上传原文件后端收到的就是它处理不了的 HEIC 或原 JPEG。解决前端压缩函数本身就是解法——canvas.toBlob指定image/jpeg把一切输入统一转成 JPEG。这就是 3.1 节那段代码的价值它不只是压缩也在统一格式。如果业务必须保留 PNG 透明通道按需在toBlob里判断原始 MIME 再决定输出格式。4.4 明明选了图却一直显示「图片加载失败」的裂图现象开发环境一切正常部署到 HTTPS 生产环境后部分图片预览是裂图图标。原因URL.createObjectURL依赖页面协议如果用 HTTP 页面生成blob:地址再放到 HTTPS 页面上或反过来混用部分浏览器会拦截。还有一种情况是revokeObjectURL被提前调用——比如 React 严格模式、或者你在异步渲染里先吊销了地址再设img.src图片就裂了。解决遵循「先设src后 revoke」的时序确保src赋值完成后才吊销同时页面尽量保持统一协议。我在 2.3 的渲染函数里特意没写 revoke就是为了避免新手在渲染时顺手调用导致时序错误。revokeObjectURL只在删除和卸载时出现一次。4.5 多图上传时后端只收到最后一张图现象前端FormData里 append 了九张图后端接口只接收到一张。原因最常见的是append时用了同一个字段名但服务端用了getParameter之类的单值取法还有一个隐蔽原因是某些后端框架如果请求体格式不标准就会静默丢弃部分字段。前端这里最容易出的问题是循环内把同一个 Blob 变量覆盖了append 进去的是同一个引用——如果你先压缩后form.append(file, blob, name)但在循环里不小心用了pendingFiles[i].file以外的全局变量就会出现九张图都指向最后一张。解决前端保证字段名用数组语义files[]后端用数组接收PHP$_FILES[files]、SpringMultipartFile[] files。循环时只从pendingFiles[i]取数据不缓存中间变量。测试时一定要选至少三张不同图片验证顺序和数量别只用一张图自测。5. 把「已测试」做成验收习惯内存检查、重复选择与手动回归标题里说「已测试」落到开发习惯上我建议你每次改完这套代码都按下面这个「三分钟验收流程」过一遍这比在本地点两下「能用」要可靠得多。第一项是重复选择回归。连续做三组操作选 9 张图、全部删除、再选同样的 9 张图确认第二次也能正常触发change且缩略图渲染正确。这一步直接验证 4.1 节的value是否真正生效。第二项是内存观察。打开 Chrome DevTools 的 Performance monitor勾选 JS heap size重复「选图 → 删除」十次观察内存曲线是否大致呈锯齿状回落而不是持续攀升。如果只涨不降就是 4.2 节的 revoke 漏了。第三项是移动端方向验证。用 iPhone 横着拍两张照片走完整链路选图、看预览、看压缩结果、上传到后端、在电脑上打开上传后的文件确认方向正确。这个过程能同时验证 3.2 节的 EXIF 修正是否生效。我自己的血泪经验是千万别只在 Chrome 桌面模拟器里测方向模拟器出来的照片根本没有 EXIF 方向字段测不出来问题。最后留一个本来想做但最终没写进代码的进阶项重复文件去重。如果你只靠name size lastModified三个字段做 Map 去重选 9 张里有两张是同一张照片的不同压缩版本时仍会漏掉要对内容做可靠的去重就需要读文件头算 MD5。这对「图集上传」场景通常是过度设计但对「证件照」「发票」这类单图复传场景很有价值。想做的话在compressImage之前先对原始File做一次 MD5用crypto.subtle.digest配合FileReader读 ArrayBuffer命中 Map 的直接跳过去逻辑不难但会让上传链路从「能跑」变成「经得起仔细看」。这个取舍我把原样留给你自己决定——反正核心链路已经都在这了你在上面加什么都不会破坏预览和上传的闭环。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。