你们有没有遇到过这种场景:一个几个G的压缩包,传到一半,网络闪断,进度条直接清零,只能重头再来。如果是几十个G的素材包或者数据库备份,那真是欲哭无泪。断点续传这个需求,几乎每个做大文件上传的前端都绕不过去。今天聊的就是,用纯 JS 插件实现支持断点续传的大文件上传方案。文章会从原理讲到接口设计,再到代码实现和部署,最后是各种坑的排查方法。适合正在做上传模块、或者想给现有系统加文件传输能力的同学,前端、全栈、运维都可以参考。
1. 断点续传的整体设计与核心原理
1.1 分片上传与合并:为什么不能一口气传完
先统一认知:所谓断点续传,在大文件场景里,不是真的从网络层断点继续,而是把大文件切成很多小块,每个小块独立上传,然后服务端合并。做完编号,上传时记录哪些块已经成功,失败或中断后,只需要重传未完成的块。这样即使断点在一半,也可能只差最后几个分片,不需要全量重传。
为什么必须分片?直接整文件上传至少有四个问题。第一,浏览器和服务器都有单次请求体大小限制。Nginx 默认client_max_body_size是 1m,不管是框架自带的 bodyParser 还是网关,对超大 body 都不友好。你当然可以调大限制,但一次传 5G,请求要等几个小时,反向代理、负载均衡、防火墙全都会被这个长连接拖住。第二,网络闪断率高。长连接越久,中途失败的概率越大,一旦失败,如果没有分片,整个文件重来。第三,浏览器内存扛不住。用 FileReader 或者 FormData 把整文件读进内存,几百 MB 就够呛,几个 G 会直接卡死或崩溃。分片后单次只有几 MB,内存开销小很多。第四,无法做断线的精确定位。不切分的话,服务端最多告诉你“没收到”,你不知道哪个字节丢了,只能重传。
分片后会有一个新问题:碎片数量。2GB 文件按 2MB 分片,就是 1024 个分片,如果并发控制不好,或者服务端写得不严谨,丢错片的概率会显著上升。所以分片大小要选合适的值。我一般取 2MB 到 5MB 之间。分片太小,比如 256KB,分片数量膨胀,每个分片都有 HTTP 往返和 form-data 解析开销,浪费带宽;分片太大,比如 50MB,单块传输时间长,失败重试成本高,断点精度也变差。选择时要看目标用户网络和服务器吞吐。内网系统可以用 10MB,公网软件建议 2MB。
代码层面,前端切分非常简单,File 对象有 slice 方法,原型链上一直都有,从 IE10 开始基本可用,老 Edge、Firefox、Chrome 都支持。真正要留意的是参数边界:start 和 end 都是字节偏移,end 不能超过文件大小,否则会返回空 Blob。
const blob = file.slice(start, end);这就是分片的基础。老浏览器里可能叫webkitSlice或mozSlice,后面兼容性部分再讲。
1.2 文件指纹与秒传:用Hash判断文件能否跳过
分片只是第一步,要让断点真正可恢复,还需要给文件一个唯一标识。很多人用文件名加文件大小,这个方案我试过,坑很多:同名不同文件夹、同名文件被另存为后内容变化、上传一半文件被替换……都会导致状态错乱。我对每个任务都会生成一个 hash 值,通常用 MD5 或 SHA-1,用 SparkMD5 这类库计算。
为什么用 hash 而不用文件名?因为断点续传要解决的是“这个文件是否传过”和“哪些分片传过了”,服务端必须能根据内容区分文件。文件名随时可以改,大小也可能相同,但内容指纹基本不会变。
校验流程大概是:
- 前端在 Web Worker 里计算整个文件的 hash。
- 调用 check 接口,把 hash、文件名、总大小、总分片数发给服务端。
- 服务端判断 hash 是否存在:
- 已存在且大小一致:直接返回“秒传成功”,前端不需要再传。
- 文件不在线但之前传过一半:返回已成功上传的分片编号列表。
- 完全没传过:返回空列表,前端从 0 开始上传。
这样,断点续传的本质就变成了:在分片粒度上跳过已经上传的分片。所谓“续传”,实际上是前一次上传留下了一堆已完成的分片,这次直接沿用。
超大文件算 hash 需要时间。1GB 文件在普通笔记本上做全量 MD5,大概要 5 到 15 秒,具体取决于 CPU 和磁盘。如果每次都这样等,用户体验很差。业界有个折衷:先采样算“粗略指纹”。比如取文件开头、中间、结尾各几 MB 拼在一起算 hash,服务和端记录这个指纹,命中后可以继续传分片,等全部上传完再对最终文件做一次全量校验。这样第一次计算很快,但理论上存在冲突可能,业务上一般能接受,尤其是对内网文件。
1.3 任务状态机与生命周期:插件内部怎么运转
插件要做到可控,必须有一个清晰的状态机。每个上传任务内部至少要有这些状态:
- waiting:刚创建,还没开始。
- hashing:正在计算文件指纹。
- uploading:正在上传分片。
- paused:用户暂停,已经中断所有未完成分片的上传。
- completed:所有分片都已上传并合并成功。
- error:出现了无法自动恢复的错误。
状态机核心是:上传过程中,暂停、网络错误、服务端响应异常,都应该回退到某个可恢复的状态,而不是直接销毁任务。比如正在 uploading 时网络断了,不应该把任务置为 error,而应该进入一个“重试中”或者回到 waiting,等下一次重试计划。插件内部可以把任务放进队列,统一调度。
我用事件驱动的方式实现:插件暴露on('progress')、on('complete')、on('error'),内部状态变化触发对应回调。外层面板只关心当前进度、是否可暂停、是否已完成,不关心底层分片细节。
2. 插件架构、工具选型与前端优化
2.1 为什么做成JS插件而不是一次性脚本
很多人一上来就在 Vue 组件里写一个 uploadFile 函数,传小文件没问题,但大文件断点续传涉及 hash、重试、并发、状态恢复,代码会迅速膨胀。如果下一个项目还要用,又得复制粘贴。所以我会把它抽象成独立 JS 插件,不依赖 Vue/React,提供统一的实例和 API。
市面上的方案我也对比过:Resumable.js 是比较老牌的,支持分片和断点续传,但它的 hash 算法默认基于文件名和大小,不太够用;simple-uploader.js 是它的重写版,功能更多;WebUploader 百度出的,但已经很久不维护,而且部分实现依赖 Flash 兼容层。我不是说它们不好,而是很多场景需要跟自己的服务端接口习惯匹配,改起来可能比重写还累。
自己封装插件的好处是可控:API 按照自己项目的习惯来,接口协议自定义,错误和重试逻辑自己把握,需要支持 worker 就加 worker,想改成动态并发也方便。核心代码量并不大,后面我会把关键部分拆开讲。
一个基本插件对外只做三件事:
- 创建任务
upload(file, options) - 控制任务
pause(id)、resume(id)、cancel(id) - 监听事件
on(event, callback)
内部再分成:hash 模块、分片管理模块、上传队列模块、事件模块。职责分离以后,逻辑很清晰。
2.2 前端计算Hash的优化:Web Worker与切片采样
Hash 计算是断点续传里最容易卡死页面的环节。1GB 文件,看着进度条 80%、90%,然后页面突然白屏,多半是主线程在做全量 MD5。解决办法就是 Web Worker。
Worker 运行在独立线程,可以在后台读取文件分片、计算 hash,计算过程中页面还能继续响应。文件对象本身可以传给 Worker,Worker 里可以用FileReaderSync读取 Blob,这是 Worker 特有的同步 API,不会阻塞页面。
简单实现思路:主线程拿到 File 对象后,创建一个 Worker,把 File、chunkSize 传给 Worker。Worker 中用一个 FileReaderSync 实例每次读取一个分片并 append 到 SparkMD5 的 ArrayBuffer 实例,全部读取完毕后,把 hash 结果 postMessage 给主线程。示例代码:
// upload-worker.js importScripts('spark-md5.min.js'); self.onmessage = function (e) { const { file, chunkSize } = e.data; const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReaderSync(); let start = 0; const total = file.size; while (start < total) { const end = Math.min(start + chunkSize, total); const buffer = reader.readAsArrayBuffer(file.slice(start, end)); spark.append(buffer); start = end; self.postMessage({ type: 'progress', loaded: start, total }); } self.postMessage({ type: 'done', hash: spark.end() }); };主线程里:
const worker = new Worker('/upload-worker.js'); worker.onmessage = (e) => { if (e.data.type === 'done') { task.hash = e.data.hash; checkAndUpload(task); } }; worker.postMessage({ file, chunkSize: options.chunkSize });文件对象通过 postMessage 传给 Worker 时,走的是结构化克隆,底层共享 Blob 的数据引用,并不会真的把文件复制一份。每处理完一个分片,临时生成的 ArrayBuffer 会被 GC 回收,不会一直占着内存。如果项目工程化程度高,可以用 Vite 或 Webpack 的new Worker(new URL(...), { type: 'module' })写法;传统部署就用独立 JS 文件加 importScripts,逻辑不受影响。
2.3 并发控制与进度统计的实现思路
分片数量多了以后,能不能一股脑全部发出去?答案是不能。浏览器同一域名并发连接数有限制,HTTP/1.1 下 Chrome 一般是 6 个左右,超过会排队;服务端同时接收几十路上传请求,线程、内存都不一定扛得住。而且全量并发时,某一个分片失败重试也会和其他请求互相干扰。
我会用一个简单的并发池:维护待上传分片队列,同时执行的请求数不超过设置值,默认 3 或 5。每次完成一个,就从队列取出一个新的。伪代码如下:
async function runPool(tasks, limit) { const results = []; const executing = new Set(); for (const task of tasks) { const promise = Promise.resolve().then(() => task()); results.push(promise); executing.add(promise); const clean = () => executing.delete(promise); promise.then(clean, clean); if (executing.size >= limit) { await Promise.race(executing); } } return Promise.all(results); }进度统计也要分清两个阶段:
- 计算 hash 阶段的进度:读取了多少字节,显示“准备中 xx%”。
- 上传阶段的进度:已成功分片数 / 总分片数。
断点恢复以后,uploaded 列表一开始就包含之前完成的分片,所以进度不是从 0 开始,而是从比如 63% 开始继续走。实现上,每个分片上传成功后,把分片编号存入task.uploaded,再触发进度事件:
task.uploaded.push(index); this.emit('progress', { id: task.id, percent: uploadedCount / totalChunks * 100 });上传过程的进度需要实时推给 UI,但频率也不要太高。如果分片较小、数量较多,可以每完成 5 个或每 200ms 汇总一次,避免频繁触发 DOM 更新导致页面卡顿。
3. 完整实现:接口设计、关键代码与 Linux 部署
3.1 插件 API 设计与核心数据结构
我习惯先定好接口协议,再写前后端。前端插件里,任务的数据结构大概是:
interface UploadTask { id: string; file: File; fileName: string; fileSize: number; hash: string; chunkSize: number; totalChunks: number; uploaded: number[]; status: 'waiting' | 'hashing' | 'uploading' | 'paused' | 'completed' | 'error'; retries: number; } interface UploadOptions { chunkSize?: number; concurrency?: number; retryCount?: number; checkUrl: string; uploadUrl: string; mergeUrl: string; headers?: Record<string, string>; withCredentials?: boolean; }插件对外的方法比较简单:
class BigUploader { constructor(options: UploadOptions) {} createTask(file: File, customOptions?: Partial<UploadOptions>): string; pause(taskId: string): void; resume(taskId: string): void; cancel(taskId: string): void; on(event: 'progress' | 'complete' | 'error' | 'statuschange', handler: Function): void; removeAllListeners(event?: string): void; }createTask返回 taskId 用于后续控制。内部流程是:创建任务对象 → 启动 hash 计算 → hash 完成后调 check → 把缺失分片塞进队列 → 并发池上传 → 全部完成后调 merge → 触发 complete。
我建议把事件回调统一封装成发布订阅,避免组件里到处传函数。比如外部这样用:
const uploader = new BigUploader({ checkUrl: '/upload/check', uploadUrl: '/upload/chunk', mergeUrl: '/upload/merge' }); const taskId = uploader.createTask(file); uploader.on('progress', ({ id, percent }) => { if (id === taskId) { // 更新进度条 } });这样 Vue、React、甚至原生页面都能接入,不绑死框架。
3.2 核心流程实现:check 查询、分片上传、合并文件
下面看完整流程。假设后端是 Node.js + Express,用 multer 接收分片。
先看 check 接口。请求:
{ "hash": "7498f...", "fileName": "backup.zip", "fileSize": 2048321024, "totalChunks": 1024 }响应:
{ "exists": false, "uploadedChunks": [0, 1, 3, 4], "uploadId": "uuid-xxx" }exists 为 true 时表示秒传,前端直接走 complete 流程;false 时根据 uploadedChunks 决定要传哪些分片。上传分片时,用 FormData 提交:
const form = new FormData(); form.append('hash', task.hash); form.append('chunkIndex', index); form.append('totalChunks', task.totalChunks); form.append('chunk', blob, `part-${index}`); await fetch(options.uploadUrl, { method: 'POST', body: form, headers: options.headers });服务端接收分片并保存到临时目录:
const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const app = express(); const TMP_DIR = path.join(__dirname, 'uploads/tmp'); const FINAL_DIR = path.join(__dirname, 'uploads/files'); const upload = multer({ dest: TMP_DIR }); app.post('/upload/chunk', upload.single('chunk'), (req, res) => { const { hash, chunkIndex } = req.body; const index = Number(chunkIndex); const savedPath = path.join(TMP_DIR, `${hash}_${index}`); fs.renameSync(req.file.path, savedPath); // 用一个 Map/Redis 记录该 hash 已收到哪些分片 recordChunk(hash, index); res.json({ ok: true, received: index }); });这里有个安全细节:分片文件名用的是hash_index,hash 由我们生成,避免用户传入原始文件名导致路径穿越。生产环境如果要保留原始文件名,最好存在数据库元数据表里,而不是拼到路径上。
合并接口是后半段的关键。合并前先检查分片是否齐全,再按索引顺序写入最终文件:
const { pipeline } = require('stream/promises'); app.post('/upload/merge', async (req, res) => { const { hash, totalChunks } = req.body; const finalPath = path.join(FINAL_DIR, `${hash}.upload`); const ws = fs.createWriteStream(finalPath, { flags: 'a' }); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(TMP_DIR, `${hash}_${i}`); if (!fs.existsSync(chunkPath)) { ws.destroy(); return res.status(400).json({ ok: false, msg: `分片 ${i} 不存在` }); } await pipeline(fs.createReadStream(chunkPath), ws, { end: false }); } ws.end(); // 清理临时分片 for (let i = 0; i < totalChunks; i++) { fs.unlink(path.join(TMP_DIR, `${hash}_${i}`), () => {}); } res.json({ ok: true, url: `/files/${hash}.upload` }); });这段代码的核心思想是:分片按索引顺序流式写入同一个目标文件,写完后清理临时文件。用createReadStream + pipeline而不是一次性读入内存,就是为了避免几 GB 数据撑爆内存。end: false表示当前分片写完不要关闭 write stream,等下一个分片继续写。
3.3 服务端落盘策略与 Linux 上传配置
很多同学会问,Linux 网络大文件上传一般采用哪种方式?答案不是最大程度调大请求体限制,而是用分片 + 暂存 + 合并这套思路。分片以后,单个请求只有几 MB,Nginx 默认client_max_body_size1m 不够,需要调到 10m 左右,而不是把整个大文件一次传上来:
server { client_max_body_size 10m; location /upload/ { proxy_pass http://127.0.0.1:3000; proxy_request_buffering off; proxy_http_version 1.1; } }proxy_request_buffering off这个参数对超大上传很关键。否则即使是分片请求,Nginx 默认也会先把请求体缓冲完再转发到后端,分片大了会影响响应速度。
临时目录要单独规划。我建议在服务器上把临时目录和最终文件目录挂到独立磁盘或独立分区,防止临时文件和最终文件占满系统盘。如果项目用的是云服务器,可以把分片直接放到对象存储临时目录,合并时再由服务端流式读取。运维上还要做定时清理任务,比如删除超过 24 小时的临时分片,防止上传到一半就放弃的任务占满磁盘。
如果服务端不是 Node,换成 Java 的 MultipartFile、Python 的 FastAPI UploadFile、Go 的c.FormFile都可以,协议不变,只要接收字段一致就行。
4. 踩坑记录与问题排查实录
4.1 分片合并时文件损坏:二进制读写的坑
这个坑我踩过不止一次。表面现象是:所有分片都显示上传成功,文件也合并出来了,但解压或打开时提示文件损坏。排查下来,原因基本集中在三个地方。
第一个是合并顺序。HTTP 请求返回顺序和发起顺序不一致,服务端不能假设“后返回的分片在文件后面”,必须按 chunkIndex 排序后再合并。用流合并时,必须串行按顺序写。
第二个是二进制和文本模式。某些语言或框架写文件时默认使用文本模式,会对换行符做转换,从而破坏二进制文件。Node 的 fs 模块不会随便转换,但如果你用 Java 的 FileWriter、Python 的open(path, 'w')写分片,就很有可能踩坑。正确写法应该是 FileOutputStream、open(path, 'wb')。
第三个是分片重复写。如果网络重试导致同一个分片被上传两次,服务端没有去重,覆盖时路径又不固定,就可能出现文件错乱。所以服务端存储分片时,路径必须固定为{hash}_{index},重复上传就覆盖同一个文件,而不是每次随机生成新文件。
我自己的经验是:合并完成后,前端把整个文件的 MD5 再算一遍传给服务端,服务端对合并文件也做一次 MD5 对比。如果不一致,立刻删除重传,不要交给用户。虽然多花几秒,但能挡住绝大多数合并隐患。
4.2 断点续传恢复失败:状态记录与服务端清理机制
断点续传的核心是状态,但状态很容易丢。我遇到过一种典型情况:前端有 localStorage 记录 uploaded,用户隔了一个晚上继续上传,前端信心满满地只传剩余分片,结果服务端返回 404。原因就是服务端临时目录被清理任务删掉了,或者服务重启后内存里的状态丢了。
正确的做法是双端都对状态负责。前端保存本地的已上传分片,但只能作为“上次尝试过”的参考;服务端必须提供可靠的 check 接口,基于文件 hash 查询实际存在的分片文件。如果服务端持久化在 Redis 里,重启也不怕:
await redis.sadd(`upload:${hash}:chunks`, index); const exists = await redis.smembers(`upload:${hash}:chunks`);如果不想引入 Redis,用文件系统也能做:check 时扫描临时目录中${hash}_*前缀的文件,落到数组返回。但生产环境文件数量多时效率低,Redis 更合适。
临时文件清理要有,但不能误删正在上传的任务。建议在清理任务里加“最后修改时间”维度,比如只删除 24 小时没有更新的分片。另外,合并成功后一定要清理临时分片,避免重复占用磁盘。前端恢复时也需要处理一种情况:服务端返回的 uploadedChunks 和本地不一致。比如用户换了一台电脑,同一文件上传过一半,新电脑上本地列表是空的,但服务端有记录。这时应该以服务端为准,直接续传缺失分片。
4.3 旧系统控件替代与浏览器兼容性处理
很多传统业务系统里,“大文件上传”曾经是靠 ActiveX 控件解决的,比如 NTKO 大文件上传控件。这类控件需要客户端安装插件,只能在 IE 或旧版 Edge 下运行,到了现代浏览器环境往往加载不出来。替换方案就是纯前端 JS 插件,基于 HTML5 的 File API、Blob.slice、XMLHttpRequest/fetch、Web Worker,不依赖任何安装包。
我接到过这类需求,处理思路是:先做能力检测,确认浏览器是否支持File.prototype.slice、Worker、FormData。如果不支持,给出明确提示或者降级为普通小文件上传,不要再尝试安装控件。能力检测代码:
const canUpload = typeof File !== 'undefined' && File.prototype && File.prototype.slice; const canUseWorker = typeof Worker !== 'undefined';在老版本 Safari 里,File.slice可能叫webkitSlice,需要做兼容垫片:
if (!File.prototype.slice && File.prototype.webkitSlice) { File.prototype.slice = File.prototype.webkitSlice; }另外,有人会问“alook浏览器js插件大全”这类移动端浏览器插件和上传方案有没有关联。我的看法是,移动端轻量化场景下,大文件上传更多依赖系统浏览器能力和 WebView 的 API;一套标准 JS 插件只要能跑在 WebView 里,比绑定特定浏览器插件可靠得多。所以重点是把插件做成标准 Web API 的实现,而不是依赖某个魔改浏览器特性。
4.4 高频问题排查速查表
整理一个表,方便上线后对照:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 上传到一半刷新页面,进度丢失 | 本地状态没保存 | 在 hash 完成后把任务信息写入 localStorage,恢复时重新 check |
| 合并后文件大小对不上 | 分片缺失或重复,合并顺序不对 | 服务端合并前校验分片连续性,合并后做 MD5 对比 |
| 浏览器标签页卡死 | hash 计算在主线程执行 | 把 hash 计算迁到 Web Worker |
| 上传速度很慢 | 并发数太低或网络往返多 | 适当调高 concurrency;大分片用并发 3 到 5 个 |
| 服务端磁盘被临时文件撑满 | 上传任务未完成且没有清理 | 定时清理超过 TTL 的临时分片,合并后立即清理 |
| 跨域请求无法携带 Cookie | fetch/XHR 没开相应选项 | 设置withCredentials: true,服务端 CORS 允许凭证 |
| 分片上传偶发 413 | 网关或 Nginx 请求体限制太小 | 调整client_max_body_size |
| Vue/React 中状态不同步 | 插件事件未取消监听 | 组件卸载时调用removeAllListeners防止重复注册 |
这张表不是万能,但覆盖了大多数项目上线前后最常遇到的基础问题。
5. 从“能用”到“好用”:体验优化与安全加固
5.1 暂停/恢复、秒传与断线自动重试
做上传功能,不能只把分片传上去就算完,用户体验和稳定性同样重要。我觉得有三个功能是必须的:暂停/恢复、秒传、自动重试。
暂停/恢复的实现不复杂:暂停时,把当前并发池里的请求 abort 掉,任务状态改为 paused;恢复时,重新从任务队列里取出未完成分片继续上传。注意 abort 请求后,可能有人已经成功上传,需要在恢复后重新 check 一下 uploadedChunks,避免重复传。
自动重试要设置重试上限和退避策略。比如分片请求失败后,隔 500ms、1s、2s 重试,最多 3 次;连续失败过多,把任务置为 error,但不销毁,用户可以手动点击重试。不要无限重试,不然服务端挂掉时前端会一直在打请求。
秒传是收益最明显的体验优化。同一个文件如果别人已经传过,check 接口直接返回 exists,进度条直接跳到 100%,用户几乎感觉不到上传过程。大文件场景里,秒传能省下大量带宽和服务器存储。
5.2 安全校验与生产环境注意事项
上传接口是最容易被人利用的功能之一。分片接口如果不校验身份,别人可以直接 POST 一堆垃圾分片填满磁盘。生产环境至少要做这几件事:
- 登录态校验:所有上传和合并接口必须校验用户身份,不能匿名上传。
- 文件类型和大小限制:前端可以做,但后端必须再校验。分片阶段可以校验总大小和分片数量,合并后要检查文件头或魔数(magic number),不要只看扩展名。
- Hash 限流:对 check 接口做频率限制,防止有人批量扫描是否已存在文件。
- 合并后杀毒或内容检测:如果面向公网用户,建议对最终文件做安全扫描,避免恶意文件流转。
- 临时文件路径安全:分片文件名使用服务端生成的上传标识或 hash,绝不要直接用用户原始文件名拼接路径。
5.3 后续扩展:断点续传之外还能做什么
如果你已经实现了一个完整的断点续传 JS 插件,其实稍作扩展就能获得更多收益。比如在上传完成后加入文件秒传,在服务端维护一个文件 hash 到存储地址的映射表,重复内容直接复用存储,这在企业网盘、素材管理系统里非常有用。再比如把分片上传和云端转码、解压任务串联起来,边传边处理,缩短用户等待时间。这些都是断点续传方案本身带来的衍生红利,不需要推翻重做。
我个人在实际项目里的另一个体会是:不要把所有逻辑都塞进前端。真正可靠的上传方案,前端只负责把分片数据送出去,状态的真实性必须由服务端保证。包括已上传列表、分片完整性、合并结果校验,后端多花点心思,前端才不会天天被用户投诉“又传失败了”。如果你正在设计或者重构上传模块,可以把这套流程和坑对照一遍,至少能少走很多弯路。