ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue大文件上传组件实战:切片上传、断点续传与Web Worker哈希计算

Vue大文件上传组件实战:切片上传、断点续传与Web Worker哈希计算 保险行业做前端节奏和普通互联网业务确实不太一样。最近接手一个理赔系统改造查勘员在事故现场拍的高清视频、定损照片动不动就是几百 MB原来用的 el-upload 直传方案在弱网环境下几乎不可用传到一半断了网就要全部从头再来业务方被投诉到没脾气。这个任务最终落成了一套 Vue 大文件上传组件切片上传、断点续传、Web Worker 算哈希、并发控制全给实现了配套 DEMO 也跑通了从选择文件到后端合并的完整链路。这篇文章把组件的设计思路和实际踩坑记录整理出来给同样在保险行业做前端、或者正在被大文件上传折磨的同学一个可以参考的范本。1. 保险业务里的上传需求到底难在哪1.1 那些被“大文件”卡住的真实场景保险行业说的“大文件”和视频网站、云盘产品的“大文件”不太一样它的特征很鲜明一是来源分散二是网络不可控三是业务不能断。最常见的是车险查勘场景。查勘员到事故现场要录制事故全貌、碰撞痕迹、路面状况现在手机默认 4K 拍摄一小段视频就是 200MB 起步一个案件通常要录好几段。这些视频要回传后台供定损员、核赔员远程查看等于是整个理赔流程的第一环。然后是寿险投保和医疗理赔。客户需要上传身份证扫描件、体检报告、病理影像、发票清单这些材料以高分辨率图片和 PDF 为主单张原图 810MB一个案件凑起来上百 MB 很常见。客户的上传环境就更复杂了手机型号各不相同网络可能是 5G也可能是地下车库里的弱信号甚至有一边排队一边传的。这些场景共同指向一个问题上传不是可有可无的附加功能而是核心业务链路的关键节点。文件传不上去理赔单就卡在系统里拖一天就是一天的客户投诉和业务损失。所以一套能应对弱网、大文件、断点续传的上传组件对保险业务来说是刚需。1.2 一套合格的大文件上传组件要解决什么问题把需求拆开之后我给自己列了四条验收标准这也是后面所有设计决策的判断依据。第一条是可靠性优先。弱网环境下传一半断了必须能从断掉的位置继续不能要求用户从头再来。这条标准直接决定了方案必须走分片上传而不是简单地把一个 Blob 扔给服务器。第二条是体验可感知。上传过程中要有实时、准确的进度反馈哪怕网络很慢用户也要能直观看到“它在动”而不是对着一个卡住的进度条发呆。保险业务的用户很多不是年轻互联网用户他们对“系统是不是坏了”非常敏感。第三条是不阻塞、不卡死。上传过程中用户还得继续编辑案件信息、填写备注、切换页面页面不能因为算文件指纹就整个僵住。这一条决定了必须引入 Web Worker。第四条是全链路留痕。保险行业对业务档案有明确的审计要求谁传的、什么时候传的、传的什么文件、文件有没有被篡改都要有记录。这决定了上传组件不只是一个 UI 组件还要约定好文件哈希、上传记录这些配套数据。2. 整体设计与方案选型2.1 为什么选切片上传而不是直传先看直传方案的问题。一个 500MB 的文件通过一次 HTTP 请求直接传给后端弱网环境下出错的概率会随着文件大小直线上升。一旦连接断开已经传出去的字节全部作废用户只能重新选文件再来一遍。更麻烦的是很多服务端网关和反向代理对请求体的大小有硬限制比如 Nginx 的 client_max_body_size 默认只有 1MB不改配置的话大文件请求直接在网关层就被拒绝了。切片上传把这些大问题拆成了小问题。前端用 Blob 的 slice 方法把文件切成一串几 MB 的分片每个分片独立走一次 multipart 请求服务端按顺序暂存最后再合并。这个方案的好处有三个单次请求体积可控能轻易绕过网关限制失败粒度缩小到一个分片重传成本极低多个分片可以并发上传充分利用网络带宽同一份弱网也能明显提升吞吐量。分片大小怎么定是一个需要权衡的选择。我实测下来5MB 是一个兼容性很好的默认值。分片太小比如 1MB一个 2GB 的文件就要产生两千多个请求HTTP 连接和请求头的开销会吃掉大量性能服务端小文件操作的 IO 压力也大。分片太大比如 20MB单个请求的传输时间变长失败重传一次就要付出更高的代价。如果业务明确是视频类大文件且网络条件比较好可以放宽到 10MB。2.2 为什么计算文件哈希要交给 Web Worker文件上传前算一个哈希值是整套设计里绕不开的一步。哈希最主要的用途是给文件一个唯一标识续传时靠它找到之前传过的分片秒传时靠它判断服务器上是不是已经存在相同文件。常用的做法是算整个文件的 MD5。但 MD5 的计算是典型的 CPU 密集型任务。以 500MB 的视频文件为例在主线程里跑可能要花十几秒甚至更久。这期间用户点任何按钮都没反应页面上轮播的动画也会卡住体验非常糟糕。有人会说那我用 requestAnimationFrame 分片算边算边让出主线程思路是对的但实现复杂而且文件读取的异步回调本身就有很大的不可控性。更干净的方案是交给 Web Worker。Worker 运行在独立的线程里和主线程通过 postMessage 通信文件分片读进来、算完哈希、再把结果传回去整个过程主线程完全不受影响。用户在上传前填单子、切切换页面都不会卡。计算过程中的进度也可以实时传回主线程显示在界面上。这里有一个细节值得注意算全文件哈希需要把所有分片都读一遍。所以我在实现里复用了切片后的分片列表Worker 按顺序读取每个分片的 ArrayBuffer 并增量更新 MD5而不是把整个文件一次性读进内存。这样无论文件多大Worker 占用的内存都控制在分片大小量级不会出现大文件直接吃爆内存的问题。2.3 组件技术栈与目录结构技术选型上我用了 Vue 3 Vite Composition API请求库是 axios哈希计算用 SparkMD5。为什么用组合式 API 而不是选项式这个可以多说一句。上传组件的状态机很复杂空闲、校验中、上传中、暂停、取消、完成、失败还有并发池、重试逻辑、进度累计这些逻辑要组织。组合式 API 允许我把这些逻辑按职责拆成一个个可复用的函数而不是散落在 data、methods、watch 各个选项里。对于这种逻辑密度高的组件组合式的可维护性优势非常明显。目录结构我按模块拆分让组件的职责一眼能看清楚src/ ├── components/ │ └── InsuranceUploader/ │ ├── index.vue // 组件模板与状态绑定 │ ├── useUploader.js // 上传状态机与主流程编排 │ ├── slice.js // 文件切片工具 │ ├── uploadClient.js // 分片请求、并发池、重试逻辑 │ └── workers/ │ └── hash.worker.js // 哈希计算 Worker拆成这样之后单个文件的行数都不多调试时也能快速定位问题是在 UI 层、流程层还是网络层。这个结构也方便后续扩展比如以后要接入腾讯云、阿里云的分片上传 SDK只需要替换 uploadClient.js 内部实现组件外面完全不用动。3. 核心实现一步步搭出上传流程3.1 文件选择与前端切片入口部分没有用现成的上传组件而是做了一个可点击、可拖拽的 drop zone 加上隐藏在背后的 input。这样一个裸的入口比套一层 el-upload 更灵活后面要加文件夹上传、摄像头采集之类的扩展也不会被组件束缚。文件选进来之后第一步是切片。核心代码很简单const CHUNK_SIZE 5 * 1024 * 1024 // 5MB function createChunks(file) { const chunks [] let start 0 while (start file.size) { chunks.push({ index: chunks.length, file: file.slice(start, start CHUNK_SIZE), }) start CHUNK_SIZE } return chunks }这里有一个很多人会误解的点file.slice 并不会真的把文件内容拷贝一份到内存里它只是创建了一个指向文件某个区间的 Blob 对象。所以一个 2GB 的文件切片后内存占用并不会变成 2GB分片列表里存的是一堆轻量的引用。真正按需读取内容是后面发送请求和计算哈希的时候这在一开始就避免了内存爆掉的风险。切片的同时我还会做一次文件类型和大小校验。保险业务场景下这个校验尤其重要因为用户上传的必须是保单、发票、视频这些指定类型不能传可执行文件。我用一个白名单数组控制 accept 属性并在 change 事件里再校验一次 MIME 类型避免有人绕过 UI 限制。3.2 Worker 里算哈希避免页面卡死切片完之后进入校验阶段也就是计算文件哈希。Worker 这边的实现是这样的// workers/hash.worker.js import SparkMD5 from spark-md5 self.onmessage (event) { const { chunks } event.data const spark new SparkMD5.ArrayBuffer() const reader new FileReader() let current 0 const readNext () { reader.readAsArrayBuffer(chunks[current].file) } reader.onload (e) { spark.append(e.target.result) current 1 if (current chunks.length) { self.postMessage({ type: progress, value: current / chunks.length, }) readNext() } else { self.postMessage({ type: done, hash: spark.end(), }) } } reader.onerror (e) { self.postMessage({ type: error, message: 文件读取失败 }) } readNext() }主线程这边的调用封装成一个 Promise这样上游流程可以直接 awaitimport HashWorker from ./workers/hash.worker?worker function computeFileHash(chunks) { return new Promise((resolve, reject) { const worker new HashWorker() worker.postMessage({ chunks }) worker.onmessage (e) { if (e.data.type progress) { hashProgress.value e.data.value } else if (e.data.type done) { worker.terminate() resolve(e.data.hash) } else if (e.data.type error) { worker.terminate() reject(new Error(e.data.message)) } } worker.onerror (err) { worker.terminate() reject(err) } }) }提示Vite 里用?worker后缀导入 Worker 文件会帮我们做打包和代码分割生产环境会自动生成独立的 Worker 脚本文件。如果用 webpack可以用new Worker(new URL(...))的方式效果类似。这个阶段界面上会显示“正在校验文件”的进度。实测下来200MB 文件在普通笔记本上全量 MD5 大约是两到三秒1GB 文件十几秒因为有进度提示用户并不会觉得是卡住了。3.3 multipart 分片上传与并发控制分片上传在 HTTP 协议层面没有任何特殊机制走的就是最普通的 multipart/form-data 文件上传。浏览器在构造 multipart 请求时会自动生成一个随机的 boundary 字符串把表单字段和文件二进制数据用 boundary 分隔组织成一个请求体。我们在前端不需要手动拼 boundary也不需要关心分隔符的细节只需要构造 FormData 并 append 对应的字段和 Blobaxios 会自动设置 Content-Type 为 multipart/form-data并在发送时完成消息体组装。单个分片的上传请求async function uploadChunk(chunk, fileHash) { const formData new FormData() formData.append(file, chunk.file) formData.append(hash, fileHash) // 文件唯一标识 formData.append(index, chunk.index) // 分片序号 const { data } await axios.post(/api/upload/chunk, formData) if (data.code ! 0) { throw new Error(data.message) } return data }这里有一点要特别说清楚分片不是一片一片串行传的那样在弱网下太慢了。也不是所有分片一次性全部并发几十个请求同时打过去服务端和带宽都扛不住。正确做法是控制并发数让同时在途的请求保持在一个合理的数量。我常用的是一个手写的并发池因为逻辑直白方便在 DEMO 里看到全貌async function runWithConcurrency(tasks, limit) { const executing new Set() for (const task of tasks) { if (executing.size limit) { await Promise.race(executing) } const promise task().finally(() { executing.delete(promise) }) executing.add(promise) } await Promise.all([...executing]) }配合失败重试每个分片最多重试三次重试间隔按 1 秒、2 秒、4 秒递增async function uploadWithRetry(chunk, fileHash, retries 3) { for (let attempt 0; attempt retries; attempt) { try { return await uploadChunk(chunk, fileHash) } catch (err) { if (attempt retries) throw err await sleep(1000 * (attempt 1)) } } }注意分片上传接口必须是幂等的同一个分片重复传两次服务端不能报错。处理方式很简单——服务端按 hash index 命名分片文件已经存在就直接返回成功。这个约定是断点续传和失败重试能安全工作的前提。3.4 进度计算、暂停、取消与断点续传进度是最容易做错但又最影响体验的部分。我见过很多人按“已成功分片数 / 总分片数”来算进度这样在前几个分片上传时进度条会长时间停在一个值不动然后突然跳一截观感很差。更好的做法是按字节算进度。每成功上传一个分片就把这个分片实际的字节数累加到 uploadedBytes 里然后const percent (uploadedBytes / file.size) * 100这样进度条是连续平滑增长的即使某个分片因为重试耗时较长已经传成功的字节也都被记录进去了不会出现“明明传了一半却显示 0%”的错觉。暂停和取消是两个容易被混淆的操作。暂停是暂时停止发送新的分片但正在传输的请求要等它收尾因为强行中断可能会让服务端留下半个分片取消则是彻底终止本次上传已经传上去的分片要被清理掉。暂停的实现只需要一个标志位加上等待在途请求结束async function pauseUpload() { paused true await Promise.all([...executing]) }取消则会调用 AbortController 中断所有在途请求并通知后端清理该 hash 下已经暂存的分片。断点续传是本组件的核心价值所在。实现思路是文件重新选中后先重新计算 hash然后向服务端查询这个 hash 已经传过哪些分片async function resumeUpload() { const { data } await axios.get(/api/upload/status, { params: { hash: fileHash }, }) const uploadedSet new Set(data.uploaded.map((item) item.index)) const remaining allChunks.filter((chunk) !uploadedSet.has(chunk.index)) await runWithConcurrency( remaining.map((chunk) () uploadWithRetry(chunk, fileHash)), CONCURRENCY ) }这样断网之后用户重新选同一个文件只需要补传缺失的分片即可不需要全部重传。我还额外做了 localStorage 缓存把最近一次未完成上传的文件名、hash、大小记下来用户刷新页面后能一键续传不需要重新选文件。4. 配套接口约定与 el-upload 集成4.1 后端接口设计init、chunk、merge 三段式前端做得再完善没有后端接口配合也跑不通。接口不需要多复杂四个就够了。接口方法用途关键参数/api/upload/initPOST上传前预检判断能否秒传、返回已传分片列表hash, fileName, size/api/upload/chunkPOST上传单个分片file, hash, index/api/upload/statusGET查询某文件已上传的分片hash/api/upload/mergePOST所有分片上传完成后触发服务端合并hash, fileName, totalChunks约定一个统一的响应格式能省掉大量联调成本。我习惯用{ code: 0, data: ... }code 为 0 表示成功非 0 是业务错误码// init 响应 { code: 0, data: { exists: false, uploaded: [0, 1, 2] } }服务端分片存储和合并的逻辑可以看这个简化版参考// 分片存储按 hash 建目录分片文件名为序号 app.post(/api/upload/chunk, upload.single(file), async (req, res) { const { hash, index } req.body const chunkDir path.join(UPLOAD_DIR, hash) await fs.promises.mkdir(chunkDir, { recursive: true }) await fs.promises.rename(req.file.path, path.join(chunkDir, String(index))) res.json({ code: 0, data: { index: Number(index) } }) }) // 合并按序号顺序读取分片写入最终文件 app.post(/api/upload/merge, async (req, res) { const { hash, fileName, totalChunks } req.body const chunkDir path.join(UPLOAD_DIR, hash) const finalPath path.join(FINAL_DIR, fileName) const writeStream fs.createWriteStream(finalPath) for (let i 0; i totalChunks; i) { const data await fs.promises.readFile(path.join(chunkDir, String(i))) writeStream.write(data) await fs.promises.unlink(path.join(chunkDir, String(i))) } writeStream.end() res.json({ code: 0, data: { url: /static/${fileName}, size: req.body.size } }) })需要注意服务端在 merge 时一定要校验分片数量是否完整并且最好能严格按 index 顺序写入否则合并出来的文件是乱序的解码时会报错。4.2 秒传与续传的原理秒传不是魔法它依赖的是文件的哈希值。用户选择一个文件后前端算出 MD5调用 init 接口预检服务端返回这个 hash 在存储里是否已经存在。如果存在且状态是已合并直接告诉前端“文件已存在无需上传”这就是秒传。如果文件不存在或者只有部分分片服务端会返回已上传的分片索引列表。此时前端只需要补齐缺失的分片这就是续传。补完之后调用 merge服务端按顺序把分片拼成一个完整文件。有一个情况要提前想到如果用户选了一个早已传过的文件但业务上需要生成一个新的附件记录那么秒传可以被设计成“不传文件体但创建一条新的文件元数据记录”。这样既省了带宽又满足了业务对每次上传留痕的要求。4.3 在 el-upload 上接自定义上传逻辑很多现有的后台管理项目用了 Element Plus 的 el-upload并不想因为引入大文件方案而替换整套 UI。el-upload 提供了 http-request 方法允许完全覆盖默认的上传行为。这是侵入性最小的集成方式。el-upload :http-requesthandleCustomRequest :limit1 :on-exceedhandleExceed accept.pdf,.jpg,.png,.mp4,.mov el-button typeprimary选择理赔附件/el-button /el-uploadasync function handleCustomRequest(options) { try { const result await uploadLargeFile(options.file) options.onSuccess(result) } catch (err) { options.onError(err) } } function handleExceed(files, fileList) { // 业务要求只能传一个附件新文件直接替换旧文件 fileList.splice(0, fileList.length) fileList.push(files[0]) }这里顺便说一句“限制只能上传一个附件”的坑。很多人会在 before-upload 里return false来试图阻止第二个文件但这样拦截的是还没开始的文件用户已经选中的文件并不会按照预期被替换。正确做法是配合 on-exceed 手动维护 fileList先清空再 push让列表中永远只有一个待上传项。把大文件上传逻辑放进 http-request 之后el-upload 自带的文件列表、删除、重传这些交互依然保留体验上是无缝衔接的。5. 实测中的常见问题与排查经验5.1 上传超时、网络请求错误的定位方法联调期间遇到最多的报错就是“上传失败: 网络请求错误”这类问题表象一致根因却五花八门。我把实际遇到过的几种情况列出来可以照着一项项排查。第一网关或代理对请求体大小做了限制。虽然分片已经控制了单次请求的大小但如果浏览器头部信息异常膨胀比如某些实现把整个文件先转成 base64 再塞进 JSON请求体大小会直接失控网关收到超限请求多半直接断开表现为网络请求错误。排查方法是打开 Network 面板看具体请求的传输大小和状态码如果请求体里能看到一长串 base64 字符基本就是这个问题的实锤。第二axios 默认没有超时时间。弱网环境下一个分片请求可能挂起好几分钟才报错用户和日志都很难接受。我给所有分片请求设置了 30 秒超时超过就触发重试。超时类错误和网络断连类错误要分开处理前者走重试逻辑后者则要提醒用户检查网络后手动续传。第三CORS 配置不完整。分片上传请求带上了 Content-Type 和自定义 Header跨域的预检请求必须被后端正确处理。常见的问题是后端只允许了 GET没有放行 OPTIONS导致实际 POST 请求永远发不出去。第四服务端 body 解析限制。部分框架默认的 bodyParser 有体积限制超过就返回 413。分片大小定在 5MB 时通常不会触到这道线但如果你把分片调大比如 50MB就要同步检查服务端的解析限制。5.2 并发参数怎么调、失败重试怎么设计并发数是最需要结合实际环境调的参数。我给的默认值是 3在办公网、稳定的宽带上可以调到 5在移动端建议 23。为什么不是越多越好因为网络连接的质量是波动的并发过高会导致多个请求互相争抢带宽出现谁都传不完的局面反而拖慢整体速度。服务端也要考虑。重试机制的坑在于“重试风暴”。如果没有间隔地连续重试某个分片一旦遇到服务端抖动几十个重试请求会同时打过去把小故障放大成大故障。我用的策略是递增间隔重试第一次失败等 1 秒、第二次等 2 秒、第三次等 4 秒超过三次就放弃该分片并标记上传失败交给用户决定是重试还是取消。重试还要注意幂等性这个我在前面强调过了。如果服务端在分片已存在时返回错误一切重试策略都白搭所以接口设计阶段就要把“同分片可重复上传”作为硬性约定写进联调文档。5.3 内存与性能优化的几个细节大文件组件稍不注意就会把浏览器内存吃出问题这几个细节我都是踩过坑才重视起来的。不要用 FileReader 一次性读取整个文件。有人为了算 hash先reader.readAsArrayBuffer(file)以为这样最简单。这个操作会把整个文件内容加载进内存2GB 的视频直接让浏览器崩溃。正确的做法是只读取分片Worker 里一段一段读完就释放内存占用始终有界。不要把分片内容提前都塞进内存。一些写法喜欢先读出一个 ArrayBuffer 数组再排队上传。分片总数一多这个数组加起来就是整个文件的大小内存瞬间爆掉。分片之间在内存里传递的是 Blob 引用真正的二进制数据只在发送和读取时才产生副本。进度刷新要做节流。Worker 里每读一个分片就 postMessage 一次主线程如果每次都去更新 DOM在大文件场景下会造成没必要的布局抖动。可以简单加一个节流函数让 progress 更新频率控制在每秒十几次以内。Worker 也要及时回收。每次算完哈希调用 worker.terminate()否则每次上传都会新开一个线程页面开久了线程数会累积起来。这一点在长时间运行的 SPA 后台系统里尤其重要。6. 从 DEMO 走向保险生产环境还差哪些事6.1 合规、安全与审计不能省DEMO 跑通了只是第一步。保险业务对文件上传有明确的合规要求上生产之前还需要补齐几件事。文件类型要用白名单校验而不是黑名单。保险公司上传的材料是可执行程序背后是安全事件在服务端也要再次校验文件的实际 MIME 类型不能只信任前端的 accept 属性。文件存储要有访问控制。理赔材料属于客户敏感信息不能放到公开静态目录应该存在私密存储区通过签名 URL 做临时鉴权访问指定有效时间和访问次数。审计日志要完整。每一个附件都应该记录操作人、案件号、文件名、大小、哈希值、上传时间、来源 IP。这样做的好处是当业务纠纷发生时可以完整还原文件上传链路这对保险公司来说几乎是必须的。6.2 后续可以怎么扩展这套组件做完之后如果继续往下走我会优先做两件事。一是对接对象存储的分片上传能力把分片直接传到对象存储服务端只负责生成凭证和记录元数据这样能大幅降低自建服务的带宽和存储压力。二是向前端加一层文件后处理编排比如视频转码、图片压缩、PDF 加水印上传完成之后自动触发让业务方拿到的永远是统一规格的文件。从接到需求到跑通 DEMO我花了大概两天时间真正上线前的后端合并、鉴权、审计又迭代了两周。个人的体会是大文件上传这个功能前端只是入口真正的难点在前后端约定、网络容错、服务端合并这些细节上。如果你也在做类似的东西可以照着这套设计先把链路跑通再结合自己公司的服务器和网络情况去调参数。很多东西不亲自传一次 1GB 的文件光看文档是体会不到的。
返回列表