做 Vue2 老项目的人,迟早会碰上大文件上传这种需求。我之前维护过一个内部管理系统,用户最爱传的就是几十兆甚至几百兆的 Excel 和视频。一开始用原生 form 和 axios 硬顶,结果就是页面卡死、进度条根本不走、偶尔传了十几分钟报个超时。后来我把百度WebUploader插件接进来,配合 Vue2 做了完整的上传进度可视化,才彻底把这块体验捞回来。这篇文章就围绕这个方案展开,讲清楚 WebUploader 的核心机制、在 Vue2 里的封装方式、进度 UI 的实现思路,以及我实际踩过的几个坑,适合正在维护 Vue2 老项目、想快速接手大文件上传模块的开发者参考。
1. 为什么大文件上传必须换一种思路:分片、并发与断点续传
1.1 一次性整传的问题到底出在哪
大文件到底多大?我的经验是超过 50MB 就该认真对待,超过 200MB 基本不能指望一次请求搞定。原因很直白:浏览器把整个文件作为 body 发送,服务器也得一次性接完。网络稍差,中途断连,前端最多显示一个 error,已传的字节全浪费了。有些网关还会在 Nginx 层设置超时时间,比如 60s,文件一大必然超时。更麻烦的是用户体验,原生 XMLHttpRequest 虽然有 progress 事件,但遇到网络波动时无法重传,用户对着一个卡死的百分比只能干等。所以在老项目里,上传大文件需要的不是单个请求,而是一整套分片、并发、重试、断点续传的策略。这也是我最终选择 WebUploader 的原因。
1.2 WebUploader 给出的解法
WebUploader 是百度 FEX 团队维护过的插件,它做的核心事情就是把一个文件切成多个小块,再通过队列调度并发上传。下面这张表可以比较直观地对比普通整传和 WebUploader 分片上传的差异:
| 关键维度 | 普通整传 | WebUploader 分片上传 |
|---|---|---|
| 单次请求数据量 | 整个文件 | 一个 2MB~10MB 的小片 |
| 失败成本 | 全部重传 | 只重传失败的那一片 |
| 并发控制 | 无,单请求串行 | 线程池式并发,threads 可调 |
| 进度展示 | 只有整体 progress 事件 | 分片粒度 + 整体粒度双重进度 |
| 断点续传 | 不支持 | 配合后端可恢复未完成的文件 |
它的内部原理其实不神秘。现代浏览器用Blob.slice()把文件按固定字节数切开,每个分片就是一个独立的请求对象;为了不让请求顺序乱掉,又用了一个异步队列来管理"哪些片在传、哪些片在等、哪些片失败要重试"。再加上uploadProgress事件不断上报当前文件的已上传字节,前端拿到这些数据后,自然就能画出一个可信的进度条。
1.3 分片上传对进度可视化的意义
如果你自己写分片上传,大概是这样:
const chunkSize = 2 * 1024 * 1024; for (let start = 0; start < file.size; start += chunkSize) { const chunk = file.slice(start, start + chunkSize); // 把这个 chunk 发到后端 }WebUploader 帮你把这些细节包装好了,但它暴露给前端的事件仍然是"文件维度"的进度。也就是说,你不需要自己去累加每个分片的上传字节数,它会在uploadProgress事件里直接给你一个 0~1 的百分比。这个百分比的计算逻辑就是已上传的总字节数 / 文件总字节数。理解这一点之后,后面做进度可视化才不会被"百分比突然跳一截"的现象吓到——因为分片是离散上传的,每完成一片,百分比就会有一个台阶式的跳跃,这很正常。
2. 在 Vue2 项目里接入 WebUploader:一个最小可用封装
2.1 依赖与资源:为什么绕不开 jQuery
WebUploader 插件本身依赖 jQuery,官方版本建议用 jQuery 1.x,所以接入前得先把 jQuery 引进来。在老项目里,我不太推荐用 npm 安装 webuploader,因为官方包已经很久没更新,npm 上的版本来源也比较杂。更稳妥的办法是把静态资源放进项目的 static 目录或 public 目录,然后在入口页面直接引入:
static/ jquery-1.12.4.min.js webuploader/ webuploader.css webuploader.js Uploader.swf<!-- index.html 或 public/index.html --> <script src="/static/jquery-1.12.4.min.js"></script> <link rel="stylesheet" href="/static/webuploader/webuploader.css"> <script src="/static/webuploader/webuploader.js"></script>CSS 别漏掉,否则文件选择按钮的外部样式、进度条框架都会是默认效果,后面自己调样式反而更麻烦。如果你用的是 Vue CLI,public 目录下的文件会映射到根路径,/static/...这种引用方式在绝大多数场景下都是可用的。
2.2 封装 Uploader 组件:初始化、事件绑定与销毁
在 Vue2 中,我习惯把上传能力封装成一个独立组件,而不是散落在业务页面里。这样上传逻辑可以复用,后续改样式或加功能也方便。下面是一个最小可用的组件结构:
<template> <div class="big-uploader"> <div class="pick-btn" id="filePicker">选择文件</div> <div v-for="item in fileList" :key="item.id" class="file-item"> <div class="file-info"> <span>{{ item.name }}</span> <span>{{ item.sizeText }}</span> <span>{{ item.statusText }}</span> </div> <div class="progress"> <div class="progress-bar" :style="{ width: item.percent + '%' }"></div> </div> <div class="progress-tip">{{ item.percent.toFixed(1) }}%</div> </div> </div> </template> <script> export default { name: 'BigFileUploader', data() { return { fileList: [], }; }, mounted() { this.initUploader(); }, beforeDestroy() { if (this.uploader) { this.uploader.destroy(); } }, methods: { initUploader() { const uploader = (this.uploader = WebUploader.create({ swf: '/static/webuploader/Uploader.swf', server: '/api/upload', pick: '#filePicker', accept: { title: 'Files', extensions: 'xlsx,xls,mp4,zip', mimeTypes: 'application/vnd.ms-excel,video/mp4,application/zip' }, fileVal: 'file', chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, duplicate: true, formData: { token: this.getToken() } })); uploader.on('fileQueued', (file) => { this.fileList.push({ id: file.id, name: file.name, sizeText: this.formatSize(file.size), percent: 0, statusText: '等待上传' }); }); uploader.on('uploadProgress', (file, percentage) => { const target = this.fileList.find((item) => item.id === file.id); if (target) { target.percent = percentage * 100; } }); uploader.on('uploadSuccess', (file) => { const target = this.fileList.find((item) => item.id === file.id); if (target) target.statusText = '上传成功'; }); uploader.on('uploadError', (file, reason) => { const target = this.fileList.find((item) => item.id === file.id); if (target) { target.statusText = '上传失败:' + reason; } }); uploader.on('uploadComplete', () => { // 所有文件处理完成 }); }, formatSize(bytes) { if (bytes < 1024) return bytes + 'B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'; return (bytes / 1024 / 1024).toFixed(1) + 'MB'; }, getToken() { return window.localStorage.getItem('upload_token') || ''; } } }; </script>这里有几个关键点要特别说明:
提示:
this.uploader = WebUploader.create(...)中的uploader属性不要写在data()里。Vue2 会把 data 对象遍历并改造成响应式,而 WebUploader 实例内部有大量方法,放进响应式系统里既浪费性能,又容易出现某些内部属性被监听后发生异常的情况。直接在mounted里给this挂一个普通属性就好。
组件销毁时调用this.uploader.destroy()也很重要,否则页面反复切换时,WebUploader 注册的 DOM 事件会残留,导致内存泄漏。
2.3 服务端配合要点:别让前端白干
如果你的后端接口不支持分片参数,前端把 chunked 打开也没有意义。WebUploader 在分片模式下,会往请求里带上这些关键字段:文件名、文件大小、当前分片索引、总分片数、文件唯一标识等。后端需要能根据这些信息把分片文件暂存,最后一块传完后合并,返回最终文件 URL。所以我建议的落地顺序是:先和后端确认接口协议,再动前端页面。否则前端进度做到 99%,最后一刻因为后端合并失败而报错,用户看到的还是失败弹窗。
3. 把进度“画”出来:从事件回调到可视化 UI
3.1 uploadProgress 到底给你了什么
WebUploader 的uploadProgress事件在分片模式下不是平滑递增的,而是每完成一个分片后突然跳一截。事件回调参数是(file, percentage),其中percentage是 0~1 的小数。你直接把它乘以 100 就能作为进度条的宽度百分比。有些场景下,你还需要知道当前已上传的字节数,可以用file.size * percentage算出来。
不过要注意,上传之前如果需要计算 MD5 做秒传或断点续传,那段时间uploadProgress不会触发,进度条会停在那里不动。这个阶段要单独设计"文件分析中"的 UI,不能让用户以为卡死了。
3.2 多文件队列和整体进度怎么算
如果用户一次选择多个文件,每个文件都会进入队列,并各自触发uploadProgress。这时候,如果你想在页面顶部展示一个"整体上传进度",不能简单把所有文件的百分比平均。正确做法是用字节加权:
calcTotalPercent(fileList) { const totalSize = fileList.reduce((sum, f) => sum + (f.size || 0), 0); const loaded = fileList.reduce((sum, f) => sum + (f.size || 0) * (f.percent / 100), 0); return totalSize ? Math.min(100, (loaded / totalSize) * 100).toFixed(1) : 0; }这段代码里的fileList需要保存每个文件的原始 size,而不是只保存格式化之后的字符串。我在 2.2 的组件里用formatSize展示文件大小,但内部计算时还是要用数字类型的size字段。像这种细节,等做到多文件上传时才会意识到。
3.3 上传速度与剩余时间的计算细节
很多上传组件只显示百分比,其实加上实时速度和剩余时间才是真正让人安心。速度不能直接用当前进度除以总用时,因为网络波动会导致结果忽快忽慢。我常用的做法是用最近一段时间内的字节增量来估算瞬时速度,再做一次平滑处理:
data() { return { lastLoadedBytes: 0, lastTime: 0, smoothSpeed: 0 }; }, methods: { onProgress(file, percentage) { const loaded = file.size * percentage; const now = Date.now(); const diffTime = now - this.lastTime; if (diffTime > 300) { const speed = (loaded - this.lastLoadedBytes) / (diffTime / 1000); // B/s this.smoothSpeed = this.smoothSpeed ? this.smoothSpeed * 0.7 + speed * 0.3 : speed; this.lastLoadedBytes = loaded; this.lastTime = now; } } }diffTime如果太短,算出来的速度容易抖动;大于 300ms 再刷新一次比较稳定。剩余时间则用(file.size - loaded) / this.smoothSpeed计算,注意速度为零时给出一个占位符,比如“计算中”,避免出现 Infinity 或负数。
3.4 避免进度刷新把页面搞卡:节流与局部更新
一次拖入多个大文件时,uploadProgress事件会非常密集。如果每次事件都去更新 Vue2 data 里的对象属性,整个文件列表都会被重新渲染,页面帧率会明显下降。我在实际项目里做了三层防护:
- 设置一个 200ms 的节流窗口,只有超过这个时间才更新
fileList中的数据。 - 百分比变化小于 1% 时跳过更新,因为视觉上根本看不出区别。
- 进度条颜色变化和宽度变化用 CSS transition 过渡,避免出现突变抖动。
const now = Date.now(); const oldPercent = target.percent; const newPercent = percentage * 100; if (now - this.lastRenderTime > 200 && Math.abs(newPercent - oldPercent) > 1) { target.percent = newPercent; this.lastRenderTime = now; }如果你还想更极致,可以直接操作 DOM,绕开 Vue 的响应式渲染,但这样会增加维护成本。我的建议是先用节流方案,因为 200ms 的间隔对用户来说已经很流畅了。
4. 实战中的坑与调优:从“能传”到“传得稳”
4.1 回调里的 this 与 Vue 响应式坑
WebUploader 的 on 回调中,如果使用普通函数,回调里的this指向 WebUploader 实例,不是 Vue 组件。我在代码里全都用了箭头函数,确保this指向组件实例。另外,很多人习惯把 WebUploader 实例放到data()里,结果发现组件变得异常卡顿,这是因为 Vue 会给它做响应式劫持。正确做法是挂在组件实例的非响应式属性上,就像前面代码注释里写的那样。
还有一个容易被忽视的问题:fileList中每个对象只保存需要展示的字段,不要直接把 file 原对象 push 进去。file 原对象里有大量内部状态,被 Vue observe 之后,每次 progress 更新都可能触发更深层次的变更检测,拖慢性能。
4.2 动态 token 与参数更新
大多数后台系统的上传接口都需要携带 token,而 token 在长时间停留后可能会过期。WebUploader 初始化时写入formData.token只对初始化那一刻有效。如果用户从选择文件到真正点击上传间隔了很久,token 可能已经失效。我的处理方式是在before-send-file钩子里统一更新:
uploader.on('before-send-file', (file) => { uploader.options.formData.token = this.getLatestToken(); return true; });这里return true表示继续上传,返回false会中断。要注意,before-send-file是整个文件开始上传之前触发,如果你的 token 是在上传过程中刷新,还需要在before-send钩子里再更新一次。分片上传场景下,before-send还可以顺便把分片序号、文件 MD5 等字段塞进 formData,服务端就能根据这些信息做分片校验和合并。
4.3 弹窗中的 picker 失效问题
如果你把选文件按钮放在 Element UI Dialog 或自己封装的弹窗组件里,很容易遇到"按钮第一次能点,打开弹窗后再点没反应"的故障。原因很简单:弹窗关闭后 DOM 被 v-if 移除,WebUploader 初始化时给 picker 绑定的 click 事件也跟着消失了。
解决方案是:弹窗每次打开后,用this.$nextTick重新初始化 uploader;如果组件已经被初始化,先调用destroy()再重建。另一个更省事的做法是把选择按钮放在弹窗外部的常驻节点上,弹窗内部只负责展示文件列表和进度条。这个方案可以避免重复初始化带来的资源浪费,但需要你根据业务场景权衡。
4.4 分片大小、并发数与失败重试的平衡
我见过有人为了追求极致进度粒度,把分片设成 256KB,结果后端接口压力巨大,整个上传反而更慢。分片大小和并发数需要根据文件大小和服务端能力来定。下面是我常用的推荐配置:
| 参数 | 建议值 | 说明 |
|---|---|---|
| chunkSize | 2MB~10MB | 小于 1MB 请求数过多,大于 20MB 容易单分片超时 |
| threads | 3~5 | 并发太高容易触发服务端限流或网络争抢 |
| retries | 3~5 | 每个分片最多重试次数,防止临时网络抖动导致失败 |
| retryTimeout | 30~60s | 分片超时时间,根据实际网关超时配置调整 |
如果你上传的文件只有几 MB,完全可以关闭分片,用普通整传反而更快。WebUploader 支持在创建时根据文件大小动态决定是否chunked,但官方没有直接提供"按大小自动设置"的开关,你需要在fileQueued事件里判断文件大小并修改上传配置。我一般用 100MB 作为阈值:小于 100MB 不分片,大于 100MB 开启 2MB 分片。这样做的好处是常规中小文件流程简单,大文件又能享受分片带来的稳定性。
5. 从“能用”到“好用”:进阶体验和选型思考
5.1 上传前的 MD5 计算:把“分析中”变成进度条
如果要做秒传或断点续传,前端在上传前要算文件指纹。我推荐用spark-md5,在通过 WebUploader 上传之前,自己读取文件并计算,顺便把计算进度显示出来:
import SparkMD5 from 'spark-md5'; function calculateFileMd5(file, onProgress) { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); const chunkSize = 2 * 1024 * 1024; let loaded = 0; reader.onload = (e) => { loaded += e.target.result.byteLength; spark.append(e.target.result); const percent = (loaded / file.size) * 100; onProgress(percent); if (loaded < file.size) { readNext(); } else { onProgress(100); console.log(spark.end()); } }; const readNext = () => { const start = loaded; const end = Math.min(start + chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); }; readNext(); }拿到 MD5 之后,可以先向后端发起一次"秒传检查",如果文件已经存在,直接提示用户文件已存在,不需要再上传。这一步和大文件上传的进度可视化是配套的,因为 MD5 计算阶段也是用户感知的一部分,不显示进度等于让用户看着屏幕发呆。
5.2 暂停、续传与状态文案的交互细节
WebUploader 原生支持uploader.stop()和uploader.upload(),分别对应暂停队列和继续上传。我用它做过一个带暂停按钮的上传列表,效果还不错。但要注意:暂停后正在上传的分片会被打断,已经上传完成的其它分片会保存在服务端临时目录里。恢复后,WebUploader 会先向服务端确认哪些分片已经存在,再继续传缺失的部分,所以用户看到的进度不会从 0 重新开始。
在 UI 上,暂停状态需要单独展示,否则用户点击暂停后,发现进度条还在动,会认为是 bug。我的做法是在文件对象上加一个status字段:pending、uploading、paused、success、error,渲染时根据status切换文案和颜色。这样比单纯显示一个百分比信息量大得多。
5.3 新项目要不要换掉 WebUploader
说句公道话,如果你正从零开始搭 Vue3 项目,我建议不要再用 WebUploader,因为它依赖 jQuery,和现代前端工程体系有些割裂。用Blob.slice()分片、XMLHttpRequest并发上传、Web Worker处理文件读取,完全能实现同样甚至更可控的进度可视化。但对于 Vue2 老项目,尤其是在线上已经跑了几年的项目,强行重写上传模块风险很高,WebUploader 仍然是性价比很高的选择。它的分片、并发、队列和进度事件设计,放到今天依然没过时。
我后来把这套方案原样迁移到了 Vue2 的另外两个项目里,改动量非常小。核心经验就是:进度可视化从来不只是画一根进度条,而是要让用户在每个阶段都能看到"系统正在正常工作"——无论是计算 MD5、等待队列、上传分片,还是重试失败块,都要有明确的反馈。最后一个建议是,动手前先和后端把分片合并、token 校验、断点续传的接口协议定清楚,否则前端做得再花哨,用户依旧会看到一个传完后失败的弹窗。