ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于HTML5的大文件分片上传与断点续传完整实现

基于HTML5的大文件分片上传与断点续传完整实现

前几天帮一个做内部系统的朋友排查问题:他们后台有个上传视频的功能,一个1.2GB的教学视频,上传到三分之一浏览器直接崩了,再点一次又要从头传。折腾了小半天,最后我把方案改成了基于HTML5的大文件分片上传,顺带把断点续传也做了。这个方案不依赖任何第三方库,浏览器原生的File、Blob、XMLHttpRequest就能搞定,而且实测下来,就算断网、刷新页面、关了浏览器再打开,也能从上次的位置接着传。如果你是前端开发者,或者在项目里被大文件上传坑过,这篇文章应该能给你一个可以直接落地的完整参考。

1. 为什么大文件上传必须分片:直传大文件的三个真实痛点

很多人第一次接到大文件上传需求时,第一反应是“把文件放进FormData,POST给后端不就完事了吗”。小文件确实可以,但文件一旦上了几百MB甚至几个GB,这个“朴素方案”会接连踩中三个硬伤。

1.1 痛点一:请求超时与连接中断

浏览器发起的HTTP请求不是无限期的。网络代理层、网关、负载均衡器,甚至你自家框架里的超时中间件,通常都有默认超时设置,很多只有几十秒。一个1GB文件按5Mbps上传带宽算,大概要传27分钟,这中间任何一秒的网络抖动、网关回收连接、服务器重启,都会让整个请求断掉。更麻烦的是,XMLHttpRequest一旦断开,不会自动续传,前端只能收到一个error事件,然后你面对的就是“又要重新传一遍”的残酷现实。

这还不算完。很多公司内部系统还套了一层反向代理,代理的读超时如果设置得不够大,大文件上传在代理层就会被掐断,前端看着进度条走了几分钟,最后突然报错,连服务器日志里都找不到完整记录。

1.2 痛点二:服务器内存与全局失败风暴

如果后端用的是普通的multipart解析逻辑,文件会被整体缓存到内存或临时目录里,等整个请求接收完再写入目标位置。一个小文件无所谓,但几个GB的文件一旦并发上来,服务器内存会肉眼可见地飙高,其他接口跟着变慢甚至卡死。这种“一个上传拖垮整个服务”的事故,我在不同项目里见过不止一次。

即便后端会把临时文件落盘到磁盘,整个上传期间也要持续占用一个连接、一个请求上下文、一坨内存缓冲。你想想,一个上传接口被几个大文件占满,其他正常业务请求就得排队,这叫“全局失败风暴”——不是上传本身失败,而是它把整个系统的稳定性拖垮了。

1.3 痛点三:全量重传的时间成本与用户体验

这个问题最直观。网络断了、浏览器崩了、用户不小心关了标签页,全都意味着从头再来。1GB文件传到80%突然断了,这80%的数据一点忙都帮不上。对一个要传教学视频、设计图纸、数据库备份的用户来说,重传一次可能就是一个下午。

所以分片上传不是炫技,它本质上是把“一次搞一个大文件”拆成“多次搞一小块文件”,让每个子请求都足够小、足够快、足够可控。这个思路一旦确立,断点续传、秒传、并发加速、失败重试全都顺理成章。

2. HTML5为分片上传提供的四张王牌

分片上传这个概念本身不新,但真正让前端开发者能在浏览器里原生实现它,靠的是HTML5时代普及的几个关键能力。我把它们称为“四张王牌”,理解了它们,后面的代码就是水到渠成。

2.1 王牌一:File对象与Blob.slice切片能力

HTML5里,<input type="file">选中的文件会得到一个File对象。File继承自Blob,而Blob有一个slice(start, end)方法,可以从文件中间切出一段新的Blob。这意味着文件可以在浏览器本地被“切成”任意多个小块,每一块都能独立上传。

这个能力在那个年代是革命性的——以前没有这个API,文件只能整个儿交给表单,想做分片只能靠Flash或者ActiveX插件。现在原生就能切片:

const file = input.files[0]; const chunkSize = 2 * 1024 * 1024; // 2MB一片 let start = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); // 这一片chunk就是一个独立Blob,可以单独上传 start = end; }

注意:file.slice兼容性在现代浏览器里已经非常好了,但如果你要兼容很老的环境,可以补个兜底:const slice = file.slice || file.webkitSlice || file.mozSlice;。

