很多年前我第一次接手一个内部系统改造,甲方抛过来一个需求:浏览器里要能传好几个 GB 的工程文件。我一开始想的很简单,form 里塞个 input,后端接住流不就行了。结果真去验证才发现,别说几个 GB,超过 2GB 的文件,浏览器直接把请求给你掐了,后端也是各种超时,上传速度感人,页面卡顿,用户体验基本归零。后来老老实实上了分片上传方案,才算是把这件事彻底解决。这篇文章就把我落地这套方案时用到的核心代码、踩过的坑、以及设计思路都摊开来讲,希望能帮你少走点弯路。
我在做的这个跨平台网页应用,目标用户既有用 Windows 的朋友,也有在 Mac 和手机端 WebView 里操作的人。分片上传的兼容性问题不是 Chrome 一家说了算,好在现代浏览器的 File API 和 Blob 切片都支持得不错,让我能用一个统一的 JavaScript 方案去覆盖所有场景。整个方案的核心模块可以独立复用,源码拿到就能改,里面包含文件切片、并发控制、断点续传、进度回调以及后端接口约定这几块,本篇文章会逐个拆开。
1. 分片上传的方案选型与整体流程设计
1.1 为什么必须拆分文件上传
先说说直接将文件整体上传会遇到什么问题。普通multipart/form-data上传,浏览器需要把整个文件读进内存再发出请求。我实测过 1GB 文件在千兆内网的传输情况,并非网速不够,而是网络连接只要稍微波动一下,整个文件就得从头重传。如果是 4GB 的文件,用户体验的崩溃程度不用我多讲,任何一次断线都会让进度归零。
分片上传的核心思路是把大文件切成固定大小的块,每块作为一个独立请求去提交。这样做有三个直接好处:
- 单个分片的大小可控,请求体不会太大,后端处理起来不会因为大流量卡死内存。
- 分片之间相互独立,失败之后只重传失败的分片,不需要整文件重来。
- 后端接收分片后可以并发写入,整体耗时比串行传输一个文件要短得多。
从用户感知上,分片上传还能配合进度条做成更细颗粒度的反馈,不再是一个干等着的小转圈。
我在做技术选型时也对比过 WebSocket 上传和 HTTP 分片上传。WebSocket 做二进制流传输在长连接场景下确实流畅,但服务端实现复杂度高,且多层代理对 WebSocket 的支持偶尔会有坑。相比起来,HTTP 分片上传更通用,跨平台和防火墙的兼容性更好,所以我最终选择了 XMLHttpRequest 加 FormData 的组合。
1.2 完整上传流程的阶段划分
分片上传不是把文件切了就完事,它需要一套完整的状态管理。整体上我把它分成五个阶段:
- 文件解析阶段:读取文件元信息,包括文件大小、文件类型、最后修改时间,生成唯一指纹。
- 切片计算阶段:根据预设分片大小,计算出分片总数和每片的起止字节位置。
- 上传准备阶段:调用后端接口,询问哪些分片已经存在,这一步用来支持断点续传和秒传。
- 分片并发上传阶段:按并发数控制请求数量,上传过程中记录每个分片的状态。
- 合并与校验阶段:全部上传完成后,通知后端合并分片,后端做完整性校验并返回最终结果。
这里有个细节经常被人忽略:文件指纹的生成。如果直接拿文件名做唯一标识,不同用户上传同名文件会冲突;如果只拿文件大小和修改时间,又可能出现重复文件被误判为已上传。我的做法是把文件大小、修改时间、文件名拼接后做一次 MD5,这样既能支持同一文件秒传,又不会误伤同名但内容不同的文件。后续文章会给出具体代码。
在实际的项目里,我还加入了一个“上传任务”对象来管理每个文件的完整状态,包括当前进度、已上传分片集合、上传中分片集合和失败分片集合。这样无论是用户手动暂停还是网络异常,都能在一次刷新后快速恢复,而不是推倒重来。
2. 前端核心实现:从文件切片到并发上传
2.1 文件读取与切片逻辑拆解
前端拿到的文件对象是一个File对象,它继承自Blob,所以可以直接用Blob.prototype.slice()方法进行切片。这个方法非常关键,它是分片上传的地基。不要自己去读FileReader把文件转成 base64 或 ArrayBuffer 再切,那会直接把内存打爆。
切片不是一个循环就完事了,要注意边界条件。我写了一段可以直接用的核心切片代码:
function createFileChunks(file, chunkSize = 5 * 1024 * 1024) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); chunks.push({ blob, start, end, size: end - start, index: chunks.length }); start = end; } return chunks; }这段代码关键在于Math.min(start + chunkSize, file.size),它能保证最后一片不会超出文件末尾。因为我一开始忽略这个边界,导致最后一片的 size 为 0,上传后跟后端合并数据时经常出现文件尾部缺字节的毛病。
另一个容易掉坑的点是分片大小的选择。我测试过很多种参数,实测下来 512KB 到 10MB 之间都有各自的适用场景。如果分片太小,请求数量太多,每个请求的 HTTP 开销会被放大,整体吞吐量反而下降;如果分片太大,又回到整文件上传的老问题上,网络波动导致重传成本变高。我最终在项目中默认选了 5MB,局域网环境下可以调到 10MB,弱网环境下调整为 1MB 更合适。
切片之后,每个分片本身就是一个Blob对象。这个对象可以直接丢进FormData,但要记住一个坑:FormData.append('file', blob, filename)的三个参数中,第三个参数是文件名。如果不传,部分后端框架解析 multipart 时拿不到文件名,导致分片写入临时文件时没有后缀,最后合并文件名会对不上。所以我在每个分片上传时都会显式传一个分片名,例如filename.part.0。
2.2 XMLHttpRequest 与 fetch 的上传实现对比
分片上传的请求方式有两种主流选择:XMLHttpRequest和fetch。如果你需要做上传进度条,XMLHttpRequest是更顺手的方案,因为它原生支持xhr.upload.onprogress事件。而fetch虽然 API 更新,但对请求体的上传进度监听目前还需要借助流式传输,某些浏览器兼容性没有 XHR 好。
我的生产代码里用的是 XHR 封装的上传方法,核心逻辑如下:
function uploadChunk({ url, chunk, index, total, identifier, filename }) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', chunk.blob, `${identifier}_${index}.part`); formData.append('index', index); formData.append('total', total); formData.append('identifier', identifier); formData.append('filename', filename); const xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.timeout = 30 * 60 * 1000; xhr.upload.onprogress = (event) => { if (event.lengthComputable) { // 单分片进度回调,可以回传进度信息 } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.responseText); } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('Network error')); xhr.ontimeout = () => reject(new Error('Request timeout')); xhr.send(formData); }); }这里有几个细节是要特别注意的。第一,xhr.open的第三个参数必须为true,代表异步请求,否则页面会卡死。第二,timeout一定要设置,因为大分片在弱网环境下可能长时间没有响应,不设置超时会导致请求挂在那里不报错也不成功。第三,xhr.onload只代表请求完成,不代表上传成功,所以必须校验 HTTP 状态码。
使用fetch的写法其实也很简洁,当你不需要细粒度进度条时可以用它:
async function uploadChunkWithFetch({ url, chunk, index, total, identifier }) { const formData = new FormData(); formData.append('file', chunk.blob); formData.append('index', index); formData.append('total', total); formData.append('identifier', identifier); const response = await fetch(url, { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`Upload failed with status ${response.status}`); } return response.json(); }但是我必须说,在实际生产环境中我大部分用的是 XHR,因为上传进度条对用户来说是很重要的心理反馈。很多管理后台的用户看到进度条走到 99% 卡住,会比看到 30% 卡住更焦虑,一个准确、平滑的进度显示能显著提升用户对系统的信任感。
2.3 并发控制与请求队列管理
切好分片之后,如果一口气把几十个请求同时发出去,浏览器会直接罢工。不同浏览器的同域并发连接数是有限的,Chrome 一般是 6 个左右,超出部分会排队,但排队过程不可控,分片请求乱序到达后端,容易让合并逻辑崩溃。所以前端必须手动做并发控制。
我的做法是写一个简单的并发池,用数组维护正在执行的任务,超过设定的并发数就等待:
async function runWithConcurrency(tasks, limit) { const results = []; const executing = []; for (const task of tasks) { const promise = task().then(result => { results.push(result); return result; }); executing.push(promise); if (executing.length >= limit) { await Promise.race(executing).then(() => { const index = executing.indexOf(promise); executing.splice(index, 1); }); } } await Promise.all(executing); return results; }这个并发池的思路是把异步任务转换成 Promise,维护一个当前在执行的 Promise 数组,当数组长度达到上限时,通过Promise.race等待最先完成的那一个,然后把它从数组里移除,腾出位置给新的任务。这样既保证了并发数上限,又不会盲目等待第一个全部完成。
实际使用中我还加入了“失败重试”逻辑。每个分片最多重试三次,重试间隔采用指数退避策略,分别是 1 秒、2 秒、4 秒。这个策略很重要。不加退避的话,网络抖动恢复后,所有失败分片会同时重试,反而把带宽占满,又造成新一轮失败。
async function uploadWithRetry(uploadFn, retries = 3) { try { return await uploadFn(); } catch (error) { if (retries <= 0) { throw error; } await sleep(1000 * (4 - retries + 1)); return uploadWithRetry(uploadFn, retries - 1); } }这里我踩过最深的坑是:盲目认为“重试”可以解决一切问题。有一次线上用户反馈上传一直失败,我反复看日志,发现是后端某个分片合并接口偶发 500 错误,前端重试三次全都撞在同一个接口上。后来我把重试机制做了改进,不是简单地重新请求,而是先调用checkStatus接口确认后端状态,如果后端已经不是接收状态,就重新初始化上传流程,问题才解决。总结下来,重试并不是万能的,重试之前一定要思考后端可能处于什么状态。
3. 断点续传与文件指纹计算详解
3.1 文件指纹的生成方案
断点续传的核心在于:如何判断“哪些分片已经上传过了”。这需要前后端对同一个文件达成相同的身份认证,也就是生成文件唯一标识。
我最早贪图省事,直接用文件名作为 identifier。结果发现两个不同用户上传同名文件时,后端会把他们当成同一个文件,第二个用户上传时会命中“已存在分片”的判断,导致秒传返回了一个错误的文件地址。这种问题在测试环境里不会暴露,只有到真实项目里用户量上来后才明显。
我现在的做法是,读取文件的基础元数据,拼接后做哈希:
async function generateFileIdentifier(file) { const metadata = `${file.name}_${file.size}_${file.lastModified}`; const encoder = new TextEncoder(); const data = encoder.encode(metadata); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); }这里用的是 Web Crypto API 的crypto.subtle.digest,不需要额外引入第三方库,浏览器原生支持,且计算速度非常快。为什么不直接对整个文件做 MD5?因为对大文件做完整哈希非常耗时,而且占用大量内存,用户上传前等几秒只为算个哈希,体验太差。
用文件元数据拼接哈希的方案几乎零成本,虽然严格意义上存在极小概率的碰撞,但在实际业务场景中足够使用。如果对安全性要求极高,比如金融级别的系统,可以采用抽样哈希的策略:取文件前 1MB、中间 1MB、末尾 1MB 组合后做哈希,以命中率和成本的平衡满足业务需求。
3.2 秒传与断点续传的前后端配合逻辑
分片上传的完整体验需要前后端紧密配合。前端在上传前先调用一个检查接口:
async function checkUploadStatus(identifier) { const response = await fetch(`/api/upload/status?identifier=${identifier}`); const data = await response.json(); return data; }后端返回的 JSON 结构大概是这样的:
{ "uploaded": [0, 1, 2, 5, 6], "total": 10, "status": "uploading" }uploaded数组表示已经上传成功的分片序号。前端拿到这个数组后,就只上传缺失的分片。如果后端返回的uploaded数量等于total,说明文件已经完全存在,可以直接跳转到最终合并接口,这就是秒传的实现原理。
这里的合并接口也要谨慎设计。我的方案是后端在最后一个分片上传完成后,自动触发合并流程,同时提供一个手动触发的合并接口。前端在上传完所有分片后,无论后端是否自动触发,都会再调用一次合并接口。合并接口内部会做幂等处理,即使重复调用也不会重复合并,这样既保证了并发场景下的安全,又简化了前端的调用逻辑。
在做断点续传时,还有一个很容易踩的坑:前端上传过的分片,在后端可能因为保存超时被清理了。比如用户退出了页面,几个小时后重新上传同一个文件,此时后端临时目录里的旧分片已经被人为清理。所以前端不能盲目相信后端返回的uploaded列表,还要对比一下后端返回的文件大小和当前文件的分片数是否一致。如果不一致,就要通知后端清理旧的临时数据,重新开始。
3.3 暂停、恢复与进度计算
用户在上传大文件的过程中,很可能会希望暂停。暂停不是说把请求取消就行,而是需要处理好过程状态。我实现暂停功能的思路是,在并发池的循环中加入一个状态变量:
class UploadManager { constructor() { this.isPaused = false; this.uploadedChunks = new Set(); } async pause() { this.isPaused = true; } async resume() { this.isPaused = false; await this.uploadRemainingChunks(); } }pause()方法内部只是把isPaused置为 true,并发池中的新任务不会再启动,但已经发出且正在传输中的请求无法被取消,这是 JavaScript 异步模型的限制。用户看到的“暂停”其实是逐步停止的,等待所有正在传输的分片完成后,整个任务才真正停下来。在 UI 上需要给用户一个明确的反馈,说明还有几个分片在收尾。
进度计算也有一些学问。简单把“已上传的分片数除以总片数”作为整体进度很粗糙,因为每个分片大小一致时还好,但如果最后一片较小,或者某些分片上传速度不同,这个进度显示就会跳来跳去。我采用的是“按字节计算”的方式,维护一个已上传字节数变量,在每次分片上传成功后将size累加进去,然后用已上传字节数 / 文件大小得到精确的进度。实测下来进度条平滑很多。
4. 跨平台适配与后端接口约定的工程化落地
4.1 跨浏览器与移动端 WebView 的兼容性处理
我之所以老强调“跨平台”,是因为这个网页应用最终会跑在 Windows 桌面端浏览器、macOS Safari、安卓原生 WebView、iOS 内嵌 WebView 等多端环境。别看这些都是浏览器,行为差异非常明显。
最大的坑出在 iOS Safari 和 WebView 对Blob.prototype.slice的参数要求上。老版本的 iOS Safari 要求slice的第二个参数必须显式传入,且不传时返回的是空 Blob。我在做兼容处理时,强制传入end,并且用一个工具函数统一封装:
const safeSlice = (blob, start, end) => { if (blob.slice) { return blob.slice(start, end); } if (blob.webkitSlice) { return blob.webkitSlice(start, end); } if (blob.mozSlice) { return blob.mozSlice(start, end); } throw new Error('当前浏览器不支持文件切片功能'); };第二个兼容性坑是FormData在部分低版本 WebView 中不支持直接追加 Blob 对象。我用了一种降级方案,先将分片 Blob 转成 File 对象,再追加:
const fileFromBlob = new File([chunk.blob], `${identifier}_${index}.part`, { type: 'application/octet-stream' }); formData.append('file', fileFromBlob);这个做法在绝大多数现代 WebView 里都可以正常工作,唯一的代价是需要额外创建一次 File 对象的引用,不会复制文件内容,所以内存占用很小。
还有一个必须处理的点是超时设置的差异,网络慢的时候 XHR 的timeout在部分浏览器中如果设得太大,反而会失效。我的处理方式是给底层 XHR 对象设置一个合理的默认值,同时采用应用层超时控制:用setTimeout包一层,超时后调用xhr.abort()。
function uploadChunkWithTimeout({ ... }) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const timer = setTimeout(() => { xhr.abort(); reject(new Error('应用层超时,请求已终止')); }, 10 * 60 * 1000); xhr.onloadend = () => clearTimeout(timer); // 其他逻辑... }); }这样处理之后,即使某个平台的底层超时实现有 bug,上层逻辑也能保证请求不会挂死。
4.2 后端接口设计与合并策略
前端写得再好,后端不给力也是白搭。分片上传最考验后端的就是两个点:一是分片存储的组织方式,二是合并时的数据完整性。
我的后端接口设计如下:
- 初始化接口
/api/upload/init:接收文件名、文件大小、分片大小,返回 uploadId。 - 分片上传接口
/api/upload/part:接收 uploadId、分片索引、分片内容。 - 状态查询接口
/api/upload/status:接收 uploadId,返回已上传的分片索引集合。 - 合并接口
/api/upload/merge:接收 uploadId,触发合并操作。
合并操作的校验逻辑可以用一句话总结:每个分片的后端接收大小必须与前端切片的期望大小一致。我在后端验证每个分片大小时会跟前端传过来的chunkSize做比对,不一致就拒绝保存。因为有些代码库的slice实现会因为文件编码问题导致分片比预期大或小,这种不一致如果提前不拦截,合并后的文件会损坏。
后端的合并我这里用一个 Node.js 的示例来说明:
const fs = require('fs'); const path = require('path'); async function mergeChunks(uploadId, totalChunks, destFilePath) { const chunksDir = path.join(__dirname, 'uploads', uploadId); const writeStream = fs.createWriteStream(destFilePath); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(chunksDir, `${i}.part`); const buffer = await fs.promises.readFile(chunkPath); writeStream.write(buffer); } writeStream.end(); // 合并完成后清理临时目录 await fs.promises.rm(chunksDir, { recursive: true, force: true }); }这段代码是按顺序写入所有分片。如果要追求更高性能,可以改成并发读取分片再按顺序写入,但要注意避免文件句柄过多导致文件描述符耗尽。我测试过 100 个分片的场景,串行读取和写入的速度完全可以接受,瓶颈还是在网络上,真到磁盘合并时反而是最不担心的一环。
4.3 工程化配置与性能调优参数汇总
分片上传的参数不能写死,要做成可配置项,最好在初始化时根据网络环境自动适配。我在项目中的默认配置如下:
| 配置项 | 默认值 | 适用场景 |
|---|---|---|
| 分片大小 | 5MB | 普通宽带环境,通用性最好 |
| 并发数 | 3 | 避免占用过多带宽,平衡稳定性 |
| 最大重试次数 | 3 | 应对网络抖动 |
| 重试退避时间 | 1s / 2s / 4s | 指数退避,避免重试风暴 |
| 超时时间 | 10 分钟 | 足够应对弱网环境下的单分片传输 |
| 最大上传文件 | 10GB | 超出时可提示客户端使用专用工具 |
其中我要特别说明下并发数的选择。并发数过高会让浏览器和网络都不稳定。我曾经把并发数调到 8,在千兆局域网环境下传输速度确实有提升,但在普通宽带环境下,大量并发请求同时挤占带宽,每个请求的速度都变慢了,反而整体吞吐量下降。最终在多数项目中维持在 3 到 5 之间,既能利用多请求并发提升速度,又不会因为抢占带宽而互相拖累。
5. 常见问题排查与实操经验总结
5.1 上线后的典型故障记录与排查思路
分片上传上线后,我收到过形形色色的 bug 反馈,这里整理几个印象深刻的:
第一类是“上传完成后文件打不开”。排查下来大概率是切片时漏了最后一块,或者后端合并时没有按照索引顺序写入。解决方案就是我在前面提到的Math.min(start + chunkSize, file.size)边界处理,以及后端mergeChunks里必须严格按照索引递增顺序处理。还有一个容易被忽略的细节,就是分片的index字段在传递过程中被后端解析成字符串,排序时按字典序而不是数字序,导致index=10排到了index=2前面。后端排序时必须显式转换成整数。
第二类是“上传过程中页面卡死”。这种情况在用户往分片上传表单中拖入超大文件时很容易触发。排查发现是FormData组装过程中,如果直接 append 一个巨大的 Blob,浏览器在主线程上执行了同步的内存分配,导致页面短暂假死。解决方案是把文件切分和 FormData 组装放到Web Worker中执行,主线程只负责接收进度事件。Web Worker 的使用很成熟,唯一要注意的是 Worker 中无法访问FormData以外的一些 DOM API,好在切分逻辑完全不依赖 DOM。
第三类是“进度条卡在 99% 不动”。原因很凿凿:前端计算进度时只统计已上传完成的字节数,最后一个分片因为并发池正在等待别的分片完成,还没真正开始上传,所以进度一直显示 99%。等到最后一个分片开始并完成,进度又直接跳到 100%,中间缺少平滑过渡。我后来的解决方式是把“已经进入上传队列但尚未开始的分片”也计入总进度的分母,并且给每个分片单独维护状态,UI 层的进度条按状态加权计算,这样 99% 的卡顿感就消失了。
5.2 分片上传性能优化的实测对比
性能调优这件事,不能靠感觉。我在不同分片大小和并发数组合下做过一轮实测,基准环境是一台普通 PC 加上千兆内网,文件大小 2GB,结果记录下来之后还挺有意思:
| 分片大小 | 并发数 | 总体耗时 | 失败重试次数 |
|---|---|---|---|
| 1MB | 3 | 425s | 7 |
| 5MB | 3 | 318s | 2 |
| 10MB | 3 | 334s | 1 |
| 5MB | 5 | 295s | 1 |
| 5MB | 8 | 337s | 6 |
| 1MB | 5 | 402s | 11 |
可以看到,并不是分片越小速度越快。分片过小带来了大量的 HTTP 请求和 multipart 解析开销。分片 5MB 加并发 5 在这个环境下表现最好。而分片过大会导致单个请求重试成本变高,所以在弱网环境下应适当减小分片。
还有一个优化策略是“自适应并发控制”,让前端根据当前上传速度动态调整并发数。比如上传速度低于某个阈值时,降低并发数,减少因网络拥塞导致的超时重试;速度快时增加并发数,充分利用带宽。这个策略实现起来不复杂,但我只把它放在了网络环境波动较大的移动端场景中,PC 端固定并发数足够稳定。
5.3 直接可复用的分片上传完整源码
下面给出一份精简但完整可运行的分片上传核心类,整合了前面所有的关键逻辑。你可以在项目中直接使用,按实际接口路径修改即可。
class ChunkedUploader { constructor(options) { this.file = options.file; this.url = options.url; this.chunkSize = options.chunkSize || 5 * 1024 * 1024; this.concurrency = options.concurrency || 3; this.retries = options.retries || 3; this.onProgress = options.onProgress || (() => {}); this.uploadedBytes = 0; this.chunks = []; this.identifier = ''; } async init() { this.identifier = await generateFileIdentifier(this.file); const status = await checkUploadStatus(this.identifier); // status.uploaded 是一组已上传的分片索引 this.chunks = createFileChunks(this.file, this.chunkSize); this.uploadedBytes = this.chunks .filter((_, index) => status.uploaded.includes(index)) .reduce((sum, chunk) => sum + chunk.size, 0); this.pendingChunks = this.chunks.filter( (_, index) => !status.uploaded.includes(index) ); } async start() { await this.init(); const tasks = this.pendingChunks.map((chunk, offset) => { return () => this.uploadWithRetry(chunk); }); await runWithConcurrency(tasks, this.concurrency); await this.merge(); } async uploadWithRetry(chunk) { let lastError; for (let attempt = 0; attempt < this.retries; attempt++) { try { await this.uploadChunk(chunk); this.uploadedBytes += chunk.size; this.onProgress(this.uploadedBytes, this.file.size); return; } catch (error) { lastError = error; await sleep(1000 * Math.pow(2, attempt)); } } throw lastError; } async uploadChunk(chunk) { const formData = new FormData(); formData.append('file', chunk.blob, `${this.identifier}_${chunk.index}.part`); formData.append('index', chunk.index); formData.append('total', this.chunks.length); formData.append('identifier', this.identifier); formData.append('filename', this.file.name); const response = await fetch(this.url, { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`分片上传失败: ${response.status}`); } } async merge() { const mergeUrl = new URL(this.url + '/merge'); mergeUrl.searchParams.set('identifier', this.identifier); mergeUrl.searchParams.set('filename', this.file.name); mergeUrl.searchParams.set('total', this.chunks.length); const response = await fetch(mergeUrl.toString(), { method: 'POST' }); if (!response.ok) { throw new Error(`合并失败: ${response.status}`); } return response.json(); } } // 工具函数 function createFileChunks(file, chunkSize) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ blob: file.slice(start, end), start, end, size: end - start, index: chunks.length }); start = end; } return chunks; } async function generateFileIdentifier(file) { const metadata = `${file.name}_${file.size}_${file.lastModified}`; const data = new TextEncoder().encode(metadata); const hashBuffer = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(hashBuffer)) .map(byte => byte.toString(16).padStart(2, '0')) .join(''); } async function runWithConcurrency(tasks, limit) { const executing = []; for (const task of tasks) { const promise = task(); executing.push(promise); if (executing.length >= limit) { await Promise.race(executing); const index = executing.indexOf(promise); executing.splice(index, 1); } } await Promise.all(executing); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }这段源码看似不复杂,但它把并发控制、重复上传的跳过、进度统计和最终合并全串起来了,是我在实际项目中反复打磨后的简化版。如果你要用在多文件上传场景,只需在外层包一个循环,为每个文件创建一个ChunkedUploader实例即可。
5.4 分片上传后续的扩展方向
我在完成基础版本后,又在真实线上项目中逐步加入了一些扩展能力,这里分享几个我想推荐你后续可以做的方向。
第一是“服务端临时文件自动清理”。分片上传如果中途放弃,后端临时目录里会堆积大量半成品文件。我的做法是在后端加一个定时任务,扫描超过 24 小时仍未合并的上传会话并清理。前端也要配合,在检测到用户离开页面前,调用一次取消接口通知后端清理。
第二是“上传任务持久化”。利用浏览器 IndexedDB 保存当前上传状态,包括 identifier、分片索引集合和文件元数据。这样用户误关页面后重新打开,可以自动恢复列表并提示“继续上传”。这个功能看似简单,但在企业内部系统中非常受认可。
第三是“多文件队列与优先级”。管理后台经常需要批量上传一堆文件,这时候可以做一个上传队列,默认按文件大小排序,小的先传,大的后传。用户也可以手动调整优先级。实现上只需要给uploader实例加一个队列管理器,控制同时启动的实例数量。
我可以很坦诚地说,分片上传这套方案并不新颖,但它涉及的前后端配合细节非常多,每一步都藏着小九九。有时候你觉得代码写对了,跑一次才发现边界情况根本没覆盖。希望这篇实战分享能让你真正把这个功能在自己的应用中落地,而不是看一眼原理就搁置了。如果你在实现过程中遇到问题,不妨先把参数表里的分片大小和并发数调整一下试试,很多看似复杂的问题,往往就是这两个值没选对引起的。