做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一次性读完再算MD5,1GB文件能卡住浏览器好几秒,主线程直接冻结,用户体验极差。
现在主流的做法是利用增量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/status | fileKey(文件hash) | exists(boolean),uploadedIndexes(number[]) |
| 上传分片 | POST/upload/chunk | fileKey,index,chunk(二进制) | 是否成功 |
| 合并文件 | POST/upload/merge | fileKey,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级别文件的稳定传输。