2.2 王牌二:XMLHttpRequest Level 2的上传进度事件

XMLHttpRequest升级到Level 2之后,不仅能传二进制数据,还暴露了upload.onprogress事件。这个事件会定期触发,给你loaded和total两个值,也就是“当前请求已经上传了多少字节”和“总共有多少字节”。

这事放在分片上传里有多重要?因为每个分片都有自己的进度,前端就可以精确计算整个文件的上传百分比。如果你用的是fetch做上传,它到现在都没有标准的原生上传进度事件,还得靠ReadableStream模拟,非常别扭。所以分片上传的经验做法是:用XMLHttpRequest,别用fetch。

const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { console.log(`上传进度:${e.loaded}/${e.total}`); } }; xhr.send(formData);

2.3 王牌三:FormData与二进制数据的原生支持

HTML5的FormData可以直接append一个Blob对象,浏览器会自动把它编码成multipart/form-data的一部分发出去。也就是说,前端可以像“上传一个普通小文件”那样上传任意一个分片:

const formData = new FormData(); formData.append('fileId', fileId); formData.append('index', chunkIndex); formData.append('chunk', chunkBlob, fileName);

后端收到的就是一个标准的文件上传请求,处理逻辑和普通上传几乎一样,只是多几个自定义字段标识这是哪个文件的哪一片。这一点让前后端对接变得极其顺利。

2.4 王牌四:本地存储与文件标识的组合

断点续传需要一个很关键的东西——文件标识。你不能靠内存里的JavaScript变量去记录“上次传到了哪里”,用户一刷新就全没了。但HTML5给了localStorage、IndexedDB,甚至SessionStorage,足够你存下来文件标识和已传分片列表。配合服务端的已传分片查询,就能做到关掉浏览器再打开也能接着传。

我见过很多人做断点续传只做了“前端临时变量”,刷新页面就失效,这其实是半成品。真正可用的断点续传必须满足:服务端知道哪些分片已经收到了,前端只需要问一下就能接续。

3. 前端完整实现:从文件切片到整体进度计算的代码拆解

前面讲了原理,这里是核心实现。我会把一段可以直接复制改用的前端代码拆开讲,每一段都说明为什么这么写。

3.1 生成文件唯一标识:决定能否断点续传的第一步

断点续传的第一件事,不是切片,而是想明白“怎么识别同一个文件”。我的做法是用文件的三要素:文件名、文件大小、最后修改时间。这三个字段组合起来,基本能唯一标识一个文件。因为用户编辑过文件,lastModified一定会变;换成另一个文件,大小大概率变;同名同大小同修改时间,通常就是同一个文件。

考虑可读性和长度,我会对这个字符串做个散列,生成一个短标识:

function createFileId(file) { const raw = `${file.name}-${file.size}-${file.lastModified}`; let hash = 0; for (let i = 0; i < raw.length; i++) { hash = (hash << 5) - hash + raw.charCodeAt(i); hash |= 0; } const salt = Math.random().toString(36).slice(2, 8); return `${Math.abs(hash).toString(36)}-${salt}`; }

有一个需要提醒的点:如果你对“文件是否真的是同一个”要求特别严格,比如担心有人改文件内容但保留了大小和修改时间,那就得做内容级Hash。可以使用SparkMD5之类的库对文件内容算摘要,但这意味着要把整个文件读一遍,几个GB的文件在浏览器里做全量Hash耗时可能达到几十秒甚至几分钟。所以我的经验是:普通业务用元数据标识就够了,秒传和内容校验可以做抽样Hash,比如读取文件头、中间、尾部的几KB内容参与计算,性价比高得多。

3.2 分片容器设计:状态机与队列

分片不是随便切完扔出去就完了,每个分片都有生命周期:待上传、上传中、成功、失败、已跳过。我用一个对象数组来管理它们,每个分片内部维护自己的状态、重试次数、Blob数据。

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB,具体怎么选后面专门说 function createChunks(file, chunkSize = CHUNK_SIZE) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end), size: end - start, status: 'pending', // pending | uploading | done | error | skipped retryCount: 0 }); start = end; } return chunks; }

最后一片的大小往往小于chunkSize,所以结束边界一定要用Math.min去卡,否则切片会越界。这个细节我见过新手踩坑,直接file.slice(i * chunkSize, (i + 1) * chunkSize),最后一刀就崩了。

