保险网页项目里大文件上传一直是个让前端头疼的事。理赔单证照片、医疗影像、事故现场视频这些附件随便一个就有几十MB到几百MB网络稍微一波动整个上传就废了客户那边体验差不说客服电话都得被打爆。我去年接手一个保险理赔后台系统前台上传模块要处理的就是这类大文件浏览器原生上传根本扛不住所以才下定决心做分片上传加断点续传。这篇内容就把我在这套系统里用JavaScript实现分片上传及断点续传的完整思路、核心源码和踩坑过程讲透适合正在做大文件上传功能的前端同学参考不管你是原生JavaScript、Vue还是React核心逻辑都是同一套。1. 保险业务场景剖析大文件上传为什么绕不开分片1.1 保险场景的文件特征决定了技术选型保险行业的上传场景和普通社区、电商完全不一样。理赔报案时要传的材料五花八门门诊病历、CT影像、费用清单、行驶证照片、事故现场记录视频甚至有些机构会上传手术录像。这些文件有几个共同特征单个体积大、数量多、来源设备复杂。手机拍的视频动不动几百MB扫描仪产出的PDF也能轻松突破百兆。而这些文件的客户往往在各种网络环境下操作办公楼光纤、家庭宽带、户外4G信号甚至地下车库的边缘信号都有可能出现。普通的上传方式处理这类文件会面临几个致命问题一是HTTP请求对超时时间有严格限制单个上传请求体量大了以后网络稍有抖动就容易中断二是中断后没有恢复能力整个文件要重新上传几百MB的数据在弱网环境下重传既是资源浪费也是时间灾难三是浏览器层面的大文件一次性读取会直接撑爆内存导致页面卡死甚至浏览器崩溃。分片上传方案在这三个维度上都给出了解法把大文件切成小块逐块上传每块独立请求互不干扰天然适配弱网环境。1.2 分片上传和断点续传的关系分片上传解决的是大文件拆分与重组的问题断点续传解决的是中断后如何恢复的问题两者是相辅相成的关系。分片上传把文件切成N个chunk之后每个chunk由独立的HTTP请求发送到服务端服务端暂存所有分片在最后一个分片上传完成时触发文件重组。断点续传的实现基础是把哪些分片已经上传成功这个状态持久化下来当上传中断后重新发起上传时先去服务端查一下已上传分片的列表跳过这些分片只补传剩余部分。在一个保险理赔场景里客户在4G信号弱的地方传一个200MB的事故视频传到一半断了如果没有断点续传他要么重新扫码再传一次要么只能放弃上传转人工理赔这两种情况都是业务上不能接受的。有了断点续传重新打开页面后检测到之前的上传任务还在自动从断点继续剩下的几十MB几分钟就传完客户无感知地完成了整个提交流程。1.3 技术方案抉择原生手写还是第三方库我这里先给个结论像这种面向生产环境的业务系统我建议核心逻辑自己手写不要盲目依赖第三方组件。市面上确实有成熟的库比如WebUploader、plupload这类老牌方案但实际接入保险类项目时你会发现它们太笨重、UI锁定严重、兼容性适配成本高。而一些轻量库虽然能处理分片但它们的设计场景偏向通用型上传对保险业务中的自定义需求、网络安全要求、审计日志等定制化能力跟不上。我最终选择了原生JavaScript来实现分片逻辑配一个必要的哈希计算库主要原因有三个一是保险系统通常有严格的安全审计要求上传参数、请求头、加密逻辑都要可控二是业务交互自定义程度高需要和内部的进度展示组件、风险提示弹窗紧密集成三是分片上传的核心逻辑本身并不复杂核心代码量其实没多少手写反而更容易掌控代码质量和排错。2. 整体架构设计与前端核心模块拆解2.1 上传全流程总览在动手写代码之前我把整个上传流程在前端模型里走了一遍梳理出六个阶段文件读取、参数准备、哈希计算、分片上传、进度汇总、合并触发。文件读取阶段把用户选择的文件对象拿到手参数准备阶段定义文件唯一标识、分片大小、总分片数等基础数据哈希计算阶段对文件内容生成一个MD5串作为服务端存储和校验的依据分片上传阶段用并发控制逐批发送chunk进度汇总阶段把已上传字节数实时反馈给UI合并触发阶段在最后一个分片上传成功后通知服务端做文件重组。这六个阶段里文件读取和参数准备相对简单哈希计算是性能瓶颈点分片上传是稳定性核心进度和合并是产品体验和业务闭环的关键。前端代码按模块拆分后每个模块的职责单一后面排查和扩展都会轻松很多。2.2 前端模块划分与核心数据模型模块拆分上我按职责划分了五个文件模块文件读取器、哈希计算器、分片管理器、上传执行器、状态存储。文件读取器负责File对象的解析和切片哈希计算器用增量方式计算文件MD5分片管理器维护chunk列表和上传状态上传执行器负责并发控制、HTTP请求、错误重试状态存储负责把上传进度和已上传chunk信息持久化到本地。核心的数据模型就一个const fileInfo { fileId: , // 唯一标识一般用MD5值 fileName: , // 原始文件名 fileSize: 0, // 文件总字节数 chunkSize: 2 * 1024 * 1024, // 每个分片大小2MB totalChunks: 0, // 总分片数 uploadedChunks: [], // 已上传的分片索引列表 status: pending // 状态pending / uploading / paused / done / error };fileId是整个分片上传和断点续传的核心纽带所有分片上传请求都要携带这个字段服务端通过它来区分不同的上传任务、存储不同分片、判断合并时机。fileId用文件内容的MD5生成这样同一个文件无论在哪台设备上传标识一致服务端也能据此实现去重和秒传功能。2.3 服务端接口契约设计前端要实现完整的断点续传离不开服务端的三个接口配合。第一个是分片上传接口接收multipart/form-data格式的分片数据存储后返回该分片的接收状态第二个是状态查询接口根据fileId返回已上传分片的索引数组第三个是合并接口通知服务端把已存储的分片合并为完整文件。除了这三个核心接口在保险业务场景下还可能加上文件元信息登记接口和上传任务过期清理接口但这些都属于服务端业务范畴。接口契约定了之后前端开发可以独立推进不需要等服务端代码就绪用Mock数据就能把整个流程先跑通。我在实际项目里就是这么做的先用本地Mock把前端逻辑全部验证完再和服务端做联调省了非常多时间。3. 分片核心逻辑实现切片、哈希与并发控制3.1 文件分片与分片大小选择文件切片靠的是浏览器原生提供的File.prototype.slice方法这个API在十年前就已经广泛支持了。传入起始和结束字节位置就能从文件里切出对应的Blob块而且切片过程本身是惰性读取的不会一口气把整个文件加载进内存这是分片上传在内存性能上优于普通上传的根本原因。分片大小怎么定这个参数直接影响上传的速度、并发效率和失败恢复粒度。我对比过1MB、2MB、5MB、10MB几个档位最终在保险项目中用了2MB。原因有三个第一2MB的分片在4G网络下单个请求的传输时间比较适中大约在三五秒内网络抖动丢包的概率低第二分片小了以后断点续传的恢复粒度更细比如一个80MB的文件在弱网下传到一半断了2MB分片是40个分片5MB分片是16个分片前者重传的损失更小第三服务端存储分片临时文件时小块分片在磁盘IO和校验逻辑上都更友好。1MB以下不建议因为分片数量过多会导致HTTP请求次数暴涨服务端处理开销增加。function splitFileIntoChunks(file) { const chunkSize 2 * 1024 * 1024; // 2MB const totalChunks Math.ceil(file.size / chunkSize); const chunks []; for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blobChunk file.slice(start, end); chunks.push({ index: i, blob: blobChunk, size: blobChunk.size, start: start, end: end }); } return chunks; }注意一个细节最后一个分片的size往往小于chunkSize上传时服务端可以根据最后一片的实际字节数来判断整个文件是否完整前端不应该手动补零或者做对齐保持原始切割就是最准确的。3.2 文件哈希计算的工程化实现给整个文件算一个MD5值是分片上传方案里比较费体力的一步。大文件的哈希计算不能把整个文件读进内存一次性计算而是要用增量方式每次读取固定大小的ArrayBuffer不断追加到哈希算法的上下文里直到读完整个文件。这一步对大文件来说可能花几秒甚至十几秒但它是值得的。为什么值得因为有了文件级MD5后服务端可以根据MD5判断这个文件是不是之前已经完整上传过如果是就直接返回已有的文件URL实现秒传功能。这在保险理赔场景里价值很大——同一个客户反复上传同一份病历扫描件的情况非常多有了秒传重复上传几乎零等待。计算MD5我用的算法库是SparkMD5它在浏览器端做了Web Worker适配性能比纯JS逐字节计算好很多。实际开发中我把哈希计算放到了Web Worker里执行避免主线程被阻塞导致页面卡顿。// hash-worker.js importScripts(https://cdn.example.com/spark-md5.min.js); self.onmessage function(e) { const file e.data.file; const chunkSize 2 * 1024 * 1024; const totalChunks Math.ceil(file.size / chunkSize); const spark new SparkMD5.ArrayBuffer(); let currentChunk 0; function readNextChunk() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); const reader new FileReaderSync(); const arrayBuffer reader.readAsArrayBuffer(file.slice(start, end)); spark.append(arrayBuffer); currentChunk; self.postMessage({ type: progress, percent: Math.round((currentChunk / totalChunks) * 100) }); if (currentChunk totalChunks) { setTimeout(readNextChunk, 0); } else { const md5 spark.end(); self.postMessage({ type: done, md5: md5 }); } } readNextChunk(); };这里用FileReaderSync是因为Web Worker内部是独立线程同步读取不会阻塞UI渲染。主页面代码只需要在创建Worker之后监听message事件拿到MD5后往下走。3.3 并发上传控制防止浏览器排队和服务器压力分片上传不能所有分片一次性同时发出这样会带来两个问题第一浏览器对同一域名的并发请求数是有限制的一般在6个左右超出会被排队第二服务端瞬时吞吐量有限过多并发请求会导致部分请求超时失败。实际项目里我设置了3个并发上限实测下来是最稳妥的。上传执行器的核心逻辑是用一个简单的任务池模型维护一个待处理队列和固定数量的Worker协程每个Worker空闲时从队列取任务执行执行完再取下一个直到队列清空。async function uploadChunksWithConcurrency(chunks, fileInfo, maxConcurrency 3) { let currentIndex 0; const uploadResults []; const failedChunks []; const worker async () { while (currentIndex chunks.length) { const chunk chunks[currentIndex]; try { await uploadChunkRequest(chunk, fileInfo); uploadResults.push(chunk.index); // 每上传成功一个分片就更新本地存储 persistUploadedChunks(fileInfo.fileId, uploadResults); } catch (error) { failedChunks.push({ index: chunk.index, error: error.message }); } } }; const workers []; for (let i 0; i maxConcurrency; i) { workers.push(worker()); } await Promise.all(workers); if (failedChunks.length 0) { console.error(以下分片上传失败, failedChunks); throw new Error(有 ${failedChunks.length} 个分片上传失败); } return uploadResults; }单分片上传函数就是标准的fetch或XMLHttpRequest提交用XMLHttpRequest的好处是可以拿到upload.onprogress事件但对当前架构来说只需要拿到结果即可所以fetch就够用了。上传请求里需要带上fileId、chunkIndex、totalChunks、fileName这些元信息服务端根据这些参数决定分片存储的位置和文件重组时的方式。async function uploadChunkRequest(chunk, fileInfo) { const formData new FormData(); formData.append(fileId, fileInfo.fileId); formData.append(chunkIndex, chunk.index); formData.append(totalChunks, fileInfo.totalChunks); formData.append(fileName, fileInfo.fileName); formData.append(chunk, chunk.blob); const response await fetch(/api/upload/chunk, { method: POST, body: formData }); const data await response.json(); if (!response.ok || data.code ! 0) { throw new Error(data.message || chunk ${chunk.index} 上传失败); } return data; }一个隐蔽的坑FormData里append Blob时如果第三个参数不给文件名某些Web框架解析multipart时会默认文件名是blob导致扩展名丢失。建议在append时显式指定文件名或者干脆在fileId参数之外额外传一份原始文件名。3.4 进度反馈与本地状态持久化上传进度需要同时展示两个维度当前分片级别和全局级别。全局进度用已上传字节数除以总字节数就行因为每个分片的大小是已知的function calcGlobalProgress(chunkSize, totalChunks, uploadedChunks) { const uploadedBytes uploadedChunks.length * chunkSize; const totalBytes totalChunks * chunkSize; return Math.min(99, Math.round((uploadedBytes / totalBytes) * 100)); }预留1%的进度给最后的合并阶段否则合并过程会有种卡了几秒没反应的错觉。这个细节是我在真机测试时注意到的进度显示到100%但文件还没出现在列表里用户就会以为失败了。代码里把上传进度封顶在99%合并接口返回成功后把进度推到100%。断点续传要真正的断点能恢复光靠内存中的状态是不够的刷新页面后内存数据就没了。我用localStorage持久化已上传分片列表键名是fileId。每次分片上传成功后更新一次localStorage。虽然也可以依赖服务端状态来做续传但本地记录能帮助前端在发起状态查询之前先做一次快速过滤减少无效网络请求。4. 断点续传完整实现状态查询、恢复逻辑与合并触发4.1 已上传分片状态获取的双通道设计断点续传的前提条件是准确知道哪些分片已经上传成功。我用了双通道机制前端本地localStorage里存了一份已上传分片列表服务端那边也按fileId保存了一份。续传时先读取本地列表做过滤过滤后剩下的分片再和服务端的状态接口对齐一次以服务端数据为准。为什么必须以服务端为准因为可能存在分片上传成功但前端本地存储没来得及更新的边缘情况或者用户在另一台设备上已经上传过一部分的情况。服务端状态才是真实状态本地数据只做减少请求的优化。async function getUploadedChunks(fileInfo) { // 先从本地读取 const localUploaded JSON.parse( localStorage.getItem(upload_${fileInfo.fileId}) || [] ); // 再向服务端验证 const response await fetch(/api/upload/status?fileId${fileInfo.fileId}); const data await response.json(); if (data.code 0 data.data data.data.uploadedChunks) { const serverUploaded data.data.uploadedChunks; // 合并两个来源取并集 const merged [...new Set([...localUploaded, ...serverUploaded])]; return merged.sort((a, b) a - b); } return localUploaded.sort((a, b) a - b); }4.2 续传逻辑组装从零开始与从中断处继续续传逻辑实际上就是在发起上传之前先做一次已上传分片的过滤。未上传的分片才进入并发上传队列。这里有个关键的决策点如果一个文件已经全部上传完成了前端检测到uploadedChunks数量等于totalChunks时不应该重复上传任何分片而是直接调用合并接口。这个逻辑能顺便帮保险业务实现秒传效果——客户上传同名同内容的文件前端算出MD5后查状态状态显示全部分片都在那直接合并出文件就行全程几秒钟完事。async function startUpload(file, callbacks) { const fileName file.name; const fileSize file.size; const md5 await calculateHashInWorker(file); const fileId md5; // 用MD5作为全局唯一标识 const fileInfo { fileId: fileId, fileName: fileName, fileSize: fileSize, chunkSize: 2 * 1024 * 1024, totalChunks: Math.ceil(fileSize / (2 * 1024 * 1024)), uploadedChunks: [], status: uploading }; // 获取已上传状态断点检测 const uploadedChunks await getUploadedChunks(fileInfo); fileInfo.uploadedChunks uploadedChunks; // 如果全部已上传直接合并 if (uploadedChunks.length fileInfo.totalChunks) { await triggerMerge(fileInfo); callbacks.onComplete(fileInfo); return; } // 构造未上传分片列表 const allChunks splitFileIntoChunks(file); const pendingChunks allChunks.filter(chunk { return !uploadedChunks.includes(chunk.index); }); // 断点续传提示 if (uploadedChunks.length 0) { callbacks.onResume(uploadedChunks.length); } // 并发上传 const uploaded await uploadChunksWithConcurrency(pendingChunks, fileInfo, 3); fileInfo.uploadedChunks [...uploadedChunks, ...uploaded].sort((a, b) a - b); // 触发合并 await triggerMerge(fileInfo); callbacks.onComplete(fileInfo); }4.3 合并触发时机与异常处理合并触发是整个分片上传最后的临门一脚。前端在把所有分片上传完成后调用合并接口服务端把所有分片按index顺序拼接成完整文件。合并操作和分片上传不同它是一个耗时可能超过HTTP请求超时时间的操作所以我在实现里给合并请求额外设置了180秒的超时时间避免合并到一半请求被浏览器中断。合并指令发送之后前端会显示服务端正在合成文件请稍候在这个阶段即使轮询状态是合并中也不能让用户关页面。比较稳妥的做法是服务端把合并操作设计成异步任务前端轮询合并状态接口等状态变成completed再从服务端拿最终的下载URL。我在正式项目里就是这么做的虽然代码复杂度高一点但用户体验和对服务端的压力管理都好得多。async function triggerMerge(fileInfo) { const response await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId: fileInfo.fileId, fileName: fileInfo.fileName, totalChunks: fileInfo.totalChunks }), // 重点合并请求超时时间放宽避免服务端重组大文件时中断 signal: AbortSignal.timeout(180000) }); const data await response.json(); if (data.code ! 0) { throw new Error(data.message || 文件合并失败); } return data; }5. 保险业务场景的避坑实录与常见问题排查5.1 高频问题速查表开发过程中踩过的坑、以及后来在线上环境暴露出来的问题我整理成了下面这张速查表做同类项目的同学可以直接对照。问题现象根本原因解决办法大文件算MD5时页面卡死哈希计算在主线程阻塞渲染用Web Worker跑哈希计算上传到一半全部请求超时分片并发数过高压垮服务器并发数限制在2-3个刷新页面后进度丢失已上传分片没有持久化每成功一片就更新localStorage合并后文件打不开分片顺序错乱或文件名丢失合并时按index排序保留原始扩展名多个分片同时上传偶发重复用户点击了多次上传按钮加上传锁同一fileId并发任务只保留一个弱网环境分片频繁失败单个请求超时设置太短上传接口用XMLHttpRequesttimeout设为30秒并开启重试服务端临时分片占满磁盘上传中断后分片无人清理服务端做定时清理任务超过24小时未合并的分片删除老版本浏览器不支持fetch兼容性遗漏对IE等浏览器做登录用户的兼容降级或用XMLHttpRequest统一封装5.2 保险合规场景下的安全增强保险系统走的是数据安全合规路线文件上传接口面临的攻击面比一般系统更大。分片上传接口如果没做权限校验等同于给别人开了一个任意文件写入的通道。我们项目里在三个层面做了安全加强第一所有上传接口都要求带鉴权凭证且分片上传接口校验的是操作人是否有该理赔案件的访问权限第二文件名在前端经过白名单过滤不允许包含路径穿越字符和脚本关键字服务端也会重新生成存储文件名丢弃原始文件名第三对上传的chunk大小做严格校验如果服务端发现某个分片实际大小超过了约定的chunkSize上限直接拒绝接收并记录日志。这三点对生产环境的保险系统来说不是可选配置而是必须项。还有一点容易被忽略分片文件的临时存储区域要放在独立的目录里并且对该目录禁止脚本执行权限防止有人恶意构造特殊的文件扩展名导致服务器解析执行。这个属于服务端的安全加固前端在上传流程里很难感知但作为系统设计者你需要把这个风险点传递给你的后端同事。5.3 我在实际项目中的几个经验心得第一分片大小不要迷信某些博客里说的越大越快。我在不同网络环境下做过对比测试2MB到4MB之间在稳定性上表现最好。大于10MB的分片在弱网下超时概率非常高而且一旦超时重传量也大得不偿失。第二上传过程中要让用户能取消和暂停。保险业务中客户突然发现传错文件了想换一个如果没有取消操作就只能干等着后期文件列表里还会出现一堆无用的体积冗余。我做了暂停按钮之后客户的误操作率明显降低。第三日志一定要记全。每个分片的上传ID、发起时间、耗时、重试次数都要有记录。线上排查问题的时候没有分片级别的日志根本无从下手。我后来在项目里加了一个上传诊断面板可以把最近一次上传的详细日志导出发给技术支持很多客户反馈的问题靠这个面板一次就定位了。第四断点续传里的断点不单指网络中断用户主动暂停也算。暂停和断点的恢复逻辑完全一致区别只是触发方式不同。所以代码设计上把暂停、网络中断、页面刷新后的恢复统一走同一条续传路径代码复用率高维护起来也轻松。最后说一个很多人忽略的点上传模块要设计成可降级的。极端情况下如果客户用的是老古董浏览器连File.slice都不支持你要在代码里做能力检测不支持的给一个明确提示并引导到传统上传通道。保险业务的客户群体年龄跨度大、设备新旧不一前端工程师的底线是你做的系统要让最笨重的环境也能完成业务闭环而不是绑定一个高门槛的技术方案把这部分用户全挡在门外。这套实现方案上线后我们后台系统里最大单文件是800MB的车祸现场录像分片上传加断点续传跑得很稳弱网环境下的完成率从之前的45%直接提升到了92%客户投诉里上传失败这个分类基本消失了。如果你正在做类似的大文件上传功能希望这篇内容能帮你少走几个弯路。有问题的话也欢迎在评论区交流我看到会回。
阅读完成 · 觉得有帮助?