资讯动态

Vue3大文件上传实战:分片上传、断点续传与秒传机制详解

发布时间:2026/10/14 6:24:50 来源:尧图企业网站定制
接手某内部工具项目时我接到了一个让人头疼的需求系统里要支持上传单个文件大小上限接近1GB。当时我的第一反应很天真——input typefile选完文件之后直接往后台扔一个 POST 不就行了结果被现实狠狠教育了一轮实验环境传一个几十MB的文件完全没问题一上生产超过200MB的文件几乎必失败。要么请求超时要么后端内存直接被打满甚至网关层直接就把请求掐断了。那段时间我每天都在看上传日志最后被逼着老老实实研究大文件上传方案落地了一套基于 Vue3 的分片上传 断点续传 秒传机制。这篇文章就是把整个流程从头到尾拆开讲清楚。这篇文章适合正在处理大文件上传需求的 Vue3 开发者也适合想弄明白分片到底在解决什么问题、不愿意只会调现成库的人。我不会只给结论会把每个选择背后的理由、踩过的坑、以及可以直接抄走的代码都写出来。1. 分片方案为什么大文件必须拆开传1.1 整包上传的三个致命问题很多第一次接触这个需求的人都会问既然浏览器都能把文件一次性读进来为什么不能直接一次性传过去问这个问题之前先想想一次普通的文件上传请求会经历什么客户端把文件二进制数据放进请求体走网络传到服务器服务器再把请求体收完整后交给后端处理。这个过程里有三个环节会直接卡死大文件。第一个是网络超时。一个2GB的文件即使在不错的宽带环境下也要传很久。而浏览器、网关、服务器都各自有请求超时时间Nginx默认的proxy_read_timeout是60秒云厂商的负载均衡通常也有自己的限制。只要任何一环先超时整个上传就宣告失败。更糟糕的是HTTP请求是一次性的断了之后没有接着传这种说法客户端只能从头再来。传了90%之后断了和一开始就断了代价是一样的。第二个是服务器内存。很多后端框架在处理请求体时会先把它完整读入内存比如Java的request.getInputStream()配合框架层缓冲或者Node里不加限制地接收body。2GB的文件就意味着进程要拿出2GB内存来装下这一个请求。生产环境里多个用户同时上传内存直接爆掉连带影响其他业务接口这是真正的生产事故级别的问题。第三个就是失败成本。整包上传时任何一个网络抖动、任何一个WiFi切换都可能导致上传中断。弱网环境下这个概率会被放大得非常明显。我之前的项目里用户用4G网络传一个300MB的文件传到60%左右稳定断线连续试了好几次都是这样最后只能把锅甩给网络运营商。但本质上是我的方案没有给用户接着传的能力。1.2 分片之后的整体流程与接口约定分片方案的思路很粗暴但有效把一个大文件切成一个个小块逐个上传全部传完后让服务端把它们按顺序合并回完整文件。每个小块就是一次独立的HTTP请求任何一块失败了只需要重传这一块其他已成功的分片不用动。我最终落地的整体流程大概是这样的用户选择文件后先判断文件大小小于某个阈值比如50MB就直接走普通上传超过阈值才走分片。对文件内容计算唯一指纹一般是MD5或SHA-1用来标识这个文件。拿着指纹去问服务端这个文件之前传过没有如果已经存在且完整直接返回秒传结果不用再传。如果没传完服务端会返回已上传的分片索引列表前端只传缺失的部分。把文件按固定大小切成多个分片用并发队列限制上传数量同时记录每个分片的上传状态。所有分片传完后前端通知服务端合并服务端校验分片完整性并合并最终返回文件访问地址。这个流程里前端和后端需要约定好三个核心接口接口作用请求携带参数POST/file/exists查询文件是否已存在秒传判断以及已上传分片列表文件指纹 fileHashPOST/file/chunk上传单个分片fileHash、分片索引 index、分片内容 chunkPOST/file/merge通知服务端合并所有分片fileHash、文件名 fileName、分片总数 chunkCount这里需要特别强调一点为什么用文件指纹而不是文件名来判断文件因为文件名完全不靠谱。同一个report.zip在用户A那里是1GB的安装包在用户B那里可能是几百KB的文档。如果拿文件名做秒传判断就会发生传了一个zip另一个人传同名的完全不同内容结果直接秒传成功拿到一份错误的文件的严重事故。所以这块一定得用文件内容计算出来的指纹来标识唯一性。2. 文件切片与唯一指纹前端绕不开的两个基本功2.1 分片大小怎么定切片如何不占内存分片大小是个典型的权衡问题。分片太小比如256KB2GB的文件会被切成8000多片每次上传都要发起一个HTTP请求光是请求头开销和浏览器并发管理就够你受的分片太大比如100MB一片那和整包上传就没本质区别了遇到网络波动照样大概率超时。以我的经验常规网络环境下用5MB一片比较均衡移动弱网下我会把分片大小降到1MB或2MB。并发数则控制在3到6之间。这个数字不是拍脑袋定的浏览器对同一域名的并发连接数是有限制的HTTP/1.1规范里给的是6个左右并发开太多后面的请求只能在浏览器这里排队并不会真正同时上传。前端切片用Blob.prototype.slice方法这一点很关键const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function createChunks(file) { const chunks []; let cur 0; while (cur file.size) { chunks.push({ index: chunks.length, blob: file.slice(cur, cur CHUNK_SIZE), }); cur CHUNK_SIZE; } return chunks; }注意file.slice()返回的仍然是一个Blob对象它只是原始文件的一个视图指向文件数据的某个区间并没有把所有分片拷贝一份到内存里。也就是说切出1000个分片不会让浏览器内存暴涨1000倍。这一点可以放心用。2.2 文件指纹计算如何避免主线程卡死文件指纹是整个断点续传和秒传机制的地基。如果两个不同内容的文件碰巧算出了同一个指纹就会发生错误秒传所以这块我要么用MD5要么用SHA-1实在不放心就上SHA-256。前端计算指纹的标准姿势是用SparkMD5这个库配合FileReader分段读取文件内容边读边累加计算。但是有一个大坑文件越大指纹计算越久。一个1GB的文件用FileReader在主线程上同步循环读取、分段算MD5浏览器页面会在计算期间直接卡死用户看到的就是一个假死页面鼠标点了没反应滚动条拖不动。这个体验在上传大文件时是不可接受的。我当时第一版就是这么干的用户反馈选完文件页面就卡了十几秒我才开始认真处理这个问题。正确的做法是把指纹计算放到Web Worker里// worker.js importScripts(https://cdn.jsdelivr.net/npm/spark-md53.0.2/spark-md5.min.js); self.onmessage (e) { const { file, chunkSize } e.data; const spark new SparkMD5.ArrayBuffer(); let offset 0; function readNext() { const blob file.slice(offset, offset chunkSize); const reader new FileReader(); reader.onload (event) { spark.append(event.target.result); offset chunkSize; if (offset file.size) { readNext(); } else { self.postMessage({ hash: spark.end() }); } }; reader.readAsArrayBuffer(blob); } readNext(); };主线程这边只需要创建Worker、传入File对象然后等待消息返回即可。File对象是可以直接postMessage给Worker的浏览器会做结构化克隆。这个方案实测下来即使1GB文件计算期间页面也完全流畅。2.3 记住上传状态断点续传的基础断点续传不仅要在服务端记录哪些分片传过了前端也要维护一份上传状态。我用的数据结构是一个普通对象Mapindex, 上传状态其中上传状态包括分片索引、分片Blob、当前已传字节数、是否成功、重试次数等信息。这里有个Vue3特有的坑要提醒一下别把这些上传状态塞进ref或reactive里做响应式管理。reactive会递归代理对象文件分片Blob被代理之后性能会肉眼可见地下降而且Map里的内容频繁变动会触发大量无意义的渲染更新。我的做法是所有分片状态放在一个普通Map里维护只把需要展示给用户的进度、状态、错误信息通过ref暴露出去。这样既保证了界面响应又不会拖垮性能。同时为了应对刷新页面后恢复上传我还会把已上传成功的分片索引列表持久化到localStorage里和服务端返回的已上传列表做合并判断。这样用户刷新页面之后重新选同一个文件能立刻跳过已传分片。3. 断点续传与秒传查询接口设计与前端配合3.1 秒传就是一次查询不是特殊上传很多人一提到秒传就觉得很高级其实理解了原理之后就发现它不过是一个查询判断而已。前端算出文件指纹后先请求POST /file/exists服务端拿着指纹查一下文件记录表如果该指纹对应的完整文件已经存在直接返回{ needUpload: false, url }前端什么都不用传直接提示上传成功。如果该指纹有上传记录但未合并返回{ needUpload: true, uploadedIndexes: [0, 1, 2, 3] }前端根据这个列表过滤掉已传分片只传剩下的。如果该指纹完全没有记录返回{ needUpload: true, uploadedIndexes: [] }前端老老实实全量上传。这个设计也顺带解决了断点续传的核心问题用户上传到一半断网了重新选择同一个文件时前端重新算指纹然后通过这个接口拿到服务端已经收到了哪些分片前端就只传缺失的部分。所以你会发现断点续传和秒传其实是同一个查询接口同时支持的两个能力。3.2 重新选择文件后的恢复流程用户重新选择文件之后完整的恢复流程是这样的先做一次快速判断如果文件大小和最后修改时间和之前上传失败时一致可以直接复用之前缓存的指纹跳过重新计算如果无法复用用刚才的Worker方案重新算指纹带着指纹请求/file/exists拿到已上传分片列表把文件切片后过滤掉已上传索引生成待上传分片队列启动并发上传队列开始传剩余分片。这里有一个细节值得说很多人在这一步容易把已上传分片列表直接当成服务端已经有完整文件。这是不行的因为分片可能传了一部分之后进程崩溃服务端的文件记录还没标记为已合并。所以/file/exists接口返回uploadedIndexes时还必须区分有未合并分片和完整文件已存在两种状态否则合并逻辑会出错。3.3 hash 缓存让秒传体验更快指纹计算虽然已经不卡主线程了但它毕竟要消耗时间。如果一个1GB文件每次重新选择都要等十几秒算出hash才能开始上传用户依然会觉得这个系统很蠢。我在实践中加了一个很简单的缓存策略本地用一个Map存文件大小-最后修改时间到文件指纹的映射。用户重新选择文件时先看这个映射有没有命中命中了就直接拿缓存的指纹完全不重新计算。只有当文件大小或修改时间变了才重新算指纹。这个方案不是100%严谨因为两个不同内容的文件也可能有相同的大小和修改时间但对绝大多数场景已经够用了。如果你对准确性要求极高可以再加一层抽样计算取文件第一片、中间一片、最后一片的内容算个快速指纹和完整指纹一起缓存这样命中率更高一些安全性也更高。我自己的项目里用的是加上分片内容大小校验的组合方案至今没出过问题。4. 并发控制、重试与取消上传稳定性的三道保险4.1 一个简单可用的并发队列实现分片上传的核心是1000个分片不能一次性全部发出去否则把浏览器连接池打满后面的请求会全部排队甚至造成浏览器崩溃。我们需要一个控制并发数的队列。网上有很多库比如p-limit、async-pool但实际项目里没必要为此引入一个依赖一个while循环就能搞定。我用的实现是这样async function runPool(tasks, limit, handler) { const results new Array(tasks.length); let cursor 0; async function worker() { while (cursor tasks.length) { const idx cursor; results[idx] await handler(tasks[idx]); } } const workers Array.from({ length: limit }, () worker()); await Promise.all(workers); return results; }这段代码的原理很简单多个工人共享同一个cursor。每个工人每次循环取一个任务索引然后处理它。因为JavaScript单线程的特性cursor这一步天然是线程安全的不需要任何锁。limit个工人会不断取任务直到所有任务处理完。整个队列的并发数就被限制在了limit。用法也很直接const chunks createChunks(file); const failed []; await runPool(chunks, 3, async (chunk) { try { await uploadChunk(fileHash, chunk); } catch (e) { failed.push(chunk); } });4.2 失败重试与精确进度计算分片上传虽然提高了容错性但单个分片仍然可能因为网络抖动失败。我的处理策略是每个分片最多重试3次每次重试间隔按指数退避也就是第一次失败等0.5秒、第二次等1秒、第三次等2秒。超过3次后不再重试把该分片标记为失败最后在界面上给出重试失败分片按钮。重试逻辑可以包装成一个通用函数async function uploadChunkWithRetry(chunk, retryCount 3) { for (let attempt 1; attempt retryCount; attempt) { try { await uploadChunk(chunk); return; // 成功就返回 } catch (e) { if (attempt retryCount) throw e; await sleep(500 * Math.pow(2, attempt - 1)); } } }进度计算要精确不能只统计成功了多少个分片因为一个分片在上传过程中也有进度。更精准的做法是同时维护两个数据已成功分片的总字节数加上当前正在上传的分片的已传字节数。每收到axios的onUploadProgress回调就更新当前分片的已传字节数再重新计算整体进度。const progress computed(() { const uploadedBytes uploadedMap.size * CHUNK_SIZE; const currentLoaded currentProgress.value; // 当前正在传的分片的已传字节 return Math.min(100, Math.floor(((uploadedBytes currentLoaded) / fileSize) * 100)); });这样做的好处是用户看到的进度条是平滑递增的不会出现卡在90%半天不动然后突然跳100%的糟糕体验。4.3 取消上传与状态清理上传功能不能只有开始还要有暂停和取消。暂停和取消在本质上是同一个操作中断当前所有请求。我用的方式是给每个请求挂一个AbortController在并发队列里保存所有活跃请求的AbortController引用const controllers new Set(); async function uploadFile(file, fileHash) { const chunks createChunks(file); const controller new AbortController(); controllers.add(controller); try { await runPool(chunks, 3, (chunk) axios.post(/file/chunk, chunk, { signal: controller.signal }) ); } finally { controllers.delete(controller); } } function cancelUpload() { controllers.forEach((c) c.abort()); controllers.clear(); }这里有个容易忽略的细节abort()之后那些还没发出的请求会直接抛一个AbortError如果你在runPool内部没有捕获这个错误它会变成一个unhandledrejection在控制台里刷出一堆报错甚至触发全局错误监控。所以取消上传时要把AbortError单独识别出来不当作真正的网络错误处理if (axios.isCancel(e) || e.name AbortError) { // 用户主动取消不加入重试队列 return; }5. 服务端接口与合并逻辑没有它前端再花哨也没用5.1 分片接收接口怎么写前端设计得再好服务端如果只提供一个简单的POST /upload接收整个文件分片方案依然是空中楼阁。我用的服务端是Node.js Express分片接收接口的核心逻辑比较简单const fs require(fs); const path require(path); const UPLOAD_DIR path.resolve(__dirname, uploads); app.post(/file/chunk, (req, res) { const { hash, index } req.body; if (!hash || index undefined) { return res.status(400).json({ message: 参数缺失 }); } const chunkDir path.join(UPLOAD_DIR, hash); if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }); } const chunkPath path.join(chunkDir, ${index}); const ws fs.createWriteStream(chunkPath); req.pipe(ws); ws.on(finish, () { res.json({ ok: true }); }); ws.on(error, (err) { res.status(500).json({ message: err.message }); }); });这里有几个细节必须说清楚。第一req.body里的hash和index不能直接用原始字符串拼接文件路径否则用户恶意构造一个hash: ../../etc/xxx就会路径穿越。我实际项目里会用正则或自定义函数严格校验hash只能包含十六进制字符index只能是非负整数然后再拼路径。第二分片不应该散落在同一个目录下而是按hash建子目录这样一个文件的几百个分片都集中在同一个文件夹里方便后续合并也方便清理临时文件。第三req.pipe(ws)这种方式能流式地把请求体写入磁盘不会把整个分片读进内存。分片本身只有几MB内存压力不大但如果用multer这类库默认的memoryStorage它会先把分片读进内存再写文件高并发下依然有问题。对于上传场景优先用diskStorage或直接pipe。5.2 合并分片与完整性校验所有分片传完后前端调用POST /file/merge。服务端要做的事是读取hash目录下的所有分片按索引排序依次写入一个完整文件最后校验文件大小和总大小是否一致。app.post(/file/merge, async (req, res) { const { hash, fileName, chunkCount } req.body; const chunkDir path.join(UPLOAD_DIR, hash); const filePath path.join(UPLOAD_DIR, sanitize(fileName)); // 读取所有分片按文件名排序文件名就是索引 const chunks fs.readdirSync(chunkDir) .filter((name) /^\d$/.test(name)) .sort((a, b) Number(a) - Number(b)); if (chunks.length ! Number(chunkCount)) { return res.status(400).json({ message: 分片数量不完整 }); } const ws fs.createWriteStream(filePath); for (const chunkName of chunks) { const chunkPath path.join(chunkDir, chunkName); const data fs.readFileSync(chunkPath); ws.write(data); } ws.end(() { // 合并完成清理临时分片目录 fs.rmSync(chunkDir, { recursive: true, force: true }); res.json({ ok: true, url: /download/${hash} }); }); });合并前为什么一定要校验分片数量因为如果某个分片在传输过程中丢失但前端因为某些原因没发现服务端就会拿着不完整的零件去组装机器最后得到一个损坏的文件。校验数量只是一个兜底方案更严格的做法是前端在/file/merge时额外传一个文件总大小服务端合并后比对实际文件大小。理想状态下这两者应该完全一致。5.3 服务端性能相关的注意点服务端的坑主要在磁盘和带宽管理上。一是磁盘空间。合并1GB文件需要1GB临时分片空间合并过程中还需要再占用1GB写入完整文件。也就是说最极端情况下需要2倍文件大小的磁盘空间。我建议服务器预留至少最大单文件大小 × 1.5的临时空间否则并发上传几个大文件磁盘满了会连锁影响其他所有业务。二是临时文件清理。如果用户上传到一半跑路hash目录下的分片就永远留在了服务器的磁盘上。没人清理的话过几个月磁盘空间会被垃圾分片塞满。我的做法是跑一个定时任务定期删除超过24小时内未被访问的hash目录。生产环境如果你有专门的运维体系也可以把这个逻辑集成到文件管理服务里。三是合并操作对CPU和带宽的影响。把所有分片读进内存再写出虽然可行但大文件合并时内存峰值也会上来。更稳的写法是直接用createReadStreampipe逐个分片追加写入保持流式处理const { pipeline } require(stream/promises); for (const chunkName of chunks) { const src fs.createReadStream(path.join(chunkDir, chunkName)); const dest fs.createWriteStream(filePath, { flags: a }); await pipeline(src, dest); }流式处理的好处是内存占用恒定不管文件是100MB还是10GB合并过程的内存占用都不会跟着涨这在生产环境里是很重要的特性。6. 实测数据与高频踩坑记录6.1 2GB文件在内部工具项目里的实测情况方案落地之后我找了一个2GB的测试文件在办公网络环境上行带宽约50Mbps下做了几轮实测。分片大小5MB并发数3。算上文件指纹计算时间完整上传大概需要7分钟左右。中途我手动断了一次网恢复了之后重新选择同一个文件系统直接跳过了已经上传的约60%分片只用了3分钟就完成了剩余部分。这个结果在当时是很让人满意的——至少再也不会出现传了半小时然后失败了的崩溃场景。换到弱网环境上行带宽约5Mbps我把分片大小调成了1MB并发数保持3。因为单分片只有1MB即使网络抖动导致请求失败重试的成本也很低。实测下来整体成功率接近100%偶尔有分片失败也会在重试后自动补上。这里也验证了一个经验分片大小不能盲选要结合实际网络环境。带宽高就调大分片减少请求数量带宽低就调小分片降低单次请求失败的成本。最好的做法是把分片大小和并发数做成配置项根据部署环境随时调整。6.2 踩过的坑和解决方案把这几个月踩过的坑整理成了一份对照表每一行都是真实遇到过一次的问题问题现象根因分析解决方案选完大文件页面卡死十几秒在主线程用FileReader逐段计算MD5把指纹计算放到Web Worker里并发调到10后请求反而变慢浏览器同域名连接数限制请求在排队并发数控制在3~6合并出来的文件是坏的分片文件名排序按字符串排导致10排在2前面按数字索引排序后再合并某些分片传失败了整个任务失败没有重试机制单分片重试3次指数退避取消上传后控制台刷一堆报错AbortError未捕获被当成异常抛出单独识别AbortError并吞掉服务端磁盘满了未合并的临时分片没人清理定时任务删除超期分片目录秒传命中错误文件用文件名判断文件唯一性改用文件内容指纹判断刷新页面后不能续传前端没有持久化已传分片状态用localStorage缓存已传索引列表上传进度条忽快忽慢只统计成功分片数没有统计分片内部进度合并统计当前分片的已传字节数还有一个容易忽略的体验问题分片上传接口如果用FormData传递分片会额外增加一部分multipart编码的开销。分片本身只有几MB可能不明显但如果追求极致性能可以直接用Blob对象作为axios的请求体并手动设置Content-Type为application/octet-stream。这样省掉了FormData的序列化和boundary开销对大量分片的场景有一定帮助。最后说点个人体会。这次做完之后我对稳定这两个字的理解比之前深了很多。大文件上传这个需求真正的难点从来不是把文件传给服务器这个动作而是在网络和设备都不稳定的情况下仍然能给用户一个确定性的结果。分片、断点续传、秒传本质上都是在对抗不确定性。而其中最关键的两个模块——文件指纹计算和上传状态管理一个决定了你能不能在断线后快速恢复一个决定了整个上传过程是否可观测、可控制。这两个模块做得稳整套上传体验就稳了。如果你接下来也要做类似的功能我建议先把接口约定想清楚前端和服务端都严格按约定来然后才是考虑要不要上Worker、并发队列怎么优化。顺序反了后面会改得很痛苦。另外千万别忘了清理服务端的临时分片这个坑我是在磁盘报警的时候才发现的代价是真的肉疼。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