3.3 单分片上传:Promise封装与重试逻辑

每个分片用Promise包一层,内部用XHR做实际网络请求。这里我会加入一个重试机制:单个分片失败后,不立刻放弃整个文件,而是等一会儿再试一次。默认重试3次,间隔逐步拉长。

function uploadChunk(fileId, chunks, chunk) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('fileId', fileId); formData.append('index', chunk.index); formData.append('total', chunks.length); formData.append('chunk', chunk.blob, file.name); xhr.open('POST', '/api/upload/chunk'); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { chunk.loaded = e.loaded; updateGlobalProgress(chunks); } }; xhr.onload = () => { if (xhr.status === 200) { chunk.status = 'done'; resolve(); } else { reject(new Error(`分片${chunk.index}上传失败,HTTP ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error(`分片${chunk.index}网络异常`)); xhr.send(formData); }); } async function uploadWithRetry(fileId, chunks, chunk, maxRetry = 3) { for (let attempt = 0; attempt <= maxRetry; attempt++) { try { chunk.status = 'uploading'; await uploadChunk(fileId, chunks, chunk); return; } catch (err) { chunk.retryCount++; if (attempt < maxRetry) { await sleep(1000 * (attempt + 1)); // 1s、2s、3s退避 } else { chunk.status = 'error'; throw err; } } } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

这里要解释一下为什么用xhr.onerror而不是xhr.onload里判断:网络断开、DNS解析失败、连接被重置这些情况会走onerror;而HTTP返回4xx/5xx会走onload且status非200。前者是“根本没连上”,后者是“服务器明确拒绝了分片”,两者的处理策略可能不同。为简单起见,我统一在onload判断status,onerror单独处理网络异常。实际项目里你可以给这两类错误配置不同的重试次数。

3.4 并发调度与整体进度计算

一次把所有分片全发出去,浏览器会排队,服务器也会扛不住。我一般是控制同时上传的请求数,常见并发值是4~6个。实现一个简单的并发池:

async function runWithConcurrency(tasks, limit) { const executing = new Set(); for (const task of tasks) { const p = Promise.resolve().then(task); executing.add(p); const clean = () => executing.delete(p); p.then(clean, clean); if (executing.size >= limit) { await Promise.race(executing); } } await Promise.all(executing); }

整体进度怎么算?很多人只数“完成了几个分片”,但这样在单个分片内部传输时进度条是不动的,体验很差。正确做法是把“已完成分片的字节数”加上“当前正在上传分片的已传字节数”,再除以文件总大小:

let doneBytes = 0; const totalBytes = chunks.reduce((sum, c) => sum + c.size, 0); function updateGlobalProgress(chunks) { let loadedBytes = 0; for (const c of chunks) { if (c.status === 'done') loadedBytes += c.size; else if (c.status === 'uploading' && c.loaded) loadedBytes += c.loaded; } const percent = Math.round((loadedBytes / totalBytes) * 100); // 更新页面进度条 progressBar.style.width = percent + '%'; progressText.textContent = percent + '%'; }

这个算法唯一要注意的是,一个分片的上传进度是增量上报的,多次触发progress事件时,不要用“当前分片已传字节”覆盖“上一个分片已完成的字节”,因为c.loaded只属于当前分片。上面用done状态累加完整分片大小,再加uploading分片的实时进度,不会重复计算,也不会漏算。

4. 断点续传的实现思路:校验接口、已传分片查询与秒传联动

分片上传做得再花哨,如果没解决“重新打开页面还能续传”的问题,就只是进度条好看了点。断点续传的核心,是让前端每次发起上传前,先问服务端“这个文件已经传了多少”。

4.1 检查接口:续传的地基

服务端需要提供一个查询接口,入参是文件标识fileId,出参是该文件已接收到的分片序号数组。我用GET请求,方便浏览器缓存也方便排查:

GET /api/upload/check?fileId=xxxx-xxxx

响应示例:

{ "chunkIndexes": [0, 1, 2, 4, 5] }

看到没有,3不在数组里,说明第3片还没收到。前端拿到这个数组后,只需要把本地的分片队列过滤一遍,只传缺失的那几片。这一步不需要把文件重新读一遍,因为切片逻辑是纯函数,同样的文件永远切成同样的分片。

4.2 前端续传流程:过滤已传分片与秒传

续传流程放在整个上传流程的最前面。用户选择文件后,立即生成fileId,马上请求check接口,然后才决定接下来干什么:

async function startUpload(file) { const fileId = createFileId(file); const chunks = createChunks(file); // 1. 先查服务端已有哪些分片 const res = await fetch(`/api/upload/check?fileId=${fileId}`); const { chunkIndexes } = await res.json(); const uploadedSet = new Set(chunkIndexes || []); // 2. 如果所有分片都在,说明文件之前已经传完了 if (chunks.every((c) => uploadedSet.has(c.index))) { // 直接触发合并,用户感知就是“秒传” await requestMerge(fileId, file.name, chunks.length, file.size); return; } // 3. 过滤出未上传的分片,加入并发池 const pendingChunks = chunks.filter((c) => !uploadedSet.has(c.index)); const tasks = pendingChunks.map((c) => () => uploadWithRetry(fileId, chunks, c)); await runWithConcurrency(tasks, 4); // 4. 全部成功后再通知服务端合并 await requestMerge(fileId, file.name, chunks.length, file.size); }

这段代码里最妙的点是“秒传”。用户之前完整传过一个文件,服务端已经有全部分片,那check接口返回的chunkIndexes就会包含所有序号。前端逻辑直接跳到requestMerge,浏览器都不用再传任何数据,服务端把已存在的分片合并成完整文件。这就是网盘“秒传”的底层原理——服务器上已经有你的文件内容了,不需要重复传输。

4.3 暂停、取消与恢复的完整实现

暂停的本质是中断尚未完成的分片请求,同时保留已成功分片的状态。我维护一个全局的activeRequests集合,暂停时遍历并调用xhr.abort():

const activeRequests = new Set(); let isPaused = false; function uploadChunkWithTrack(fileId, chunks, chunk) { return new Promise((resolve, reject) => { // ...创建xhr和formData... activeRequests.add(xhr); xhr.onload = () => { activeRequests.delete(xhr); if (xhr.status === 200) resolve(); else reject(new Error(...)); }; xhr.onerror = () => { activeRequests.delete(xhr); reject(new Error(...)); }; xhr.send(formData); }); } function pauseUpload() { isPaused = true; activeRequests.forEach(xhr => xhr.abort()); activeRequests.clear(); showStatus('已暂停,进度将保留'); }

恢复的时候更简单:因为暂停时那些正在传的分片还没收到成功响应,服务端不会把它们当作有效分片;重新点击“继续上传”时,再次调用check接口,把已成功的过滤掉,只传没成功的。这个过程你会发现,暂停和断网在逻辑上是同一件事——中断后必须靠服务端的状态恢复,而不是靠前端缓存。

有一个容易被忽略的坑:xhr.abort()之后,服务器端如果已经接收到该分片的全部数据但还没来得及返回响应,那么这个分片可能会被保存为有效分片。下次check查询时它会出现在已传列表里,这是可以的,不算错。但如果服务器只接收了一半,就可能会残留半个分片文件。我在下一章会讲服务端怎么用.part文件机制避开这个问题。

5. 服务端配合怎么做:Node.js接收分片与合并的完整示例

前端再厉害,服务端不配合也白搭。这一章用一个完整的Node.js示例讲清楚服务端的三个接口怎么写,以及我最在意的落盘细节。

5.1 服务端职责与目录结构

服务端只需要做三件事:接收分片、查询已传分片、合并分片。目录结构建议这样:

uploads/ ├── temp/ │ └── {fileId}/ │ ├── chunk-0 │ ├── chunk-1 │ └── chunk-2.part └── data/ └── final-filename.mp4

临时目录按fileId隔离,每个文件的切片放在自己的文件夹里,合并完成后再整体删除。这比你用数据库记录分片位置更简单可靠,文件系统本身就是最好的“状态存储”。

5.2 接收分片:busboy解析与.part落盘细节

我用busboy解析multipart表单,因为你无法直接用Node的核心库优雅地解析文件流。安装依赖:npm install busboy。

const http = require('http'); const fs = require('fs'); const path = require('path'); const busboy = require('busboy'); const TEMP_DIR = path.join(__dirname, 'uploads', 'temp'); const DATA_DIR = path.join(__dirname, 'uploads', 'data'); fs.mkdirSync(TEMP_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true }); const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); // 接收单个分片 if (req.method === 'POST' && url.pathname === '/api/upload/chunk') { const bb = busboy({ headers: req.headers }); let fileId = ''; let index = -1; bb.on('field', (name, val) => { if (name === 'fileId') fileId = val; if (name === 'index') index = Number(val); }); bb.on('file', (name, stream, info) => { const dir = path.join(TEMP_DIR, fileId); fs.mkdirSync(dir, { recursive: true }); // 关键:先写成.part,全部接收完再改名 const partPath = path.join(dir, `chunk-${index}.part`); const finalPath = path.join(dir, `chunk-${index}`); const out = fs.createWriteStream(partPath); stream.pipe(out); out.on('close', () => { fs.renameSync(partPath, finalPath); }); }); bb.on('close', () => { res.writeHead(200); res.end('ok'); }); req.pipe(bb); } });

这个.part机制特别重要,我特意拿出来说。假设前端上传第3片到一半断网了,服务端可能已经创建了一个残缺文件。如果没有.part机制,这个残缺文件会被check接口当作“已上传分片”返回,前端就会跳过它,最后合并时得到一个损坏的文件。改成先写.part、写完再改名后,check接口只认不带.part后缀的文件,残缺分片永远不会被误认为有效分片。

5.3 合并分片:流式合并与完整性校验

前端所有分片都传完后,会请求合并接口。服务端要按分片序号顺序读取,用流式方式写入目标文件,避免一次性把所有数据读进内存。

// 合并分片 if (req.method === 'POST' && url.pathname === '/api/upload/merge') { let body = ''; req.on('data', (d) => (body += d)); req.on('end', () => { const { fileId, name, total } = JSON.parse(body); const fileDir = path.join(TEMP_DIR, fileId); const missing = []; // 1. 完整性校验:必须所有分片都在 for (let i = 0; i < total; i++) { if (!fs.existsSync(path.join(fileDir, `chunk-${i}`))) { missing.push(i); } } if (missing.length > 0) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: '缺失分片', missing })); return; } // 2. 按顺序流式合并 const destPath = path.join(DATA_DIR, name); const out = fs.createWriteStream(destPath); let current = 0; function appendNext() { if (current >= total) { out.end(); fs.rmSync(fileDir, { recursive: true, force: true }); // 合并完清理临时目录 res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ url: `/download/${name}` })); return; } const rs = fs.createReadStream(path.join(fileDir, `chunk-${current}`)); rs.pipe(out, { end: false }); rs.on('end', () => { current++; appendNext(); }); rs.on('error', () => { res.writeHead(500); res.end('合并失败'); }); } appendNext(); }); }

合并时务必做“缺失分片校验”,我见过有人合并时只管按序号读文件,结果有几个分片丢了,最后合并出的文件播放到一半突然花屏。虽然前端理论上不会漏传,但网络和并发场景下服务端检查一遍更稳妥。

5.4 检查接口:返回已存在分片

查询接口很简单,列出临时目录里所有chunk-前缀文件,解析出序号返回:

if (req.method === 'GET' && url.pathname === '/api/upload/check') { const fileId = url.searchParams.get('fileId'); const dir = path.join(TEMP_DIR, fileId); let chunkIndexes = []; if (fs.existsSync(dir)) { chunkIndexes = fs.readdirSync(dir) .filter((f) => f.startsWith('chunk-') && !f.endsWith('.part')) .map((f) => Number(f.replace('chunk-', ''))); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ chunkIndexes })); }

注意一定要过滤掉.part后缀的文件,原理前面讲过了。另外这里直接用同步readdirSync是为了示例简洁,生产环境建议改成异步版本,或者在项目里用koa/express。

6. 实战避坑与调优经验:并发数、切片大小与网络中断实测

代码能跑通是第一步,真正好用是第二步。这一章全部来自我实际踩坑和压测后的调优经验。

6.1 切片大小怎么选:从网速和重试成本分析

切片大小没有标准答案,但有几个约束条件。片越小,单次请求越快,失败重传的成本越低;但片越多,请求数越多,网络开销和服务器处理次数也越大。

切片大小1GB文件分片数适用场景
256KB4096片弱网环境,重试成本优先,但请求量巨大
2MB512片常规Web应用,大多数项目我推荐这个
10MB102片内网带宽充足,追求传输效率

我自己的推荐区间是1MB~5MB。低于1MB,分片数量太多,每个请求的HTTP头开销加起来不可忽略,服务器日志量也非常可观;高于10MB,弱网下传一片要等很久,中断了损失又大,重试粒度太粗。稳妥起见的默认值是2MB,绝大多数场景不会出大问题。

6.2 并发数设置与浏览器连接限制

浏览器对同一域名的并发连接数有限制,HTTP/1.1下通常是6个左右。如果你一次发10个分片请求,超出部分的请求不会立即发出,而是在浏览器队列里排队,这可能让你误以为“并发已经满了”,实际上是浏览器没放行。我的经验是前端并发限制在4~6个,既不会挤爆连接,又能保证带宽被充分利用。

并发太高还有一个隐藏问题:网络稍有波动,大量分片同时失败重试,服务端日志瞬间刷屏,前端也容易陷入“全部重试”的抖动循环。把并发限制在4~6个,重试压力可控得多。

6.3 弱网与中断实测:重试机制和progress事件兼容

我在测试环境做过一次真实模拟:上传到中途把WiFi断开,大概有3个分片受影响失败。由于重试机制有退避间隔,恢复网络后这3个分片被重新上传成功,整个文件最终合并完成,全程没有人工干预。这就是分片上传比单请求上传“抗造”的最直观体现。

还有一个小经验:progress事件里的total在某些环境下可能是0,虽然现代浏览器很少出现,但写代码时最好加个判断:

if (e.lengthComputable && e.total > 0) { chunk.loaded = e.loaded; }

否则你会算出NaN,进度条直接变成一条不动的杠。

6.4 服务端容易被忽略的三个坑

第一个坑是反向代理的client_max_body_size。很多人以为前端分了片就万事大吉,结果分片本身有2MB,网关默认限制才1MB,上传请求直接403。这个配置经常藏在公司统一的网关层,不是后端代码能控制的,一定要提前确认。

第二个坑是磁盘空间和临时目录的权限。合并后临时目录必须清理,否则每个上传过的文件都会留下几百个分片文件,几天就能把磁盘塞满。上面代码里合并成功后rmSync删掉了临时目录,生产环境建议再写一个定时任务,清理超过24小时没合并的临时目录。

第三个坑是重复上传与文件覆盖问题。如果两个用户同时上传同名文件,DATA_DIR里的目标文件会被互相覆盖。实际项目里应该在合并时生成唯一文件名,比如加时间戳或UUID,然后再把原始文件名存进数据库做映射。文件的真实存储名和对外展示名,从一开始就要分开。

6.5 进阶方向:分片校验、Web Worker与更完善的秒传

如果你想把方案做到生产级,还有几个方向可以走:

分片校验。目前我们只做了“分片是否存在”的校验,没有校验“分片内容是否完整”。可以给每个分片算MD5,上传时带给服务端,服务端落盘后比对摘要。合并时再对整个文件算摘要,确保合并结果干净。这会增加计算量,但文件完整性要求高的场景必须做。

Hash计算放进Web Worker。给一个几个GB的文件做内容级Hash时,UI线程会被卡死,鼠标都拖不动。用Web Worker在后台线程算,主线程只管进度条。

用IndexedDB记录上传状态。localStorage容量太小,存不了多少信息。IndexedDB可以存fileId对应的文件名、大小、上传时间、已传分片集合,用户下次选中文件时前端主动匹配历史记录,体验更顺滑。

最后再分享一点个人体会

分片上传和断点续传这套方案,真正落地时大概率会遇到“前端写好了,后端不配合”的协作问题。我的建议是,先把check、chunk、merge这三个接口的协议定义清楚,用一份简单的接口文档或者一个JSON示例固定下来,再各自开发。后端可以先只用curl模拟分片请求,前端也可以用Mock数据先跑通流程,两边最后联调时会省很多时间。

还有一个实际使用中发现的小技巧:上传大文件时,页面不要用普通的表单提交那种方式管理状态,把上传器的核心逻辑做成一个独立的类,内部维护好分片队列和并发池,UI层只负责调用start、pause、resume和接收进度回调。这样哪怕以后换了Vue、React,甚至换成了小程序,逻辑代码都能直接复用。

如果你正在被大文件上传折磨,希望这篇东西能帮你少走点弯路。分片上传不神秘,断点续传也不复杂,把上面几块拼起来,就是一个经得起断网和刷新考验的完整方案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进