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

Vue大文件分块上传实践:断点续传、秒传与并发控制

Vue大文件分块上传实践:断点续传、秒传与并发控制 ★ FEATURED ARTICLE
我在汽车配套制造企业的技术部门做过几年前端日常打交道最多的需求之一就是文件上传。一开始团队里没人把上传当回事直到客户甩过来一份800多MB的整车产线检测视频要求在MES系统里归档。结果后端超时、浏览器假死、断到一半从头再来折腾了一周才意识到在工厂这类真实生产环境里大文件上传从来不是“调一个接口”那么简单。如果你也是Vue技术栈又正好接过类似“汽车制造大文件分块上传”的需求那这篇DEMO拆解应该能帮你省不少弯路。先讲清楚这个东西是干什么的大文件分块上传就是把一个动辄几百MB甚至几个GB的文件在前端按固定大小切成若干块逐块传到后端最后再合并。它解决的核心痛点有三个——避开服务器/网关的请求体大小限制、解决网络抖动导致的整包重传、让上传进度真实可感。适合谁看用Vue做管理后台、MES系统、文件网盘的开发者以及面试前想把这题彻底吃透的前端候选人。1. 为什么偏偏是汽车制造场景需要这个DEMO1.1 传统上传死在了哪几个环节很多人以为大文件上传的瓶颈只是“慢”其实真实情况比这复杂得多。拿汽车制造环境举例典型的大文件有这几类整车装配线的监控录像一段经常超过1GB、CAD/CAE设计图纸几百MB到几个GB不等、质量检测记录附带的高清图片包、产线设备导出的日志文件。这些文件有两个共同点体积大、数量多而且都是生产环节的真实数据缺一个都可能导致追溯链条断裂。传统input typefile配一个后端接收接口的做法在这种场景下会连续踩坑。首先是后端框架的请求体限制Tomcat默认的maxPostSize是2MBSpring Boot里虽然可以通过spring.servlet.multipart.max-file-size调大但调大之后又会遇到新的问题——Java后端一次性接收一个1GB的文件需要申请差不多同样大小的堆外内存来处理多线程并发上传时内存直接被打满。其次是浏览器层面读取一个大文件进行完整上传中间只要网络抖一下导致TCP连接断开整个请求就废了前端的onerror回调触发后用户只能重头来过。最后是用户的感知问题一个几GB的文件如果前端只能显示“上传中”三个字任何操作者心里都没底不知道这到底是在传还是卡死了。1.2 分块上传到底在解决什么分块上传的核心思想很简单把一个完整文件切成若干个小块每一块独立上传后端接收后先落盘暂存等所有分块都到齐了再按顺序合并出原文件。这个方案把前面说的三个问题全部拆掉了。第一避开单请求大小限制。单个分块可以控制在1MB到10MB之间这个量级远远低于大多数网关、Nginx、后端框架的限制不需要为超大文件单独调配置。第二实现“哪块坏了重传哪块”。在生产环境中工厂内网虽然整体带宽尚可但车间现场的AP和交换机经常出现瞬断整包上传时一次断线就前功尽弃分块上传后前端可以记录哪些分块已经成功下次只补传失败的那几块。第三让进度计算变得真实。因为文件被拆成了N块前端可以用已完成块数 / 总块数来计算进度配合每个分块的loaded事件还能算得更细用户能看到“第27/96块”这种明确的进度信息。1.3 DEMO的边界与目标我这个DEMO不是给头部车企那种超大流量平台用的生产级方案而是一个能快速跑通、能讲清楚原理、能直接往Spring Boot Vue项目里移植的工程原型。目标很简单把一个1GB左右的文件通过Vue前端切片后稳定上传到后端并合并成功支持断点续传和秒传在分块上传过程中不会把浏览器主线程卡死上传进度能被用户直观感知。所以这个DEMO里没有做复杂的权限系统、私有云存储适配、分布式文件服务那些属于真正落地时的外围内容。核心关注点始终是切片、哈希、并发控制、续传、合并这五件事怎么用Vue实现。2. 分块上传的前端核心设计2.1 文件切片把大文件拆成可控单元切片这个动作本身不复杂浏览器原生APIFile.prototype.slice()就是干这个的。Vue里通常在一次change事件中拿到用户选择的File对象然后循环调用切片接口把整个文件切成一个数组。我习惯的切片代码长这样const CHUNK_SIZE 2 * 1024 * 1024; // 2MB一片 function createChunks(file) { const chunks []; let start 0; while (start file.size) { const chunkData file.slice(start, start CHUNK_SIZE); chunks.push({ chunkData, uploadId: null, // 后端初始化后返回 chunkIndex: chunks.length, chunkTotal: Math.ceil(file.size / CHUNK_SIZE), fileMd5: null, uploaded: false }); start CHUNK_SIZE; } return chunks; }这一步看起来简单但有两个容易被忽略的细节。第一File.slice()是惰性切片它不会真的把文件内容一次性读进内存只是记录了一个引用和偏移量真正读取发生在调用FormData.append()并发送请求的时候所以不需要担心切200个分块就把内存撑爆。第二分块大小不能拍脑袋定我在第四节会专门说怎么根据实际网络环境选2MB、5MB还是10MB。2.2 先算哈希为秒传和断点续传铺路如果只是把文件切碎上传那这个DEMO还停留在“能用”级别。真正带工程价值的设计是上传前先计算整个文件的MD5然后把这个哈希值随第一个请求发给后端。这个设计能带来两个能力。能力一秒传。后端收到文件MD5后先去自己的存储系统里检查是否已经存在同哈希文件如果存在直接返回“无需上传”前端连分块都不用发用户感觉瞬间完成。在汽车制造场景里同一款车型的检测视频结构高度相似产线上经常会重复上传相同零件编号的录像文件秒传能省下大量存储和带宽。能力二断点续传。有了文件级哈希后端就可以用这个MD5作为目录名来组织分块文件。前端在上传前先询问后端“这个文件传过吗传到第几块了”后端返回一个已上传分块的索引集合前端跳过这些分块只补传剩下的这就是断点续传的实现基础。计算文件MD5在Vue里我常用SparkMD5import SparkMD5 from spark-md5; function calcFileHash(file) { return new Promise((resolve, reject) { const chunkSize 2 * 1024 * 1024; const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let index 0; const loadNext () { const start index * chunkSize; const end Math.min(start chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); }; reader.onload (e) { spark.append(e.target.result); index 1; if (index Math.ceil(file.size / chunkSize)) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror (e) reject(e); loadNext(); }); }有一个实操心得必须写在这里全量计算MD5非常耗时。一个1GB的文件用主线程跑这个计算浏览器可能要卡住十几秒甚至更久肉眼可见的“白屏”。所以我个人在真实项目中会做一个权衡——对超过200MB的文件改用抽样计算比如每块取首尾各256KB参与哈希计算虽然理论上哈希冲突概率会略微上升但实测性能提升非常明显配合文件大小、文件名等元信息一起校验误判风险完全可接受。DEMO里为了严谨我保留了全量计算但建议在Web Worker里执行别卡UI。2.3 并发控制与进度计算分块是串行切出来的但上传不能串行否则1GB文件切成512块2MB的一块一传一个Promise半天传不完。我见过不少初级方案直接Promise.all把全部分块一起发出去这在浏览器并发连接数限制下同域一般是6个左右不仅不会更快反而会让大量请求排队甚至把后端的连接池打满。正确的做法是维护一个“并发池”同时最多发N个请求发完一个就从队列里补一个。这个N在工厂内网环境我通常设3到5公网设2到3。核心逻辑如下async function uploadChunksWithLimit(chunks, limit 3, requestFn) { const queue chunks.filter(c !c.uploaded); let index 0; async function worker() { while (index queue.length) { const chunk queue[index]; index 1; try { await requestFn(chunk); hasUploadedCount.value 1; progress.value Math.round( (hasUploadedCount.value / chunks.length) * 100 ); } catch (err) { failList.value.push(chunk.chunkIndex); } } } const workers Array.from({ length: limit }, () worker()); await Promise.all(workers); }进度计算这里要注意不要直接用某个分块的onUploadProgress去算总进度因为并发时多个请求同时回调会导致进度值来回跳。我上面这种“每成功一个分块就累加计数”的做法虽然颗粒度粗一点但显示稳定不闪烁用户体验反而更好。如果UI上想体现“某一块正在上传”的细节可以单独给每个分块维护一个状态表格展示但不参与总进度条的计算。3. Vue DEMO的完整实现3.1 起步项目初始化与依赖这个DEMO基于Vue 3 Vite Axios SparkMD5后端我模拟了一套Spring Boot接口。先初始化项目npm create vitelatest car-upload-demo -- --template vue cd car-upload-demo npm install axios spark-md5目录结构不用太复杂我一般这样组织src/ pages/UpLoad.vue # 上传页面 utils/fileChunk.js # 切片与哈希工具 utils/uploader.js # 上传调度核心 api/uploadApi.js # 后端接口封装这里提醒一下Vite和Vue版本直接使用Vite模板默认生成的Vue 3.4即可不要手动降级版本Axios 1.6对浏览器兼容性更好。3.2 核心代码切片、哈希与上传调度上传页面的核心逻辑封装在uploader.js里我把完整流程串起来import { calcFileHash, createChunks } from ./fileChunk; import { uploadApi } from ../api/uploadApi; export async function uploadFile(file, options) { const { onProgress, onSuccess, onError } options; // 1. 计算文件哈希 const fileMd5 await calcFileHash(file); const chunkCount Math.ceil(file.size / CHUNK_SIZE); // 2. 初始化上传让后端校验秒传/获取已上传分块列表 const initRes await uploadApi.initUpload({ fileName: file.name, fileSize: file.size, fileMd5, chunkTotal: chunkCount }); if (initRes.data.instantSuccess) { onProgress(100); onSuccess(initRes.data); return; } // 3. 切片并上传 const chunks createChunks(file); const uploadedChunks new Set(initRes.data.uploadedChunks || []); chunks.forEach(c { if (uploadedChunks.has(c.chunkIndex)) { c.uploaded true; } }); await uploadChunksWithLimit(chunks, 3, async (chunk) { const formData new FormData(); formData.append(fileMd5, fileMd5); formData.append(uploadId, initRes.data.uploadId); formData.append(chunkIndex, chunk.chunkIndex); formData.append(chunkData, chunk.chunkData); await uploadApi.uploadChunk(formData); }); // 4. 通知后端合并所有分块 await uploadApi.mergeUpload({ uploadId: initRes.data.uploadId, fileMd5, fileName: file.name }); onProgress(100); onSuccess({ fileMd5 }); }uploadApi.js里对应的是axios封装import axios from axios; const client axios.create({ baseURL: /api }); export const uploadApi { initUpload: (data) client.post(/upload/init, data), uploadChunk: (formData) client.post(/upload/chunk, formData), mergeUpload: (data) client.post(/upload/merge, data) };3.3 后端接口约定Spring Boot侧的三个端点虽然这是个Vue DEMO但前后端接口咬合必须说清楚否则你看完前端代码照样跑不通。我按Spring Boot常见的controller写法给出接口约定重点是字段语义统一。第一个是初始化接口POST /api/upload/init。请求体里的fileMd5是前端算出来的文件级哈希chunkTotal用于后端校验分块数量。响应体设计成{ code: 0, data: { uploadId: uuid-123, instantSuccess: false, uploadedChunks: [0, 1, 2] } }如果instantSuccess为true说明后端已有同哈希文件前端什么都不用传了。uploadedChunks是已收到的分块索引列表前端据此跳过。第二个是分块上传接口POST /api/upload/chunkmultipart表单里必须有uploadId、chunkIndex、chunkData三个字段。后端收到后把这块数据写到以uploadId命名的临时目录下文件名就叫chunk_0.bin、chunk_1.bin这种便于最后按索引排序合并。第三个是合并接口POST /api/upload/merge。后端拿到uploadId后检查当前已收到的分块数量是否等于初始化时的chunkTotal相等则按索引顺序用FileOutputStream或Files.write逐块拼接成完整文件。注意合并时不能用一个空文件从头追加到底因为并发上传的分块可能乱序必须先按chunkIndex排序再合并。3.4 断点续传与秒传的实战落地光有上面三步遇到一个生产环境最常见的问题就没招了用户传了一个500MB的文件传到40%时浏览器崩溃重新打开页面再来一次难道从第一块开始重传肯定不行。我在DEMO里加了两个持久化能力。一是把文件MD5和分块上传进度存入localStorage再次选择同一文件时先查缓存命中则跳过已上传分块。二是更可靠的做法依赖后端返回的uploadedChunks——后端每次收到一个分块都会落盘所以它的记录比前端本地缓存更可信。前端初始化时把后端返回的已传分块集合维护好跳过这些块只传缺失部分这就是断点续传。为了伪装“断点”还可以刻意在DEMO里加一个测试按钮模拟上传中断。做法很简单在上传过程中随机把某个分块标记为失败然后重新调度时只补传失败块观察日志里是否只重传了那一块。用这种方式验证断点续传比手动断网方便得多。4. 实测结果与性能表现4.1 我这边实测的数据我在本地用Vue 3 Spring Boot 3 MySQL其实合并存储用磁盘就够MySQL只是记录元数据跑了一轮简单压测文件选择的是1.2GB的产线监控视频。硬件环境只是普通的公司办公机i5-12400 16GB内存内网千兆。当分块大小为2MB时总共切成600个分块并发数设为3。实测结果是完整上传耗时约85秒浏览器Network面板里请求全部返回200后端合并后生成的视频文件和源文件MD5完全一致。整个过程中Chrome主线程的FPS保持稳定没有明显卡顿。作为对比同样文件不分块整包上传时Nginx直接报413把Nginx的client_max_body_size调到2G后虽然能发出去但上传到一半时偶发的网络抖动导致请求失败前端没有任何续传逻辑只能重来。另一组数据是关于哈希计算的。1.2GB文件全量计算MD5在SparkMD5 FileReader的主线程方案下耗时约18秒期间页面滚动和点击几乎无响应。改成抽样计算后每块首尾各256KB参与计算耗时降到2秒以内UI完全流畅。这个对比非常直观也是为什么我强烈建议生产环境谨慎使用全量哈希。4.2 分块大小怎么选分块大小这个参数没有标准答案我根据自己的项目经验给一个参考逻辑。分块太小比如512KB会导致分块数量巨大。一个1GB文件要切2048块每块请求都有HTTP头、日志、数据库写操作等固定开销后端的压力会非常大甚至出现合并时文件描述符不够的问题。分块太大比如50MB又回到大请求的覆辙Nginx虽然能放行但一个分块传一半断线了重传成本很高而且后端合并时单块写入的时间太长。我个人的经验规律是这样的网络环境推荐分块大小理由工厂内网/千兆局域网5MB ~ 10MB带宽充足分块大一些能减少请求数后端压力小公网/跨区域传输1MB ~ 2MB公网抖动频繁小分块单次失败成本低续传粒度更细混合办公场景2MB ~ 5MB兼顾速度和稳定性推荐DEMO默认2MB的折中方案另外还要结合后端线程池和数据库连接池来看并发数乘以单块大小就是瞬时吞吐量。比如并发3、分块5MB瞬间就要往后端写15MB数据后端落盘速度如果跟不上请求就会堆积排队。所以分块大小和并发数是联动调优的不是单独拍板。5. 常见问题与排错实录5.1 高频问题速查表我把团队里实际遇到过的、以及社区里高频出现的问题整理成一张表方便你直接对号入座。问题现象可能原因解决办法后端收到分块后拼接出的文件损坏/打不开合并时未按chunkIndex排序或分块序号错乱合并前按索引排序并校验分块数量是否等于chunkTotal上传过程中页面无响应MD5计算在主线程执行阻塞了UI渲染把哈希计算放到Web Worker或改用抽样哈希Nginx返回413client_max_body_size限制调大Nginx配置但更彻底的办法是走分块方案让单块远低于限制一个分块失败后所有分块全部重传前端没有持久化已传分块状态初始化时请求后端uploadedChunks跳过已传分块Promise.all发送几十个并发请求并发数没控制改为固定并发池限制同时上传的分块数量3~5个上传进度条反复横跳多个分块同时回调进度导致计算混乱改用“成功块数/总块数”计算方式大文件切块后内存飙升FileReader一次性读取了整块数据保持2MB~10MB的单块大小正常不会爆内存检查是否误将全文件读入部分老浏览器不支持File.slice工控机可能还是老版本Chrome或360兼容模式判断浏览器能力不支持则降级为整包上传或提示更换浏览器5.2 两个让我印象深刻的坑第一个坑是合并顺序与数据库状态不一致。早期我做的DEMO后端数据库里记录“分块已上传”是在接收分块的接口里同步写的但由于前端是并发发送后端的日志记录顺序和分块实际写入磁盘的顺序不一致导致判断已传分块集合时数据库里出现了一部分但磁盘上还没有的情况。后来我在接收分块接口里改成“先写磁盘再写数据库”而且以磁盘上的文件名为准合并前做一次磁盘扫描来核对彻底解决了这个问题。第二个坑是FormData里字段顺序对后端解析的影响。有些后端框架对multipart的字段解析有顺序要求比如某些老版本的第三方组件会把chunkIndex读成字符串再强转数字遇到非数字格式就报错。我在前端里始终保证一个固定顺序先传文本字段再传文件字段即fileMd5、uploadId、chunkIndex在前chunkData最后。这个习惯帮我排掉了很多莫名其妙的400错误。5.3 给汽车制造场景的几个实用建议如果这个DEMO真的要放进MES或者质量追溯系统我建议再补三件事。第一上传前先做文件类型白名单校验工厂里有保密要求的图纸格式尤其要注意避免员工把无关的大文件往系统里堆。第二上传完成后立刻计算合并文件的MD5和前端提交的文件哈希比对确保传输过程没有损坏数据。这在汽车质量追溯里是硬性要求因为检测录像可能作为责任认定的证据。第三给上传过程加一个“审计日志”记录操作人、工位、文件名、大小、哈希、开始和完成时间方便后续追查。另外提醒一点汽车行业偶尔会有老旧的Windows工控机浏览器可能还是IE内核或者老版本ChromeFile.slice、FormData、Promise这些API在那些环境里可能不全上生产系统前务必做好兼容性降级方案。我心里对这个DEMO的定位很清楚它不是一个教科书式的玩具而是一个能跑通、能解释、能移植到真实项目里的分块上传骨架。如果你正在做的恰好也是Spring Boot Vue的项目把这三个后端接口按我这里说的对齐前端这套切片和并发逻辑直接拿过去就能用。面试里如果再被问到“大文件上传怎么做”你把这套的细节讲清楚——为什么切片、为什么算哈希、并发数怎么控制、断点续传怎么实现——基本就是标准的加分答卷。
阅读完成 · 觉得有帮助?
咨询建站