保险网页项目里,大文件上传一直是个让前端头疼的事。理赔单证照片、医疗影像、事故现场视频,这些附件随便一个就有几十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并发任务只保留一个 |
| 弱网环境分片频繁失败 | 单个请求超时设置太短 | 上传接口用XMLHttpRequest,timeout设为30秒并开启重试 |
| 服务端临时分片占满磁盘 | 上传中断后分片无人清理 | 服务端做定时清理任务,超过24小时未合并的分片删除 |
| 老版本浏览器不支持fetch | 兼容性遗漏 | 对IE等浏览器做登录用户的兼容降级,或用XMLHttpRequest统一封装 |
5.2 保险合规场景下的安全增强
保险系统走的是数据安全合规路线,文件上传接口面临的攻击面比一般系统更大。分片上传接口如果没做权限校验,等同于给别人开了一个任意文件写入的通道。我们项目里在三个层面做了安全加强:第一,所有上传接口都要求带鉴权凭证,且分片上传接口校验的是操作人是否有该理赔案件的访问权限;第二,文件名在前端经过白名单过滤,不允许包含路径穿越字符和脚本关键字,服务端也会重新生成存储文件名,丢弃原始文件名;第三,对上传的chunk大小做严格校验,如果服务端发现某个分片实际大小超过了约定的chunkSize上限,直接拒绝接收并记录日志。这三点对生产环境的保险系统来说不是可选配置,而是必须项。
还有一点容易被忽略:分片文件的临时存储区域要放在独立的目录里,并且对该目录禁止脚本执行权限,防止有人恶意构造特殊的文件扩展名导致服务器解析执行。这个属于服务端的安全加固,前端在上传流程里很难感知,但作为系统设计者你需要把这个风险点传递给你的后端同事。
5.3 我在实际项目中的几个经验心得
第一,分片大小不要迷信某些博客里说的"越大越快"。我在不同网络环境下做过对比测试,2MB到4MB之间在稳定性上表现最好。大于10MB的分片在弱网下超时概率非常高,而且一旦超时,重传量也大,得不偿失。
第二,上传过程中要让用户能取消和暂停。保险业务中客户突然发现传错文件了,想换一个,如果没有取消操作就只能干等着,后期文件列表里还会出现一堆无用的体积冗余。我做了暂停按钮之后,客户的误操作率明显降低。
第三,日志一定要记全。每个分片的上传ID、发起时间、耗时、重试次数都要有记录。线上排查问题的时候,没有分片级别的日志根本无从下手。我后来在项目里加了一个上传诊断面板,可以把最近一次上传的详细日志导出发给技术支持,很多客户反馈的问题靠这个面板一次就定位了。
第四,断点续传里的"断点"不单指网络中断,用户主动暂停也算。暂停和断点的恢复逻辑完全一致,区别只是触发方式不同。所以代码设计上把暂停、网络中断、页面刷新后的恢复统一走同一条续传路径,代码复用率高,维护起来也轻松。
最后说一个很多人忽略的点:上传模块要设计成可降级的。极端情况下如果客户用的是老古董浏览器,连File.slice都不支持,你要在代码里做能力检测,不支持的给一个明确提示并引导到传统上传通道。保险业务的客户群体年龄跨度大、设备新旧不一,前端工程师的底线是你做的系统要让最笨重的环境也能完成业务闭环,而不是绑定一个高门槛的技术方案把这部分用户全挡在门外。
这套实现方案上线后,我们后台系统里最大单文件是800MB的车祸现场录像,分片上传加断点续传跑得很稳,弱网环境下的完成率从之前的45%直接提升到了92%,客户投诉里"上传失败"这个分类基本消失了。如果你正在做类似的大文件上传功能,希望这篇内容能帮你少走几个弯路。有问题的话,也欢迎在评论区交流,我看到会回。