如果你正在做后台管理系统、博客或者任何带文件上传的页面,肯定躲不开一个需求:用户选了图片之后,不等提交,先在页面上把图显示出来,确认没问题再点“上传”。这就是上传前预览。很多人第一反应是去找插件,实际上用原生JS就能写得干干净净,核心代码也就四五十行。这篇实战案例就带你从零手写一个图片预览功能,顺带把文件读取、内存管理、格式校验这些容易踩坑的点一并讲透。无论你是刚学完 JS 基础还是已经写过一阵子的前端,这篇文章都能帮你把“选文件→预览图→传服务器”这条链路彻底打通。
1. 为什么需要“上传前预览”
1.1 传统做法到底慢在哪
我见过不少团队的旧项目,上传图片的流程是这样的:用户选择文件 → AJAX 把文件传给后端 → 后端保存后返回图片 URL → 前端拿到 URL 再拼到<img>上显示。这个流程确实能工作,但问题很明显。第一,用户等了半天才看到自己的图片,万一网速不好或者服务器处理慢,体验就很煎熬。第二,如果用户选错了文件,或者想换个角度重新拍一张,每一次都要走一遍完整的上传流程,服务器上堆了一堆垃圾文件,前端这边还得做“删除刚才上传的文件”的逻辑,非常繁琐。
更关键的是,很多场景下用户根本不需要把文件传上去才知道它长什么样。选择图片这个动作发生在本地浏览器,文件数据就在用户的磁盘上,浏览器完全有能力把图片内容读出来,在内存里直接渲染到页面上。这就是上传前预览的核心思路:先把本地文件读进浏览器,显示出来,让用户确认;真正点“提交”的时候,才把文件数据发送给服务器。
1.2 预览本质是“本地读取 + 即时渲染”
上传前预览不是什么魔法,它依赖的是浏览器提供的 File API。用户通过<input type="file">选中文件后,浏览器会生成一个File对象,这个对象里保存着文件名、大小、类型、修改时间等元信息,也提供了读取文件内容的方法。我们要做的,就是监听change事件,拿到这个File对象,然后把它转成浏览器可以直接显示的图片地址,赋给<img>的src属性。
听起来简单,实际操作里有两个非常关键的选型问题:用什么方式把图片读出来?这决定了你的页面在内存占用、响应速度、兼容性上的表现。下面我会把两个主流方案都讲清楚,你再根据实际项目场景去选。
2. 核心原理:浏览器是怎样在本地“读”图片的
2.1 先认识 File 对象和 FileList
在写代码之前,得先弄清楚我们手上拿到的是什么。<input type="file">在用户选择文件后,它的files属性会变成一个FileList对象。即使你只选了一个文件,files也是一个类似数组的集合,需要靠索引访问:input.files[0]。
每一个元素都是File对象,而File是Blob的子类。你可以把File对象想象成一张快递单:上面写着包裹的名字(name)、重量(size)、类型(type)和揽收时间(lastModified),但包裹本身还在快递站,不会因为这张快递单就自动跑到你面前。要看到包裹里的东西,必须使用浏览器提供的方法去“取件”——这就是FileReader或URL.createObjectURL干的事。
这里有一个细节:type属性是浏览器根据文件的 MIME 类型推断出来的,不是绝对的。比如你把一个.jpg文件改名为.png,type仍然是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.readAsDataURL | URL.createObjectURL |
|---|---|---|
| 最终产物 | Base64 字符串 | Blob 临时 URL |
| 数据体积 | 膨胀约 33% | 原始体积不变 |
| 内存占用 | 较高(字符串常驻内存) | 较低(随引用释放) |
| 释放时机 | 替换引用后由 GC 回收 | 必须手动 revoke |
| 能否直接上传 | 可以,作为字符串发给后端 | 需要转成 File/Blob 后上传 |
| 兼容性 | IE10+ | IE10+(旧内核需前缀) |
| 适用场景 | 需要拿到图片数据的场景 | 仅做页面预览的场景 |
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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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 class="upload-wrap"> <h3>选择图片,立即预览</h3> <label for="fileInput" class="upload-btn">选择图片</label> <input type="file" id="fileInput" accept="image/*"> <div id="previewBox"></div> </div> <script src="./preview.js"></script> </body> </html>这里有几个设置非常关键。
accept="image/*"表示文件选择器默认只显示图片类文件,但是**用户仍然可以切换到“所有文件”**去选择非图片,所以 JS 里必须再做一次校验。
display: none隐藏了原生输入框,这是配合<label for="fileInput">使用的标准做法。注意不要给这个 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/'):这是第一道防火墙。虽然accept="image/*"帮你过滤了大部分情况,但用户手动切到“所有文件”后仍能选非图片,这时候如果不拦截,后面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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>JS 上传前图片预览</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 class="wrap"> <h2>选择图片,立即预览</h2> <label for="fileInput" class="btn">选择一张图片</label> <input type="file" id="fileInput" accept="image/*"> <div id="previewBox"></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 格式,accept="image/jpeg,image/png"会直接把.heic文件置灰。如果你确实只要 JPG 和 PNG,可以加一句accept="image/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[type=file]的处理和 PC 差异很大。iOS 的 Safari 和微信内置浏览器里,accept="image/*"会弹出自带的相册选择器,你可以加一个capture="environment"属性,让部分安卓浏览器直接调起相机。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 压缩、进度条显示,你会发现这套基础逻辑完全够用。先把这个核心玩透,再往上层堆功能,你会走得比那些一上来就引一堆插件的同行稳得多。