首页 / 资讯中心 / 文章详情

Vue大文件上传插件开发:分片、断点续传与秒传全解析

Vue大文件上传插件开发:分片、断点续传与秒传全解析 ★ FEATURED ARTICLE
做Vue大文件上传插件这活儿我前后折腾过两轮。第一轮是给某个后台管理系统做附件模块硬编码把上传逻辑写死在页面里结果换个项目连复制粘贴都没法用。第二轮彻底重写把它拆成一个可插拔的上传核心用的时候传配置、挂钩子业务代码几乎不用改。这篇文章就把完整的开发思路、选型理由和扩展实现一条龙写透从分片上传的基础逻辑到hash计算的细节坑再到断点续传、秒传的落地写法最后把插件架构怎么拆、扩展点怎么留都讲清楚老手可以跳着看新手建议从头过一遍。1. 大文件上传的整体设计思路1.1 先搞明白为什么要做分片浏览器原生上传一个几百MB的文件理论上也能传但实际生产环境里会遇到一堆糟心事。首先是HTTP连接不稳定现在前端项目基本都有网关层代理转发一般都有超时时间我见过比较多的是60秒或者5分钟文件稍大或者网速稍慢请求就被拦腰截断了表现的形式是上传到一半直接报网络错误。其次是服务端和浏览器都有请求体大小限制Nginx默认的client_max_body_size是1MB你没改配置的话连10MB的文件都传不进去更别说什么500MB。就算你把配置都调大了一个请求占着一个连接传大半天对服务端并发资源的占用也非常不划算。分片上传的思路就是化整为零——把一个大文件切成N个小块比如每块5MB然后并发地把这些小块传上去最后在服务端合并。这样每个请求都是小体积、短耗时就算某一小片失败了重传这一片就行不用整个文件从头再来断点续传的能力也就是建立在这个基础之上的。这里说的分片和HTML5的File.slice()是配套的浏览器原生就把文件当二进制数据处理按字节切割只是切片计算不会真正把文件全部加载进内存所以切1000个分片也不会把内存撑爆。这也是为什么分片上传方案能落地的根本原因。1.2 断点续传和秒传的技术底座断点续传要做到的是文件传了一半网络断了页面关了甚至浏览器崩溃了下次再传这个文件的时候能跳过已经传完的部分只补传剩下的分片。这里面最关键的一环就是文件指纹。所有分片最终要归到同一个文件上怎么证明你重传的这个文件就是上次传过的那个用一个稳定的、能代表文件内容的哈希值来标识。前端计算整个文件的MD5或指纹传到后端后端用这个指纹做去重索引。第二次上传同一个文件的时候先查一下服务端哪些分片已经存在了把这些分片的编号跳过剩下没传的继续传这就是断点续传的核心逻辑。而秒传就是断点续传的一个特例——如果服务端查出来所有分片都已经存在了那不用传任何数据直接触发一个合并接口告诉后端“文件齐了”整个文件就算上传完成了。用户体感上就是秒传完成。1.3 插件化架构的分层拆分插件化的核心目标是把上传能力从业务代码里剥离出来做成一个可独立维护、可配置、可扩展的模块。我在设计插件内部结构的时候分了四层层职责要点接入层对外提供upload、pause、resume、cancel等方法面向业务调用方不用了解分片细节核心层分片、队列调度、并发控制、状态管理关注数据加工和任务执行传输层实际发起HTTP请求封装上传动作可替换axios为原生fetch或XMLHttpRequest扩展层提供钩子、事件、中间件入口让外部代码能在关键节点插入自定义逻辑这样拆完之后业务代码跟上传逻辑各干各的。业务只知道调uploader.upload(file)内部的切片方式、请求封装、并发数都可以通过配置切换将来接口协议变了只要把传输层替换掉就行业务方代码几乎不用动。这个架构的好处不只是代码整洁。换个新项目复制整个模块过去改一下配置文件的接口地址和并发数就能直接用。如果不同业务有特殊需求比如某个项目要在上传前加密文件内容就可以通过扩展钩子注入一个transform函数在切片之后对分片数据做处理核心层不用改一行。2. 核心细节解析与关键技术选型2.1 分片大小的合理取值分片大小没有绝对的标准但选型逻辑是清晰可判的。分片设得太小比如1MB总片数量翻倍每个分片都要发起一次HTTP请求请求头、鉴权信息、网络往返开销占比会上升分片设得太大比如100MB单个请求耗时变长一旦失败重传的代价就高。我用的经验值是5MB到20MB之间具体选取要结合你的典型文件大小和服务端接收能力。一个常见做法是根据文件大小动态计算切片大小代码如下function calcChunkSize(fileSize) { if (fileSize 100 * 1024 * 1024) { // 100MB以下用2MB分片 return 2 * 1024 * 1024; } if (fileSize 1024 * 1024 * 1024) { // 100MB~1GB用5MB分片 return 5 * 1024 * 1024; } // 1GB以上用10MB分片 return 10 * 1024 * 1024; }这个策略的思路是小文件追求请求开销低、尽早传完大文件追求失败重传代价小。实际项目里5MB和10MB是出现频率最高的两个值。2.2 hash算法的工程取舍与增量计算计算整个文件的哈希是整个流程里最容易让页面卡死的环节。文件大读取整文件算指纹就慢如果用FileReader一次性读完再算MD51GB文件能卡住浏览器好几秒主线程直接冻结用户体验极差。现在主流的做法是利用增量hash思路把文件分块读入边读边计算算完一块释放一块不等全部读入内存再算。SparkMD5这个库的ArrayBuffer增量模式就是干这个的代码形态大概是import SparkMD5 from spark-md5; async function calcFileHash(file, chunkSize 2 * 1024 * 1024) { const spark new SparkMD5.ArrayBuffer(); const fileReader new FileReader(); let offset 0; while (offset file.size) { const chunk file.slice(offset, offset chunkSize); const buffer await readAsArrayBuffer(fileReader, chunk); spark.append(buffer); offset chunkSize; // 可以在这里通过回调汇报进度 } return spark.end(); } function readAsArrayBuffer(reader, blob) { return new Promise((resolve, reject) { reader.onload (e) resolve(e.target.result); reader.onerror reject; reader.readAsArrayBuffer(blob); }); }这样分批算出来的最终值和一次性读取全文件算出来的完全一致但峰值内存占用小得多UI线程也不会长时间被阻塞。配合requestIdleCallback或者await nextTick()式的让位可以在计算hash期间分段让出主线程保持页面可交互。另外一个容易被忽略的点是如果文件已经上传过且服务器存了hash与分片的映射关系那么拿到秒传结果后就不需要再生成hash。我的插件里加了一个缓存机制按文件大小文件名最后修改时间做一层本地指纹快速判断短时间内重复上传同一个文件直接跳过重算hash节省的耗时非常可观。2.3 并发控制的请求队列设计直接一股脑把所有分片请求同时发出去几十个请求同时打向服务端带宽被抢占每个请求分到的带宽都变少整体上传速度反而下降服务端并发压力还大。正确的做法是控制并发数维护一个任务队列当前活跃请求数达到上限剩余任务排队等待。我在插件里实现了一个极简的并发池核心是一个循环消费队列的逻辑class ConcurrentPool { constructor(limit) { this.limit limit; this.activeCount 0; this.queue []; } add(task) { return new Promise((resolve, reject) { this.queue.push({ task, resolve, reject }); this._next(); }); } _next() { if (this.activeCount this.limit || this.queue.length 0) { return; } const { task, resolve, reject } this.queue.shift(); this.activeCount; task() .then(resolve, reject) .finally(() { this.activeCount--; this._next(); }); } }使用的时候每个分片传一个任务函数进去池子内部保证同时最多发出limit个请求。我设的默认并发是3带宽充足、服务端支持情况下建议调成5太高收益不明显反而会增加重传概率。2.4 插件接口设计与职责边界插件对外暴露的接口既要稳定又要有弹性。稳定是指核心方法名和返回结构不能变弹性是指钩子要够用。我设计的接口分为三类文件操作类start(file)、pause()、resume()、cancel()查询类getStatus()、getProgress()钩子类beforeUpload(file)、beforeChunkUpload(chunk)、onProgress(percent)、onSuccess(result)、onError(err)钩子的设计遵循一个原则默认不做事但提供能力。执行流里每一步都检查是否存在对应钩子存在就调用穿透式地把控制权交给使用方。比如beforeUpload里返回一个false可以中断整个上传流程业务方可以用来实现“文件类型不允许则取消”的校验而插件内部不需要内置任何业务判断。这个设计的边界在于插件只负责把文件二进制切好、传好、汇报好状态不负责存业务数据、不负责渲染进度条UI。UI层面的业务副作用通过事件抛给外层组件处理这样才能保证插件的通配性。3. 实操过程与核心环节实现3.1 从零搭建基础分片上传逻辑先做一个最小可用的分片上传实现再说抽象的事。我采用一个类来承载上传流程业务代码通过一个实例来操作。class ChunkUploader { constructor(options) { this.url options.url; // 上传接口 this.chunkSize options.chunkSize || 5 * 1024 * 1024; this.concurrent options.concurrent || 3; this.file null; this.chunkList []; this.pool new ConcurrentPool(this.concurrent); } start(file) { this.file file; this.chunkList this._splitFile(file); this._uploadChunks(); } _splitFile(file) { const chunks []; let cur 0; while (cur file.size) { chunks.push({ index: chunks.length, blob: file.slice(cur, cur this.chunkSize), }); cur this.chunkSize; } return chunks; } _uploadChunks() { const tasks this.chunkList.map((chunk) () { const formData new FormData(); formData.append(chunk, chunk.blob); formData.append(index, chunk.index); formData.append(filename, this.file.name); return request({ url: this.url, method: post, data: formData, }); }); Promise.all(tasks.map((task) this.pool.add(task))) .then(() { // 全部上传完成调用合并接口 }) .catch((err) { console.error(upload failed:, err); }); } }这里request可以简单封装成axios实例。第一个版本不需要考虑hash、不需要考虑续传先把分片请求发出去收回来整个链路能跑通就说明分片本身没问题。第二步再逐步加入hash确认、服务端分片校验、暂停续传这些能力。3.2 断点续传与秒传的完整实现要在基础版上加入断点续传需要等待三层信息文件的hash值、服务端已有的分片列表、文件在服务端的记录状态。整体逻辑是文件选好之后先生成hash然后带上hash调用服务端的“预查询”接口服务端返回一个对象告诉前端两个信息这个文件是否完整存在秒传、哪些分片序号已经存在续传跳过。前端根据这个返回结果决定后续动作async function prepareUpload(file, uploader) { const res await uploader.calcHash(file); const fileKey res.hash; const { exist, uploadedIndexes } await uploader.checkFileStatus(fileKey); if (exist) { // 秒传逻辑直接合并 await uploader.mergeFile(fileKey); return { status: done, via: instant }; } const pendingChunks filterChunksByUploaded(uploader.chunkList, uploadedIndexes); // 只上传未完成的分片 await uploader.uploadChunks(pendingChunks, fileKey); await uploader.mergeFile(fileKey); return { status: done, via: chunked }; }uploadedIndexes的过滤是关键动作。比如总共切了20个分片服务端说3、7、15已经传过了那前端就只发剩余17个请求原来的分片直接跳过。断点续传的自然含义就成了“有选择地上传缺口分片”。秒传场景里服务端返回exist: true前端一个字节都不上传直接通过合并接口完成归档整个流程耗时几乎可以忽略。这个能力在用户反复上传相同文件或共享文件时非常实用。3.3 服务端配合约定与合并机制分片上传不是前端单方面的事接口约定必须前后端对齐。我实际用的接口设计给出一份供参考的约定接口请求方式参数返回预查询GET/upload/statusfileKey(文件hash)exists(boolean),uploadedIndexes(number[])上传分片POST/upload/chunkfileKey,index,chunk(二进制)是否成功合并文件POST/upload/mergefileKey,filename文件url、大小等服务端收到合并请求后按序号把分片拼起来拼完以后做个大小校验和前端报告的总大小不一致就报错要求前端重新补齐缺失分片。前端拿到错误后按返回的缺失分片序号重新上传对应块比无脑全部重传优化太多。注意服务端的分片缓存需要设置过期策略比如24小时未合并的分片自动清理否则用户传了一半放弃服务端磁盘上就堆了一堆垃圾分片占空间时间长了磁盘迟早被塞满。3.4 插件扩展点的落地写法插件化不是挂在嘴上的概念我给你展示两个真实的扩展点实现。第一个扩展点是加密分片。某项目要求文件在上传过程中不能被直接拦截拿到明文我在插件里暴露了一个transformChunk钩子默认实现是原样返回。业务方接入时的写法是const uploader createUploader({ url: /api/upload, async transformChunk(chunk) { const encrypted await encryptData(chunk); return encrypted; }, });插件内部切好片后、放入FormData之前调用这个钩子完全无感知地替换了传输内容。第二个扩展点是上传进度的实时节流上报。默认的onProgress每切一片、每传完一片都会触发但对于大的进度条而言一分钟触发几十次纯属浪费。业务方可以通过一个节流包装器优化const uploader createUploader({ url: /api/upload, onProgress: throttle((percent) { state.uploadPercent percent; }, 500), });throttle就是一个标准的500毫秒节流函数进度条刷新频率降下来界面的重绘压力也小了而且用户看的还是平滑的递增效果。这个扩展点能落地的关键就是插件允许调用方覆盖默认行为而不是把进度更新函数写死在内部。4. 常见问题与排查技巧实录4.1 hash计算导致页面卡顿与内存飙升分片上传踩过最多的坑就是在计算hash的时候一次性把整个文件读进内存。某次我用一个近2GB的视频文件压测页面直接卡死十几秒内存占用飙到3GB以上差一点就把浏览器干崩溃。解决方向就是前面说的增量计算用FileReader循环读取分片再spark.append()。另一个细节是如果文件极大全量hash计算的时间仍然不可忽略可以在UI上先给一个“正在计算指纹”的过渡状态避免用户以为卡死了。计算期间禁用上传按钮状态到位后再开放。还有一个工程上的缓兵之计——服务端如果支持可以只对文件的前后若干MB加中间若干MB做采样hash速度提升非常明显误判率在绝大多数业务场景下可以接受。前提是跟服务端约定好同一套采样规则否则前后端算出来的hash值对不上。4.2 分片上传完成却合并失败出现这个问题的典型症状是所有分片请求都返回成功但合并接口报错原因是分片缺失或分片大小校验不一致。常见的坑是File.slice()切割时最后一片的大小小于chunkSize服务端如果按固定大小去读就会越界或丢失数据。服务端合并时的正确姿势是按分片的实际大小来追加写入而不是按固定chunkSize去读。前端在上传最后一个分片时不用特殊处理只要把chunk.size如实传给服务端即可。另一个常见原因是并发上传时分片到达顺序错乱服务端存分片的时候不能按请求到达顺序写入最终文件必须先存临时分片等合并时按序号拼接。4.3 并发数过高导致网络抖动并发从3调到10上传速度不会线性提升反而经常出现请求超时。这是因为浏览器与服务端、网络链路之间同时建立太多连接带宽不够分每个连接分到的吞吐量都变低整体耗时反而加长。我踩过把并发拉爆的坑之后做了个简单的限速功能。在并发池之上再加一个带宽估算根据最近几次上传成功的分片大小和耗时动态调整并发数。基本思路是const baseConcurrent 3; const speed recentBytes / recentTime; if (speed 200 * 1024) { // 网速低于 200KB/s降到2并发 this.pool.limit 2; } else if (speed 2 * 1024 * 1024) { // 网速充足升到5并发 this.pool.limit 5; }这只是个启发式策略实际效果在不同网络环境下有区别但方向是对的并发数应该是一个动态值而不是写死的常量。动态调整的代码量并不复杂使用的体验提升却是碾压级的。4.4 上传失败后的重试策略不能一刀切常规做法是对失败的分片立即重试但如果整个网络已经断开立即重试只会连续失败N次消耗完重试次数再宣告失败。我的插件里加了指数退避策略第一次失败后等待2秒第二次等待4秒最多封顶30秒。async function retryWithBackoff(task, maxRetries 3) { let delay 2000; for (let i 0; i maxRetries; i) { try { return await task(); } catch (err) { if (i maxRetries) throw err; await sleep(delay); delay Math.min(delay * 2, 30000); } } }这个策略尤其适合对网盘上传、弱网环境上传。每一步重试前还有一个额外判断——当前网络是否在线如果navigator.onLine为false就直接暂停队列等online事件触发后再恢复省掉无意义的网络请求。4.5 大文件上传进度条卡在99%的经典误导分片都传完了进度条显示99%合并接口耗时较长用户看到进度纹丝不动就会怀疑是不是卡住了。这里的问题不是上传慢而是合并是服务端在做前端没有可靠的反馈渠道。处理办法有两个方向。第一次简单方案是合并接口做成异步任务服务端立刻返回“合并中”的状态前端轮询或通过WebSocket接收进度。第二个更省事的方案是在进度计算里把合并阶段占用的时间纳入体验设计比如分片上传完成时把进度固定在90%合并开始后按轮询到的状态逐步推进到100%至少让用户感知到系统还在工作。值得强调的经验是大文件上传的完整链路里前端只是其中一个环节。文件指纹算法、分片策略、并发控制、重试机制这些前端做完了还得跟服务端把接口约定、临时文件清理、合并策略对齐否则再优雅的前端设计也落不了地。5. 从单页脚本到通用插件的沉淀过程5.1 从业务代码里抽离通用逻辑的步骤最开始的上传代码是直接写在Vue组件里的data里放着分片数组methods里放着切片和请求方法换了页面只能复制粘贴再改改。沉淀的第一步是把只跟文件上传相关的逻辑全部移到独立的js文件里组件里只保留状态引用和方法调用。第二步是以类的形式把所有方法组织起来file、chunkList、concurrent从外部传入类内部不依赖任何组件实例。到这个阶段模块已经可以在多个项目间跳着用了。第三步才是在类的基础上抽象插件钩子和事件机制。这一步要克制不要一上来就引入事件总线、状态管理这些重家伙把钩子的颗粒度控制在“够用”的范围内等有业务真的需要更多扩展点的时候再加也完全来得及。5.2 插件依赖管理与版本演进插件依赖不能贪多我用到的核心依赖只有axios和spark-md5两个。axios负责封装请求如果项目已经全局配置了请求拦截器上传插件可以直接复用这套配置避免重复做鉴权逻辑。spark-md5比较轻量算hash的能力足够用。如果项目对计算性能有更高要求可以换用hash-wasm一类的WASM方案计算速度能快几倍但引入成本和兼容性排查也要一起评估进来。插件本身上线后要留好版本迭代空间。比如早期版本只支持单文件上传后来加批量文件队列、文件夹上传支持都应该是向前兼容的扩展而不是推翻重写。几乎每个版本迭代我都收到过集成方的需求API尽量保持稳定是基本底线。5.3 一个通用插件的边界在哪里做插件的最大风险是把功能堆得太满最后变成一个什么都做不好的巨无霸。我在设计时坚持几个边界原则不做UI组件只做逻辑核心具体进度条样式由业务方自己绘制。不做业务校验文件格式、大小限制交给业务方在beforeUpload钩子里处理。不绑定特定请求库传输层通过适配器模式替换默认实现用axios。不隐式管理生命周期pause和cancel之后的本地状态清理动作要由业务方明确触发。守住边界插件才能在不同业务间保持通用。一旦插件开始内嵌具体的UI样式或业务规则它就会逐渐僵化最后只能服务于当初开发的单一场景。5.4 后续还能继续扩展的方向分片上传的路线图还能往外延伸很多方向。多文件批量上传的调度策略文件夹上传的递归遍历服务端直传OSS时的预签名URL与分片直传Web Worker里做hash计算的并行方案这些都属于同一套内核上的功能镀金。如果团队后面有跨端需求还可以把这套逻辑抽成框架无关的核心包再分别做Vue、React甚至小程序适配层。核心层只依赖浏览器File API不依赖任何框架就能大大扩展它的复用范围。不过升级扩展的前提是核心架构够稳。我把稳定核心看得比新增功能重要得多每次改动都先跑一遍自测断言确认分片覆盖率、暂停恢复、秒传命中、报错重试四条主链路完好才敢升级。写在最后的小提示如果你准备在自己的项目里落地这套大文件上传能力我这里有几个来自一线经验的提醒。第一不要一上来就做秒传和断点续传先把基础分片上传链路跑通让文件能分片传上去、服务端能合成完整文件打牢这一步再逐步加复杂度。第二hash计算务必测试大文件场景小文件看不出问题一次性读完整文件导致页面卡死是必现的只是时间早晚。第三接口千万别设计成前端上传完所有分片再通知服务端合并一定先做好服务端的预查询接口这是秒传和续传的核心入口。分片上传并没有想象中那么神秘拆解下来无非是切、传、并、验四个动作。把每一个动作做到可观测、可控制、可恢复你的上传插件一样能支撑起G级别文件的稳定传输。
阅读完成 · 觉得有帮助?
咨询建站