ARTICLE DETAIL

资讯详情

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

Vue2大文件上传实战:WebUploader分片上传与进度可视化

Vue2大文件上传实战:WebUploader分片上传与进度可视化 前阵子在维护一个Vue2老项目时业务提了个很现实的需求系统里的数据包动辄几个G用普通文件上传直接超时用户传一半就断没有任何进度提示只能干等。领导拍板要上一套“大文件上传进度可视化”的方案。我翻遍现有插件最后选了百度开源的WebUploader。这不是什么新东西但在Vue2生态里它确实是把分片上传、并发控制、进度回调凑得最齐的组件。这篇教程不整虚的直接分享我们在生产环境里怎么用Vue2接WebUploader包含完整封装代码、参数取舍、后端合并细节以及我踩过的坑。适合正在维护Vue2老项目、或者准备用WebUploader做上传模块的同学参考。1. 需求拆解与方案选型1.1 大文件上传的典型痛点先说场景。大文件上传最常见的是若干个GB级别的压缩包、数据库备份、音视频素材。普通input typefile提交的是整体请求一旦网络波动、代理超时、后端处理慢请求就断了而且前面的数据传输全部作废。用户没法知道传了多少、还差多少体验很糟糕。解决问题通常有三个方向加大服务端超时时间、改用分片上传、加上进度反馈。单纯加大超时只是把爆炸时间往后拖延治标不治本。分片上传是把大文件切成小块每片独立上传即使某一小片失败也能单独重传。进度反馈则是把“当前上传量/文件总大小”实时显示出来让用户心里有底。WebUploader恰好把分片和进度这两件事都做了。它内部用Blob.slice把文件切块然后每个分片单独发POST请求同时在每个分片上传完成后计算整体百分比通过事件通知上层。这就是我们想要的“大文件上传进度可视化”闭环。1.2 为什么选WebUploader而不是其他方案当时我也对比过几个选择原生XMLHttpRequest手写分片、Plupload、resumable.js、WebUploader。方案分片上传并发控制进度回调Vue2友好度维护成本原生XHR手写自己实现自己实现基于xhr.upload一般很高Plupload支持支持支持一般较高resumable.js支持支持支持一般较高WebUploader支持支持支持高低手写XHR当然可控性最强但你需要处理队列、并发线程数、分片重试、文件去重、进度计算开发周期不会短。Plupload 和 resumable.js 能力也不差但WebUploader在文件队列、事件机制、UI绑定上做得更顺手尤其是它的pick、dnd、paste可以直接绑定页面里的选择按钮和拖拽区域省掉不少DOM操作代码。这里要注意WebUploader的双模式里有Flash但Flash这条路已经废弃了。现在实际项目里只走HTML5模式初始化参数里swf路径仍然建议配上兼容老IE不配只影响极旧浏览器问题不大。1.3 在Vue2项目里必须做一层组件封装WebUploader本身是面向DOM的jQuery风格组件和Vue2的数据驱动模型相处得并不自然。如果直接在页面里WebUploader.create()它会往DOM里塞input、绑定事件这些DOM不归Vue管理。一旦Vue组件重渲染或者v-if切换节点WebUploader里的DOM引用就会失效选择按钮很可能消失。所以我的做法是把WebUploader封装成一个独立Vue子组件。父组件通过props传递上传地址、文件类型限制、分片大小等参数子组件内部维护文件列表和进度状态再通过$emit把上传成功、失败等事件抛出去。这样Vue的data是唯一数据源WebUploader只负责网络分片和DOM拾取两层互不干扰。2. 核心参数、进度机制与后端联动细节2.1 初始化参数怎么填WebUploader的配置项很多但真正和“大文件进度可视化”强相关的就几个。我常用的一组参数如下参数作用我常用的值说明chunked是否开启分片true大文件上传的核心开关chunkSize每个分片大小2 * 1024 * 10242MB相对平衡threads并发上传分片数3并发太高容易打满带宽fileSizeLimit单文件大小上限2 * 1024 * 1024 * 1024表示2GB0表示不限制fileNumLimit队列文件数量上限20防止一次挂太多auto选择文件后是否自动上传false手动控制便于展示列表duplicate是否允许重复文件false一般禁止重复避免误传pick选择按钮DOM#filePicker指向组件内元素accept文件类型过滤按需配置和input accept类似chunkSize要合理。太小会导致分片请求数量巨大比如1GB文件如果每片512KB就是2048个请求服务器压力大太大又失去断点续传的意义。2MB到5MB是我觉得比较理想的区间。threads并发数如果公网上传建议2~3内网带宽足可以开到5但开多了Chrome的并发连接数也会受限。还有一个formData参数可以给每个分片请求统一追加公共参数比如用户token、上传渠道。但如果你要针对某个文件动态加参数更灵活的做法是在uploadBeforeSend回调里往data对象塞字段。这个后面会讲。2.2 进度回调的机制与性能优化WebUploader在上传过程中会触发uploadProgress事件回调有两个参数文件对象和百分比。百分比是0到1的小数比如0.67表示67%。我们要做的就是把百分比转成整数更新到Vue的data里用进度条展示出来。这里有个容易被忽略的细节uploadProgress触发频率非常高每个分片每次上传数据时都可能触发。如果文件列表里同时有3个文件在传一秒钟可能回调几十次。每次都直接改Vue数据组件重渲染频繁页面会出现掉帧、卡顿。我的优化方式是做节流只允许每200毫秒内最多更新一次进度。最后在上传成功时手动把进度置成100%避免因为节流导致最后一个进度值停留在99%。示例// 简单节流 let lastUpdateTime 0 this.uploader.on(uploadProgress, (file, percentage) { const now Date.now() if (now - lastUpdateTime 200) return lastUpdateTime now const item this.fileList.find(f f.id file.id) if (item) { item.percent Math.round(percentage * 100) } }) this.uploader.on(uploadSuccess, (file) { const item this.fileList.find(f f.id file.id) if (item) { item.percent 100 item.status success } })如果你的文件不多不做节流也不会有什么问题。但作为一个负责的上传组件我希望无论用户传多少文件页面都保持流畅所以这个节流我会保留。2.3 分片参数与后端合并逻辑开启chunked后WebUploader向服务端发送的每个分片请求会自动带上几个关键参数chunk表示当前分片的索引从0开始chunks表示总分片数量name是原始文件名size是文件大小。后端必须依靠这些参数把分片按顺序拼接回完整文件。很多前端同学只写了前端结果进度条能到100%后端报错或者合并出来的文件打不开。原因通常是后端没有处理分片参数把每个分片当成独立文件直接返回成功。所以前端要配合后端把协议定清楚。以Java或者Node后端为例常见做法是收到分片后把当前分片按chunk序号写入临时目录等到所有chunks都已上传完毕按chunk从小到大读出来合并成一个文件。伪代码如下# 示意伪代码不依赖具体框架 def upload_chunk(request): chunk request.get_param(chunk) # 当前分片索引从0开始 chunks request.get_param(chunks) # 总分片数 file request.get_file(file) # 当前分片内容 save_to_temp(file, request, chunk) uploaded count_temp_chunks(request) if uploaded chunks: merge_temp_chunks(request, chunk_indicessorted(range(chunks))) return complete return ok如果前端在uploadBeforeSend里额外加了自定义参数比如bizId、userId后端同样可以从请求里取到用来做文件归属和分片目录隔离。注意不要覆盖WebUploader自带的chunk和chunks字段否则后端就懵了。3. 完整实操用Vue2封装一个FileUploader组件3.1 引入WebUploader与静态资源我用的是npm包方式npm install webuploader --save然后需要在入口文件或者组件里引入样式import webuploader/dist/webuploader.css同时把node_modules/webuploader/dist/Uploader.swf复制到项目的静态目录比如public/static/并在初始化参数里指向它。虽然现代浏览器不加载Flash但这个路径必须配不然某些环境初始化会报错。如果项目是HBuilderX或者普通Vue CLI搭建的Vue2工程方式一样。确保能访问到静态资源文件就行。3.2 组件模板与脚本下面是我在生产环境里用的一个简化版FileUploader.vue去掉了业务定制逻辑保留最核心的“选择文件、展示列表、进度条、上传/取消”能力。template div classfile-uploader div classtrigger-area div idfilePicker classpick-btn选择文件/div span classhint支持分片上传最多10个文件单文件不超过2GB/span /div ul classupload-list li v-foritem in fileList :keyitem.id span classfile-name{{ item.name }}/span span classfile-size{{ formatSize(item.size) }}/span div classprogress-wrap div classprogress-bar :style{ width: item.percent % } /div /div span classfile-status{{ statusText(item.status) }}/span button v-ifitem.status queued classbtn btn-start clickstartOne(item) 开始 /button button v-ifitem.status uploading classbtn btn-cancel clickcancelOne(item) 取消 /button /li /ul div classaction-bar button classbtn btn-primary clickstartAll开始上传全部/button button classbtn btn-cancel clickcancelAll取消全部/button /div /div /template script import WebUploader from webuploader export default { name: FileUploader, props: { server: { type: String, required: true }, accept: { type: Object, default: () ({ title: All Files, extensions: }) }, chunkSize: { type: Number, default: 2 * 1024 * 1024 }, threads: { type: Number, default: 3 }, auto: { type: Boolean, default: false } }, data() { return { uploader: null, fileList: [] } }, mounted() { this.initUploader() }, beforeDestroy() { this.destroyUploader() }, methods: { initUploader() { this.uploader WebUploader.create({ swf: /static/Uploader.swf, server: this.server, pick: #filePicker, accept: this.accept, chunked: true, chunkSize: this.chunkSize, threads: this.threads, auto: this.auto, fileNumLimit: 10, fileSizeLimit: 2 * 1024 * 1024 * 1024, duplicate: false }) let lastUpdateTime 0 this.uploader.on(fileQueued, (file) { this.fileList.push({ id: file.id, name: file.name, size: file.size, percent: 0, status: queued }) }) this.uploader.on(uploadProgress, (file, percentage) { const now Date.now() if (now - lastUpdateTime 200) return lastUpdateTime now const item this.fileList.find(f f.id file.id) if (item) { item.percent Math.round(percentage * 100) item.status uploading } }) this.uploader.on(uploadSuccess, (file) { const item this.fileList.find(f f.id file.id) if (item) { item.percent 100 item.status success } }) this.uploader.on(uploadError, (file) { const item this.fileList.find(f f.id file.id) if (item) { item.status error } }) this.uploader.on(uploadComplete, (file) { // 单个文件所有分片处理完成 // 这里可以追加日志或提示 }) }, startAll() { if (this.uploader) { this.uploader.upload() } }, startOne(item) { if (this.uploader) { this.uploader.upload(item.id) } }, cancelAll() { if (this.uploader) { this.uploader.stop(true) } }, cancelOne(item) { if (this.uploader) { this.uploader.stop(item.id) } }, destroyUploader() { if (this.uploader) { this.uploader.destroy() this.uploader null } }, statusText(status) { const map { queued: 等待中, uploading: 上传中, success: 已完成, error: 失败 } return map[status] || status }, formatSize(size) { if (!size) return 0B const units [B, KB, MB, GB] let index 0 let num size while (num 1024 index units.length - 1) { num num / 1024 index } return num.toFixed(2) units[index] } } } /script这段代码注意几个点picker指向的#filePicker是组件模板里的DOM需要在mounted之后初始化WebUploader。如果放在created里会找不到节点。所有WebUploader事件回调都用了箭头函数保证this指向Vue实例避免写const self this。cancelOne用的stop(item.id)item.id是WebUploader文件对象的id不能传Vue文件列表的索引。beforeDestroy一定要调用destroy()否则组件销毁后WebUploader后台可能还在跑上传或者监听事件造成内存泄漏。3.3 在业务页面中使用组件封装好以后父组件使用起来很简单。比如在某个业务表单里引入template div file-uploader server/api/file/uploadChunk :acceptacceptConfig :chunk-size2 * 1024 * 1024 :threads3 upload-successhandleUploadSuccess / /div /template script import FileUploader from /components/FileUploader.vue export default { components: { FileUploader }, data() { return { acceptConfig: { title: ZIP、CSV、SQL, extensions: zip,csv,sql } } }, methods: { handleUploadSuccess() { // 提示用户或刷新列表 } } } /scriptserver接口路径前端写相对路径就可以背后由Webpack或Nginx代理到真正的服务地址。注意接口地址不要加Vue路由上的history模式那种伪路径确保WebUploader请求的是真实后端地址。3.4 进度条样式细节与按钮交互进度条我直接用纯CSS实现不依赖UI库。这样在任意项目里都能跑。给一点基础样式.file-uploader .progress-wrap { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; flex: 1; } .file-uploader .progress-bar { height: 100%; background: #1677ff; border-radius: 4px; transition: width 0.2s linear; }transition: width 0.2s会让进度条变化更平滑也顺便隐藏了节流带来的跳变感。如果用的是Element UI可以把div.progress-wrap替换成el-progress组件百分比直接绑定item.percent。按钮交互上上传中的文件不允许再次点“开始”所以我用v-if做了状态互斥。取消上传的按钮如果用户手滑点掉重新选择同一个文件会受duplicate: false影响。实测中确实有人会误取消所以要不要允许重复文件需要按业务权衡。如果必须重新上传同名文件把duplicate改成true但要注意文件列表里可能出现同名的两个item靠id区分即可。4. 高频问题排查与避坑实录4.1 选择按钮在Vue更新后消失这个问题我一开始就遇到了。现象是第一次页面渲染时“选择文件”按钮正常但文件列表推进或组件状态变化后按钮没了。原因是WebUploader初始化之后在按钮内部动态创建了真正触发文件选择的input并把透明层覆盖在按钮上。Vue重新渲染#filePicker及内部DOM时WebUploader持有的DOM引用被替换事件失效。解决思路保证#filePicker内部的DOM不在Vue渲染的v-if、v-for里。不要动态修改#filePicker内部的子节点。如果确实要调整按钮文案和样式用CSS伪元素或者固定子元素结构。我在组件里把#filePicker单独放在一个div容器里容器内没有Vue动态指令问题就消失了。4.2 大文件上传时页面卡顿、进度条掉帧大文件上传时页面卡顿除了进度回调频繁外还有可能是文件列表里每一项的进度都在触发重渲染。如果同时上传10个文件每个进度条每200毫秒更新一次Vue在低端设备上压力会很大。我的排查步骤先确认是不是网络请求阻塞主线程。打开DevTools看Network面板是否一堆分片请求排队如果是降低threads并发数。再确认是不是进度更新太频繁。把uploadProgress里的更新逻辑注释掉再看页面是否流畅。加上节流后如果还卡考虑把进度条单独拆成一个子组件只有进度值变化时才重渲染那一小块。实测下来threads3加200毫秒节流在普通笔记本上同时传5个1GB文件页面依然能正常操作。4.3 回调里的this指向和响应式更新陷阱WebUploader的回调风格是jQuery式函数不用箭头函数时this指向的是WebUploader实例不是Vue组件。很多同学一开始会把this.fileList.push写成this.uploader.fileList.push结果数据不更新。解决办法是统一用箭头函数this.uploader.on(uploadProgress, (file, percentage) { this.fileList this.fileList.map(...) })另外如果你直接修改fileList里某个项的percent属性因为对象是当初push进去的已经做了响应式转化所以能正常触发更新。但如果后来用索引this.fileList[index] newObj替换整个项Vue2是监听不到的。这时候要用this.$set(this.fileList, index, newObj)。我们的组件里没有用索引替换只是原地修改属性所以没问题。但如果你在别处要动态增加字段记得用this.$set。4.4 服务端合并文件顺序错乱或文件损坏前端进度到了100%后台却报文件损坏或大小不对。我排查过两次第一次是后端拿文件名排序而不是拿chunk序号排序。第二个分片文件命名里混入了前端生成的分片MD5导致排序错乱。正确做法所有临时分片文件命名时只保留文件唯一标识 下划线 分片索引比如abc123_0.partabc123_1.part。合并时严格按索引升序读取。这个唯一标识最好由前端在uploadBeforeSend里传过来this.uploader.on(uploadBeforeSend, (file, data) { data.fileMd5 fileMd5Map[file.id] || data.bizId some-biz-id })后端收到后用bizId或者fileMd5作为分片文件的目录名这样不同文件的上传分片不会混在一起。分片索引用WebUploader自带的chunk字段它是从0开始递增的。4.5 断点续传与秒传的扩展思路WebUploader本身没有完整实现断点续传但它有MD5插件可以计算文件hash。如果你想扩展“秒传断点续传”基本思路是文件入队后计算整个文件的MD5。在真正上传前把这个MD5发给后端查询。后端返回“文件已存在”或者“已有哪些分片”。如果文件已存在直接提示秒传成功如果有一部分分片存在前端跳过这些分片只上传缺失分片。由于WebUploader每次上传都是独立请求要跳过已存在的分片可以在uploadBeforeSend拿到当前chunk序号时判断如果后端说这个分片已存在就调用uploader.skip()或者直接返回成功状态。具体实现和WebUploader版本有关但思路是通用的。这种扩展需要后端配合提供查询接口代码量不小如果不是强需求建议先不做。大多数业务场景下有了分片上传和进度展示已经能解决90%的问题。4.6 一些值得注意的细节不要在uploadProgress里做高耗时的操作比如深拷贝整个文件列表。进度更新只要改percent和status两个字段就够了。组件销毁时WebUploader的destroy()会尝试卸载Flash如果没有flash会走空逻辑不会报错。别为了安全把destroy包在try/catch里什么都不做。后端要对分片请求设置合理的超时时间但不要设置过短。并发3个分片时单个2MB分片上传在普通网络下一般几秒内完成。如果服务器处理很慢可以适当调大requestTimeout参数。上传完成后前端最好把文件列表里已经成功的项清掉不然列表越积越长内存和UI都有压力。使用nginx等服务器时分片后的单次请求体积很小一般不会触发上传大小限制但如果整体上传失败率很高记得检查代理层有没有限制单链接体量。最后再分享一个经验封装上传组件时不要把WebUploader实例直接挂在Vue的data里。虽然也能用但实例内部有大量原生属性和方法Vue2会给它做一层代理白白增加内存和响应式开销。我是把它挂在普通实例属性上比如this.uploader null但不在data里声明而是直接在created里给一个非响应式的引用。如果你用TypeScript可以用$uploader命名好维护又清晰。这一个组件从上手到跑通前后大概花了一天。最大的感受是WebUploader虽然版龄不小但在Vue2项目里把分片、并发、进度这几个硬需求串起来依然比从零手写少走很多弯路。你只要把初始化参数和事件回调理解透再记住节流和销毁这些细节就能稳定跑起来。把这套组件沉淀到项目里后续其他页面需要上传大文件直接复用就行。
返回列表