
如果你正在做后台管理系统、博客或者任何带文件上传的页面肯定躲不开一个需求用户选了图片之后不等提交先在页面上把图显示出来确认没问题再点“上传”。这就是上传前预览。很多人第一反应是去找插件实际上用原生JS就能写得干干净净核心代码也就四五十行。这篇实战案例就带你从零手写一个图片预览功能顺带把文件读取、内存管理、格式校验这些容易踩坑的点一并讲透。无论你是刚学完 JS 基础还是已经写过一阵子的前端这篇文章都能帮你把“选文件→预览图→传服务器”这条链路彻底打通。1. 为什么需要“上传前预览”1.1 传统做法到底慢在哪我见过不少团队的旧项目上传图片的流程是这样的用户选择文件 → AJAX 把文件传给后端 → 后端保存后返回图片 URL → 前端拿到 URL 再拼到img上显示。这个流程确实能工作但问题很明显。第一用户等了半天才看到自己的图片万一网速不好或者服务器处理慢体验就很煎熬。第二如果用户选错了文件或者想换个角度重新拍一张每一次都要走一遍完整的上传流程服务器上堆了一堆垃圾文件前端这边还得做“删除刚才上传的文件”的逻辑非常繁琐。更关键的是很多场景下用户根本不需要把文件传上去才知道它长什么样。选择图片这个动作发生在本地浏览器文件数据就在用户的磁盘上浏览器完全有能力把图片内容读出来在内存里直接渲染到页面上。这就是上传前预览的核心思路先把本地文件读进浏览器显示出来让用户确认真正点“提交”的时候才把文件数据发送给服务器。1.2 预览本质是“本地读取 即时渲染”上传前预览不是什么魔法它依赖的是浏览器提供的 File API。用户通过input typefile选中文件后浏览器会生成一个File对象这个对象里保存着文件名、大小、类型、修改时间等元信息也提供了读取文件内容的方法。我们要做的就是监听change事件拿到这个File对象然后把它转成浏览器可以直接显示的图片地址赋给img的src属性。听起来简单实际操作里有两个非常关键的选型问题用什么方式把图片读出来这决定了你的页面在内存占用、响应速度、兼容性上的表现。下面我会把两个主流方案都讲清楚你再根据实际项目场景去选。2. 核心原理浏览器是怎样在本地“读”图片的2.1 先认识 File 对象和 FileList在写代码之前得先弄清楚我们手上拿到的是什么。input typefile在用户选择文件后它的files属性会变成一个FileList对象。即使你只选了一个文件files也是一个类似数组的集合需要靠索引访问input.files[0]。每一个元素都是File对象而File是Blob的子类。你可以把File对象想象成一张快递单上面写着包裹的名字name、重量size、类型type和揽收时间lastModified但包裹本身还在快递站不会因为这张快递单就自动跑到你面前。要看到包裹里的东西必须使用浏览器提供的方法去“取件”——这就是FileReader或URL.createObjectURL干的事。这里有一个细节type属性是浏览器根据文件的 MIME 类型推断出来的不是绝对的。比如你把一个.jpg文件改名为.pngtype仍然是image/jpeg。所以前端校验文件格式时不能只看文件后缀应该优先看type是否以image/开头后面我会在代码里演示。2.2 两条读取路径FileReader 与 URL.createObjectURL目前浏览器里读取本地文件、生成预览地址主流就两条路。第一条是FileReader.readAsDataURL。调用后浏览器会异步读取文件内容把它编码成一段 Base64 字符串形如data:image/jpeg;base64,/9j/4AAQSk...。这段字符串本身就是图片的完整数据可以直接赋给img的src也可以塞进FormData作为文件内容提交。优点是兼容性好IE10 都支持而且数据是“自包含”的存哪儿都能用缺点是太占内存——Base64 编码会让文件体积膨胀约 33%而且在生成字符串的过程中浏览器要把整个文件读进内存大图片会造成明显的卡顿。第二条是URL.createObjectURL(file)。它会根据传入的文件对象在浏览器内存里生成一个临时的 URL形如blob:http://localhost:8080/xxxx-xxxx-xxxx。这个 URL 不是真实存在的网络地址而是浏览器内部维护的一个“指针”当img加载这个 URL 时浏览器会直接从内存中解码对应的文件数据不需要经过 Base64 转换这一层性能明显更好。生成的 URL 只在当前页面会话期间有效而且用完以后必须手动调用URL.revokeObjectURL(url)释放内存否则会一直占用着文件对应的内存块。我用一张表来对比它们的核心差异你直接照着选就行对比维度FileReader.readAsDataURLURL.createObjectURL最终产物Base64 字符串Blob 临时 URL数据体积膨胀约 33%原始体积不变内存占用较高字符串常驻内存较低随引用释放释放时机替换引用后由 GC 回收必须手动 revoke能否直接上传可以作为字符串发给后端需要转成 File/Blob 后上传兼容性IE10IE10旧内核需前缀适用场景需要拿到图片数据的场景仅做页面预览的场景2.3 预览用 ObjectURL上传用 FileReader别混我实际写项目时预览部分默认都用URL.createObjectURL只有确实需要把图片内容作为 Base64 提交给后端或者要结合 Canvas 做压缩、裁剪等操作时才用FileReader.readAsDataURL。原因是预览这个动作追求的是快和省createObjectURL不复制文件数据只是建了个内存引用速度自然快而 Base64 是把整个文件复制成字符串图一大就有明显延迟。这个选择背后的道理可以拿“打开一份文档”来类比createObjectURL相当于直接双击文件用系统默认软件打开快但占用的是软件的资源readAsDataURL相当于先把文档内容全部复印一份出来再拿复印件去做备注标注——你随时能用复印件但复印的过程耗时复印件还占地方。理解这个差异之后你就不会再纠结“到底用哪个”了。3. 完整实现从零手写上传前预览3.1 先搭 HTML 结构input 用 accept 限制但不做唯一校验我的习惯是给用户一个明显的“选择图片”按钮而不是直接暴露系统原生的文件选择框。最简单的方式是用label关联隐藏的input这样用户点击好看的自定义按钮时浏览器会自动触发文件选择器。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title上传前图片预览/title style .upload-wrap { max-width: 400px; margin: 40px auto; padding: 24px; border: 1px dashed #ccc; border-radius: 8px; text-align: center; } .upload-btn { display: inline-block; padding: 8px 20px; background-color: #4a90d9; color: #fff; border-radius: 4px; cursor: pointer; user-select: none; } #fileInput { display: none; } #previewBox { margin-top: 16px; min-height: 120px; display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; } #previewBox img { max-width: 180px; max-height: 180px; border-radius: 6px; border: 1px solid #eee; object-fit: cover; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } /style /head body div classupload-wrap h3选择图片立即预览/h3 label forfileInput classupload-btn选择图片/label input typefile idfileInput acceptimage/* div idpreviewBox/div /div script src./preview.js/script /body /html这里有几个设置非常关键。acceptimage/*表示文件选择器默认只显示图片类文件但是**用户仍然可以切换到“所有文件”**去选择非图片所以 JS 里必须再做一次校验。display: none隐藏了原生输入框这是配合label forfileInput使用的标准做法。注意不要给这个 input 设置disabled否则 label 点击也不会触发文件选择器。#previewBox是预览容器里面会动态插入img元素用 CSS flex 排列方便后续扩展多图预览。3.2 JS 核心逻辑拿到 File → 生成 URL → 渲染 → 记得释放接下来是最核心的 JavaScript 逻辑。我写的是单图预览版本如果你想支持多图只需把files[0]换成遍历files即可这一点后面会单独展开。// preview.js (function() { const fileInput document.getElementById(fileInput); const previewBox document.getElementById(previewBox); // 保存上一次生成的 objectURL用于替换时释放 let lastObjectURL null; fileInput.addEventListener(change, function(e) { const file e.target.files[0]; // 没选文件就直接跳过 if (!file) return; // 关键校验type 必须是图片 if (!file.type.startsWith(image/)) { alert(请选择图片文件); fileInput.value ; return; } // 2MB 以上图片给个提醒可选避免用户无感知选了个超大图 if (file.size 2 * 1024 * 1024) { console.warn(图片超过 2MB预览可能较慢); } // 释放上一次的 URL避免内存不断累积 if (lastObjectURL) { URL.revokeObjectURL(lastObjectURL); lastObjectURL null; } // 生成新的预览 URL const objectURL URL.createObjectURL(file); // 清空旧预览 previewBox.innerHTML ; // 创建 img 并设置 src const img document.createElement(img); img.src objectURL; img.alt file.name; previewBox.appendChild(img); // 记录本次 URL以便下一次选择文件时释放 lastObjectURL objectURL; }); })();这段代码的每一步我都解释一下为什么这么写。e.target.files[0]change事件的target就是input元素它的files属性是FileList。哪怕用户只选了一个文件也一定要通过索引取值这是新手最容易忽略的地方。file.type.startsWith(image/)这是第一道防火墙。虽然acceptimage/*帮你过滤了大部分情况但用户手动切到“所有文件”后仍能选非图片这时候如果不拦截后面createObjectURL生成的 URL 指向的就不是图片数据img会显示成破碎的图标。URL.revokeObjectURL(lastObjectURL)这是很多教程不会讲、但实际开发必须处理的点。每生成一个新的objectURL浏览器旧会为它保留一份文件数据引用。如果你不断选择新图片而不释放旧引用整个 Tab 页面的内存会越吃越多。我亲眼见过一个后台系统因为没释放 URL连续传了几十张图后页面直接卡死打开任务管理器一看内存占用飙到 1GB 以上。正确做法就是在替换预览之前把上一次的 URL 释放掉。fileInput.value 在非图片文件拦截时把 input 的值清空。这样做的目的是让用户下次选择同一个文件时也能触发change事件——如果不重置你选了一次非法文件后会发觉“再选同一个文件页面一点反应都没有”。3.3 多图预览循环 FileList 分批次释放多图预览的需求也很常见比如商品相册、证件照片上传。实现思路和单图几乎一样区别在于要遍历fileInput.files为每个文件生成独立的objectURL并收集到数组里统一管理释放。fileInput.addEventListener(change, function(e) { const files Array.from(e.target.files); // 先释放上一轮所有 URL if (lastObjectURLs.length) { lastObjectURLs.forEach(url URL.revokeObjectURL(url)); lastObjectURLs []; } previewBox.innerHTML ; files.forEach(file { if (!file.type.startsWith(image/)) return; const objectURL URL.createObjectURL(file); lastObjectURLs.push(objectURL); const img document.createElement(img); img.src objectURL; img.alt file.name; previewBox.appendChild(img); }); });这里有个性能细节如果你用Array.from(e.target.files)把FileList转成真正的数组就能使用forEach代码读起来更顺。FileList本身虽然是类数组但它不是 Array 的实例直接for...of在部分旧浏览器里会有兼容问题用Array.from最稳妥。内存释放上多图场景比单图更容易出问题。你选择的图片越多lastObjectURLs数组就越长。所以我在下一轮选择开始前先统一释放所有旧 URL再创建新 URL保证任意时刻只有当前这一批预览图占着内存。3.4 完整可运行代码与效果说明我把上面的单图版本整理成一段可以直接复制运行的完整代码。这是纯原生 HTML JS不需要安装任何依赖保存成preview.html用浏览器打开就能测试。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleJS 上传前图片预览/title style body { font-family: system-ui, sans-serif; background: #f5f5f5; } .wrap { max-width: 480px; margin: 60px auto; background: #fff; padding: 32px; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.06); } .btn { display: inline-block; background: #2d6cdf; color: #fff; padding: 10px 24px; border-radius: 6px; cursor: pointer; } #fileInput { display: none; } #previewBox { margin-top: 20px; } #previewBox img { max-width: 100%; border-radius: 8px; display: block; } /style /head body div classwrap h2选择图片立即预览/h2 label forfileInput classbtn选择一张图片/label input typefile idfileInput acceptimage/* div idpreviewBox/div /div script const fileInput document.getElementById(fileInput); const previewBox document.getElementById(previewBox); let lastURL null; fileInput.addEventListener(change, (e) { const file e.target.files[0]; if (!file) return; if (!file.type.startsWith(image/)) { alert(只能选择图片文件); fileInput.value ; return; } if (lastURL) { URL.revokeObjectURL(lastURL); lastURL null; } const url URL.createObjectURL(file); previewBox.innerHTML ; const img document.createElement(img); img.src url; img.alt file.name; previewBox.appendChild(img); lastURL url; }); /script /body /html实际测试时的效果是点击“选择一张图片”系统弹出文件选择器选中一张图片后change事件触发生成的预览图立刻出现在按钮下方即使此时文件还没有上传到服务器图片内容和最终上传后的效果完全一致。4. 常见问题与排查技巧实录4.1 Base64 膨胀 33% 是怎么算出来的很多人在网上看到“Base64 会让图片体积膨胀约 33%”但不理解原因。这里一次性讲清楚Base64 编码把二进制文件按每 3 个字节也就是 24 位为一组转换成 4 个可打印字符每个字符用 6 位表示。24 位数据变成 4 个字符后每个字符在传输时按 8 位计算总位数变成了 32 位比原来多了 8 位。换算下来数据量变成原来的4 / 3倍也就是多出约 33.3%。举个例子一张 10MB 的图片如果用FileReader.readAsDataURL转成 Base64 字符串实际得到的是约 13.3MB 的文本还得加上data:image/jpeg;base64,这一小段前缀。如果你把这样的字符串直接挂在img上那 13.3MB 都要常驻内存如果把它塞进FormData发给后端网络传输量和后端解析开销也跟着涨。所以能用objectURL预览就不要轻易上 Base64我见过不少团队就是因为这个细节导致后台页面在上传大图时内存暴涨其实是没分清工具该怎么用。4.2 objectURL 不释放会造成什么后果这里必须多说一句因为实际项目里漏掉revokeObjectURL的情况太常见了。你可以自己做一个小实验打开浏览器的开发者工具切到 Performance 或 Memory 面板连续选择 20 张不同的大图片每次都生成预览但从不撤销objectURL再看着内存曲线一节一节往上爬。当你不断选择新图片却不释放旧 URL 时每一张旧图片的完整文件数据都被浏览器保留在内存里直到页面关闭。如果是单图上传还好用户一般就选一次但如果是多图编辑场景比如商品编辑页面里要反复替换 5 张主图每替换一轮就积压一批旧图片半个小时的编辑操作能让页面的内存占用飙升到几百 MB。而且这个问题不太容易暴露出具体的报错只会让页面越来越卡最终无响应。正确的释放时机应该是每次准备替换预览之前先撤销掉之前所有的 URL。如果是多图分批加载比如点击“查看更多”才展示更多预览那你最好在组件销毁或者页面卸载时统一释放避免内存被长期占用。4.3 change 事件没触发先检查这几个地方遇到过好几次读者反馈“我明明选了文件但预览区没反应”。排查顺序一般是这样的。第一检查input元素是不是真的被label关联好了。最容易犯的错是label的for属性值和input的id不一致导致点击按钮根本没触发文件选择器。第二检查accept是不是写得太严苛。比如有的苹果手机相册里有 HEIC 格式acceptimage/jpeg,image/png会直接把.heic文件置灰。如果你确实只要 JPG 和 PNG可以加一句acceptimage/jpeg,image/png,.heic让用户至少能选再在 JS 里给出格式不支持的提示。第三检查是不是“选同一个文件”导致的不触发。这是change事件的天性如果 input 的 value 没有变化再选同一个文件不会触发第二次change。解决办法就是我在代码里写的在处理完一个文件后把fileInput.value设为空字符串强制重置。第四确认change事件是不是被别的事件拦截了。比如你在 input 外层套了个form按回车键提交form时把页面刷新了预览自然被清空。这种场景建议在form上监听submit并调用e.preventDefault()。4.4 图片格式校验要靠 type别只信后缀前面提过type是浏览器根据实际文件内容推断出来的比后缀靠谱。但还有一个反过来的坑如果你拿到的文件是空文件或者数据流被人为截断type可能不是空字符串而是正常的image/jpeg但图片在浏览器里就是渲染不出来。这种情况我建议你在img上绑一个onerror事件图片加载失败时给用户明确的反馈而不是留一个破图标img.onerror function() { this.style.display none; alert(图片文件已损坏或格式不支持); };这样至少用户在选到坏图时能立刻知道而不是以为自己的代码写错了。4.5 大图预览导致页面卡顿试试 Canvas 压缩objectURL虽然性能好但如果你要预览的是一张 10MB、8000x6000 的原始照片浏览器解码超大图本身就会卡顿。这时候最好的办法不是优化预览方式而是先压缩再预览。思路是用 Canvas 把图片绘制到一个小尺寸的画布上再把画布导出一个体积更小的图片。具体逻辑我在下一节展开这里先说结论预览图不需要用原始分辨率显示通常 1200px 宽就完全够用配合canvas.toDataURL(image/jpeg, 0.8)能把 10MB 的图压到 100KB 上下。上传时再决定到底传原图还是传压缩图取决于你的业务。5. 进阶扩展拖拽、压缩与真正上传5.1 拖拽上传预览用 dragover drop 替代文件选择框上传前预览做好之后很多人会进一步问能不能直接拖拽图片到页面上预览这个功能实现起来比你想的简单核心是监听dragover和drop这两个事件。const dropZone document.getElementById(dropZone); dropZone.addEventListener(dragover, (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 files Array.from(e.dataTransfer.files); files.forEach(file { if (!file.type.startsWith(image/)) return; const url URL.createObjectURL(file); const img document.createElement(img); img.src url; dropZone.appendChild(img); }); });这里有个关键点必须提醒drop事件的e.dataTransfer.files才是用户拖进来的文件列表不要搞错成e.target.files那是input元素才有的属性。拖拽的文件也可能是多个所以按数组来遍历处理。另外dragover事件必须调用e.preventDefault()否则浏览器默认行为会阻止你松开鼠标时触发drop。5.2 图片压缩用 Canvas 把 10MB 压成 200KB如果你在做移动端项目图片压缩几乎是刚需。用户手机上随便拍的图都是好几 MB直接上传非常慢。压缩的核心思路是把图片绘制到一张更小的 Canvas 上然后用toDataURL或toBlob导出。function compressImage(file, maxWidth 1200, quality 0.8) { return new Promise((resolve, reject) { const url URL.createObjectURL(file); const img new Image(); img.onload function() { // 等比缩放 const scale Math.min(1, maxWidth / img.width); const width img.width * scale; const height img.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); // 转成 Blob比 toDataURL 内存更友好 canvas.toBlob((blob) { if (blob) { resolve(blob); } else { reject(new Error(Canvas 导出失败)); } URL.revokeObjectURL(url); }, image/jpeg, quality); }; img.onerror function() { URL.revokeObjectURL(url); reject(new Error(图片加载失败)); }; img.src url; }); }注意ctx.drawImage(img, 0, 0, width, height)里的width和height是缩放后的尺寸如果省略这两个参数就会按图片原始尺寸绘制那就没有压缩意义了。canvas.toBlob的回调参数是一个Blob对象这个 Blob 可以直接放进FormData发给后端也可以再用URL.createObjectURL(blob)生成预览地址。整个压缩过程里FileReader和URL.createObjectURL都配合使用各自发挥特长。5.3 配合 FormData 做真正的上传预览做得再漂亮最终还是要传到服务器。现代上传都是用FormData对象装文件然后通过XMLHttpRequest或fetch发出去。function uploadFile(file) { const formData new FormData(); formData.append(file, file); // 如果后端需要额外字段也可以继续 append formData.append(description, 用户上传的头像); fetch(/api/upload, { method: POST, body: formData, }) .then(res res.json()) .then(data { console.log(上传成功服务器返回的文件路径, data.url); }) .catch(err { console.error(上传失败, err); }); }这里值得一提的坑是FormData.append(file, file)追加的file必须是File或Blob对象。如果你用FileReader读出了 Base64 字符串想直接塞进 FormData那是行不通的——后端会收到一个字符串而不是文件。解决办法是你得先把 Base64 转换成 Blob或者干脆在上传阶段直接用原始File对象。另外如果预览用的是 Canvas 压缩后的 Blob上传时也要append这个 Blob并给它起一个文件名formData.append(file, compressedBlob, avatar.jpg)。第三个参数指定文件名后端按文件名保存时就不会丢失扩展名。5.4 移动端与跨端场景的注意点移动端浏览器对input[typefile]的处理和 PC 差异很大。iOS 的 Safari 和微信内置浏览器里acceptimage/*会弹出自带的相册选择器你可以加一个captureenvironment属性让部分安卓浏览器直接调起相机。multiple属性在 iOS 上要谨慎使用某些旧版本的 Safari 不支持一次选多张只允许一张一张选。如果你用 uniapp 开发跨端应用要区分清楚“预览本地待上传图片”和“预览已上传图片”。uni.previewImage是用来预览已有的图片URL数组的它不做“本地文件选择”你仍然需要先用uni.chooseImage拿到本地临时文件路径再考虑是直接uploadFile上传还是通过uni.getFileSystemManager().readFile读取后交给 Canvas 压缩。跨端和 Web 端的 API 完全不同思路可以借鉴代码不能照搬。6. 踩坑后的经验总结最后分享几条我在真实项目里总结出来的习惯谈不上多高深但都是血泪换来的。第一默认用URL.createObjectURL做预览只在需要数据传输时才用FileReader。这条规则能帮你避开 80% 的内存问题。第二每次更换预览图之前一定要先释放上一次的objectURL。我习惯在代码里用一个变量统一管理绝不散落在各处revoke否则删漏了你自己都发现不了。第三预览图的样式别用width: 100%硬撑。用户选了一张 4000px 的超大图你缩成一个 200px 的头像框浏览器还是要老老实实解码完整图片一次卡顿就够你被投诉了。先压缩再显示或至少在img上设置合理的宽高比例并用object-fit: cover裁切掉多余部分。第四所有文件相关操作都要做好异常兜底。文件可能损坏、格式可能异常、Canvas 可能因为跨域问题抛安全错误每个环节都绑一个onerror或catch别让用户在无声无息中看着一片空白以为功能坏了。这一步看着啰嗦但运维成本低得多。我其实更推荐你在项目里把预览逻辑封装成一个独立函数传入File对象、目标容器和回调这样复用性会强很多。后面如果你需要在同一个页面同时支持多图预览、拖拽上传、Canvas 压缩、进度条显示你会发现这套基础逻辑完全够用。先把这个核心玩透再往上层堆功能你会走得比那些一上来就引一堆插件的同行稳得多。