ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端图片上传前预览:从createObjectURL到canvas压缩的完整实践

前端图片上传前预览:从createObjectURL到canvas压缩的完整实践 上次给一个内容管理后台加图片上传功能测试同事随手拖了一张 30MB 的单反原片进来页面硬生生卡了两三秒才把预览刷出来隔了一天他又拿一个扩展名叫.jpg、实际内容只有 3KB 的假图片系统在提交之后才发现异常白白走了一轮网络请求。这两个问题其实都出在同一个环节上传前预览没做好。所谓上传前预览就是用户在input typefile选择文件之后、真正提交给服务器之前先把本地图片显示在页面上。它看起来只是一个img标签换src的事但一旦放到真实项目里文件大小、格式伪装、多选排序、拖拽上传、内存释放这些问题全都会冒出来。这篇内容我从实际踩坑出发把 JS 实现上传前预览的完整链路拆开讲清楚既有原理也有可以直接抄走的代码。无论你在维护后台管理系统、个人资料页还是商户入驻表单这套思路都适用。1. 上传前预览到底解决了什么问题1.1 本地预览和服务器回显不是一回事很多人一看到“图片预览”四个字马上想到img src/upload/xxx.jpg /这种写法但那是服务器回显它发生在文件已经传到后端、后端又返回了可访问 URL 之后。上传前预览则是另一套数据流用户电脑里那个File/Blob对象在fetch或表单提交之前直接交给浏览器的图片解码器显示到页面上。这里有几个关键区别它没有网络请求、不需要等待后端返回、用户选完文件立刻就能看到效果而且失败成本极低。但它的使用范围也只局限在当前页面。用URL.createObjectURL(file)生成的临时地址只在当前浏览器上下文里有效不能当成普通图片路径存进数据库也不能拿到另一个浏览器里继续用。把这两个概念分开很重要否则很容易在项目里把本地预览和服务端回显混在一起出现“保存完没图刷新后图才出来”的怪问题。还要多说一句上传前预览是纯前端的用户体验功能它不是安全校验也不等于上传动作。看到本地图片不代表服务器一定能成功接收这部分后面我会专门讲。1.2 一个成熟预览功能要处理的边界条件如果只写三行代码大多数教程到“显示图片”就结束了但真实场景里你躲不开这些边界用户选了一个根本不是图片的文件。acceptimage/*在很多浏览器里只是对话框的默认筛选并不保证文件的真实类型。超大原图。现在手机动不动就 4800 万像素一张原片十几 MB 很常见直接丢给img解析会让页面卡顿。多图上传。像商品详情页一次传 6 张图顺序调整、单张删除、批量替换都要处理。拖拽上传。从桌面直接拖文件进来比点击选择更顺手但dragover和drop的默认行为坑很多。重复选择同一个文件。第二次选同一张图时如果input的value没有重置change事件可能根本不触发。内存管理。createObjectURL生成的引用不主动释放会在单页应用里越积越多。老版本浏览器和移动端 WebView。URL.createObjectURL、FileReader、canvas.toBlob这些能力的支持程度各不相同。把这些问题处理完这个“小功能”才算真正能交付。下面我从技术选型开始逐个击破。2. 两个核心 API 的取舍readAsDataURL 与 createObjectURL2.1 readAsDataURL把整个文件先搬进内存再说老牌方案是FileReader.readAsDataURL代码写起来非常简单const reader new FileReader(); reader.onload function (e) { document.querySelector(#preview).src e.target.result; }; reader.readAsDataURL(file);它的原理是让浏览器把图片文件完整读取成二进制再转成 Base64 字符串。最后你拿到的data:image/png;base64,....是一段很长的文本可以直接塞给img的src也可以直接放进表单提交。但这个方案的代价非常直白Base64 文本比原始二进制文件大约大三分之一。一张 10MB 的图片用这种方式读取会生成 13MB 以上的字符串占用 JS 堆内存再加上图片解码需要的内存移动端很容易被瞬间拉高好几倍内存出现明显卡顿甚至白屏。图片越大这个方案越吃力。所以我的建议是除非你明确需要“图片的完整 Base64 内容”比如后端接口只接受 Base64、或者某个老组件强依赖 dataURL否则上传前预览这个环节尽量别用它当主力方案。2.2 createObjectURL让浏览器原生解码预览几乎零成本现代浏览器更推荐用URL.createObjectURL(file)const url URL.createObjectURL(file); img.src url;这里的url形如blob:https://your-site.com/xxxxx-xxxx-xxxx它不是一个真实路径而是指向浏览器内存里那个File对象的一个引用。浏览器在渲染img时会自己调度解码不需要 JS 手动塞一大段字符串进内存所以大图的预览速度通常远快于readAsDataURL。还有一个实际好处是几乎没有内存双份开销。文件本身还在磁盘或内存中的原位置只是被浏览器按需读取。虽然我前面说它仍然需要管理但整体比 Base64 轻太多。需要注意一点URL.createObjectURL生成的地址必须调用URL.revokeObjectURL(url)释放否则这个引用会一直挂着。释放时机是有讲究的我放到第 5 节详细讲。2.3 选型对比一张表看懂对比项FileReader.readAsDataURLURL.createObjectURL读取方式FileReader 异步读取转 Base64 字符串生成 Blob URL浏览器按需解码内存占用高Base64 比原文件大约 33%低不产生重复的大字符串预览速度大图明显变慢快浏览器原生能力生命周期字符串可长期保存在变量里当前页面有效需 revoke 释放兼容性很古老IE10 基本可用IE 需要 webkitURL 兜底现代浏览器无压力典型场景表单提交 base64、后端接口特殊要求上传前即时预览、多图缩略图、拖拽回显结论很明确上传前预览优先用createObjectURL性能好、代码少。readAsDataURL可以作为降级方案也可以在确实需要 Base64 的时候再拿出来。3. 可复用的上传前预览组件从单图到多图3.1 基础结构隐藏 input 配合自定义按钮裸的input typefile在不同操作系统里长相差很多你很难统一它的样式。所以实际项目中通常把input藏起来用一个自定义按钮去触发文件选择input typefile idfileInput acceptimage/* multiple hidden / button typebutton idpickBtn选择图片/button div idpreviewBox/divconst fileInput document.querySelector(#fileInput); const pickBtn document.querySelector(#pickBtn); pickBtn.addEventListener(click, () fileInput.click());这里有两个细节值得说。一是hidden是 HTML 属性不是 CSS 的display:none对于文件输入框来说更简洁可靠不会触发一些奇怪的可访问性问题。二是为什么不直接用label forfileInput也可以用但自定义按钮的触发时机更可控比如某些业务需要在打开选择器之前先去请求权限、检查数量上限label就做不到这种拦截。3.2 单图预览的完整实现先写一个最精简但能用的单图版本const previewBox document.querySelector(#previewBox); let currentUrl null; fileInput.addEventListener(change, function () { const file this.files this.files[0]; if (!file) return; // 先做基础类型过滤 if (!file.type.startsWith(image/)) { previewBox.innerHTML p classerror请选择图片文件/p; this.value ; return; } // 释放上一张临时地址 if (currentUrl) { URL.revokeObjectURL(currentUrl); } const url URL.createObjectURL(file); currentUrl url; previewBox.innerHTML img src${url} alt本地预览图 /; });这个版本我在change开头读取this.files[0]然后立刻处理最后我会强调一个容易被忽略的操作把this.value重置为空。为什么要重置我放到第 4 节单独讲因为这是“重复选择同一个文件不触发 change”的根源。有一点必须提醒createObjectURL返回的临时地址不要直接拼到form里当成上传字段提交。Blob URL只在当前页面内部有效服务端根本没法访问这个地址。它只服务于本地回显。3.3 多图预览、删除与批量管理多图场景下核心是要维护一张“文件对象表”每张预览卡对应一条记录。我用Map来存方便按唯一 id 删除const fileInput document.querySelector(#fileInput); const previewList document.querySelector(#previewList); const filePool new Map(); // uid - { file, url, card } let uidSeed 0; fileInput.addEventListener(change, function () { const files Array.from(this.files || []); files.forEach(handleFile); this.value ; // 关键重置保证重复选择同一文件也能触发 change }); function handleFile(file) { if (!file.type.startsWith(image/)) { console.warn(忽略非图片文件 file.name); return; } const uid uidSeed; const url URL.createObjectURL(file); const card document.createElement(div); card.className preview-card; card.innerHTML img src${url} alt / p${file.name}/p button typebutton>function compressImage(file, maxSize 1200, quality 0.8) { return new Promise((resolve, reject) { const blobUrl URL.createObjectURL(file); const image new Image(); image.onload function () { const width image.naturalWidth; const height image.naturalHeight; const scale Math.min(1, maxSize / Math.max(width, height)); const canvas document.createElement(canvas); canvas.width Math.round(width * scale); canvas.height Math.round(height * scale); const ctx canvas.getContext(2d); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); // 释放临时 URL URL.revokeObjectURL(blobUrl); // 转成体积更小的 JPEG/WebP blob canvas.toBlob((blob) { if (blob) { resolve(blob); } else { reject(new Error(canvas 转 blob 失败)); } }, image/jpeg, quality); }; image.onerror function () { URL.revokeObjectURL(blobUrl); reject(new Error(图片解码失败)); }; image.src blobUrl; }); }调用时这样用const compressedBlob await compressImage(file, 1200, 0.8); const previewUrl URL.createObjectURL(compressedBlob); // 将 previewUrl 设置到 img 的 src这个方案我测试下来非常稳一张 12MB 的原图压缩完后预览体积经常只有一两百 KB页面响应直接变快。但有两个坑要提前说一是canvas.toBlob在部分老版本 Safari 和 IE 里不存在需要toDataURL降级二是手机照片经常带 EXIF 方向信息canvas重绘时如果浏览器不自动应用方向照片可能会横躺这种场景可以借助 CSSimage-orientation: from-image缓解但最好抽时间做成统一的方向处理函数。压缩后的blob并不是原始File对象。如果你选择上传压缩后的图记得后面FormData.append时用这个blob而不是原来那个file。4.2 拖拽上传的两个高频翻车点桌面端用户很喜欢直接从文件夹里把图片拖进页面。实现本身不复杂const dropZone document.querySelector(#dropZone); dropZone.addEventListener(dragover, function (e) { e.preventDefault(); e.dataTransfer.dropEffect copy; }); dropZone.addEventListener(drop, function (e) { e.preventDefault(); const files Array.from(e.dataTransfer.files); files.forEach(handleFile); });第一个翻车点不写preventDefault文件拖进来会被浏览器直接打开。你必须在dragover和drop两个事件里都调用e.preventDefault()否则拖拽的默认行为会覆盖掉你的逻辑。第二个翻车点更隐蔽如果你的预览区里还有按钮、文字等其他元素drop事件冒泡到外层时e.target是那个按钮而不是外层容器。很多人在这里误用e.target去取元素结果死活拿不到要么被已存在的子元素挡住导致drop不触发。稳妥做法是监听容器本身统一用e.currentTarget并且取消事件冒泡的地方不要加在子元素上保持容器作为统一入口。另外如果拖拽的是跨域网页里的图片浏览器可能不让 canvas 读取像素那个 pseudo 的安全异常会出现在getImageData或调用toBlob读取时。本地文件拖拽一般不触发但你做粘贴截图预览时需要注意。4.3 重复选择同一个文件为什么 change 不触发这是高频 bug用户选了一张图片预览不满意重新点开选择器小心翼翼又选了同一张图结果页面毫无反应。原因是input的value还停留在上一次的文件路径浏览器认为“值没变”所以不派发change事件。解法很简单在change回调处理完文件后手动重置fileInput.value ;注意一定要放在处理完文件之后不能放在回调开头。因为放进input.value以后this.files会变为空如果你在开头重置后面就读不到刚才选择的那批文件了。另外说一个容易被坑的操作不要试图直接给input赋值null或者空字符串以外的值会有兼容问题。value 是跨浏览器最稳的写法。5. 内存泄漏、格式伪装与兼容性踩坑清单5.1 blob URL 什么时候释放URL.createObjectURL产生的引用如果一直不释放页面长期运行就会累积越来越大的内存占用。典型场景是单页应用里不停切换图片却没有调用revokeObjectURL最后用户能明显感到页面变重。但释放也有讲究我见过两种错误方向第一种是完全不释放这里不展开。第二种是过早释放。很多人习惯在img.onload里立刻revokeObjectURL认为图片都显示出来了就可以清理。这在大多数情况下确实够用因为浏览器已经完成解码但如果你后面还要用这同一个 URL 去做二次操作例如重新设置src、把图片塞进 canvas 再渲染一次就可能得到一张空图。实际项目我更推荐“统一管理删除时释放”的方式。我第 3 节的filePool就是干这个的新增时记录 URL删除卡片或重新选择时统一revokeObjectURL。这种策略比在onload里释放更可控也更容易排查问题。如果你希望尽快释放可以借助image.decode()const img new Image(); img.src url; img.decode().then(() { URL.revokeObjectURL(url); }).catch(() { URL.revokeObjectURL(url); });这个方案适合简单场景但需要确认图片不再被复用。5.2 accept 和 MIME 都别当作校验依据acceptimage/*只是让文件选择器默认筛选图片用户依然可以切换到“所有文件”选一个.pdf而file.type来自文件的 MIME 信息很多系统会把所有未知类型统一标记为application/octet-stream所以即使扩展名是.jpg也可能拿不到image/jpeg。靠file.type.startsWith(image/)做基础过滤之后我建议再做一次“真解码校验”。校验思路很简单让Image去加载一遍加载成功且图片存在有效尺寸才认为确实是一张图片function verifyImage(file) { return new Promise((resolve) { const url URL.createObjectURL(file); const img new Image(); img.onload () { const valid img.naturalWidth 0; URL.revokeObjectURL(url); resolve(valid); }; img.onerror () { URL.revokeObjectURL(url); resolve(false); }; img.src url; }); }不过这种校验也有成本大图会被完整解码一次如果文件很大页面可能还是会有一瞬间的性能波动。所以实战中我会分两步先看 MIME 快速拦截再用图片解码做最终确认。业务上如果对性能敏感可以把这一步挪到提交阶段统一处理。5.3 老版本浏览器、跨域图片与画布污染兼容性这块最容易踩的是三个点。第一URL对象可能不存在或需要前缀。老旧浏览器里要用window.URL || window.webkitURL来拿。我一般写一个工具函数function createObjectURL(file) { const URLImpl window.URL || window.webkitURL; if (!URLImpl || !URLImpl.createObjectURL) return null; return URLImpl.createObjectURL(file); }如果返回null再降级到FileReader.readAsDataURL预览。只要把降级写成一个小函数主流程就不会被老环境拖垮。第二canvas 污染。如果图片不是本地文件而是从一个跨域地址加载过来的canvas.drawImage之后画布会被标记为“已被污染”调用canvas.toBlob()、canvas.toDataURL()或getImageData()会抛 SecurityError。本地文件的Blob URL不触发但粘贴截图、拖拽其他来源的图片时可能遇到。第三部分 Android WebView里FileReader读取大文件超时或直接失败。这种环境没有太好的办法只能前端做一个文件大小提示超过比如 20MB 就建议用户先压缩再上传。6. 从本地预览到真正上传完整链路接法6.1 数据流闭环File、Blob 与 FormData 的关系预览只是前半场真正要发到服务器的还是文件本身。这里很多人会混淆预览用的Blob URL不能直接作为表单值提交。后端拿不到你的blob:https://...这个消息我之前强调过再强调一次。正确的数据流是filePool里保存的是File对象提交时把这些对象塞进FormDataconst formData new FormData(); formData.append(title, document.querySelector(#title).value); filePool.forEach((item) { formData.append(images[], item.file, item.file.name); });FormData.append接受Blob/File类型第三个参数是文件名。如果你用了第 4 节的压缩方案想上传压缩后的图就传入压缩后的blobformData.append(images[], compressedBlob, compressed- file.name);所以整套流程总结下来就是文件选择 → 预检验 → 本地预览 → 可选压缩 → 暂存 File/Blob → 提交时组装 FormData → 发送。6.2 带真实上传进度的实现用 XMLHttpRequest很多人写上传直接fetch但 fetch 目前的进度支持做得不如老牌XMLHttpRequest直接。如果你需要给用户一个“上传中 x%”的进度条用XMLHttpRequest反而更省事const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload); xhr.upload.addEventListener(progress, function (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); progressBar.value percent; progressText.textContent percent %; } }); xhr.onload function () { if (xhr.status 200 xhr.status 300) { console.log(上传成功); } else { console.error(上传失败); } }; xhr.onerror function () { console.error(网络异常); }; xhr.send(formData);如果你的接口是 JSON记得在xhr.onload里JSON.parse(xhr.responseText)再去处理。这里我不展开 axios 或fetch的流式上传因为它们要么需要额外库要么浏览器兼容性还没有统一。6.3 最后一步提醒服务端校验不能省前端做这么多目的是提升用户体验但绝对不能替代服务端的安全检查。恶意用户可以绕过页面直接构造请求把一张伪装成图片的可执行文件传上去。所以后端仍然要校验扩展名、MIME、文件大小最好再尝试用图像解析库解码一次确认它能被正常识别为图片再落盘。这不是炫技而是上线的底线。预览做得再好也只是让正常用户少走弯路别让它在安全边界上承担不该承担的职责。最后分享两个我一直保留的习惯。第一个是在预览区域给每张图片备好错误态img.onerror时直接换成占位图或移除卡片不要让用户盯着一个灰框发呆。第二个是在多图预览的img上顺手加decodingasync长列表滚动时的流畅度会有明显提升。只要把这些边边角角都处理完这个看起来只有几行代码的“小功能”才算真正经得起真实用户折腾。
返回列表