上传个大文件,网断了、浏览器崩了、用户跑了。别急,这一篇全搞定。
咳咳,大伙看见”大文件上传”这几个字也别急着划走,我知道这个话题听起来有点”老生常谈”,什么分片上传、断点续传,相信大家都听过无数遍了。但笔者今天不是来给大家念PPT的——要知道以前我们聊大文件上传,要么是网上找个轮子直接用,要么是copy几段代码改改就完事,结果一遇到个实际问题就抓瞎:分片大小怎么定?进度怎么算?服务器那边怎么合并?万一中途断了怎么办?
今天笔者就结合自己的实战经验,跟大家聊聊怎么从零实现一个大文件上传功能,核心是”分片”二字,但咱不只讲概念,而是手把手带你撸代码,保证你看完就能用到项目里。
为什么大文件上传是个”老大难”问题?
大家平时调接口传个头像、传个文档,文件三五MB已经顶天了,用 FormData 直接一把梭就完事:
| const formData = new FormData(); formData.append('file', file); await axios.post('/api/upload', formData);
|
但一旦遇到大文件——比如一个500MB的视频、一个2GB的设计源文件——一系列的问题就来了:

经常上传小视频的伙伴应该都遇到这样的问题;不过解决问题的思路呢也很简单:把大文件拆成小块,一块一块传,最后在服务器合并。
这就跟寄快递一样——你要寄一个大件的家具,肯定不会整体搬运,而是拆成一个个零件打包,到货了再组装。分片上传也是这个道理:
| 大文件(500MB) ↓ 拆成 10 个分片(每个 50MB) ↓ 逐个上传分片(1MB、2MB...) ↓ 服务器按顺序合并分片 ↓ 还原成完整文件
|
这样做有三个好处:
- 每个分片请求时间短,不容易超时
- 某个分片失败了只需重传那一个,不用全部重来
- 可以并发上传多个分片,速度翻倍
前置知识:File、Blob、ArrayBuffer搞清楚
在开始说分片代码之前,咱得先搞清楚浏览器里几个跟文件相关的概念,不然后面代码里看到slice、ArrayBuffer这些API就懵了。
Blob —— 二进制大对象
Blob的全称是Binary Large Object,即二进制大对象。MDN对它的定义是:
Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成ReadableStream来用于数据操作。
简单来说,Blob就是一个装着原始数据的容器,你可以把它想象成一个只读的文件“替身”——它像文件,但又不完全是文件。使用 Blob() 构造函数创建 Blob 对象:
| const textBlob = new Blob(['Hello, World!'], { type: 'text/plain' });
const jsonBlob = new Blob( [JSON.stringify({ name: '大文件上传', version: '1.0' }, null, 2)], { type: 'application/json' } );
const multiBlob = new Blob(['第一部分数据', '第二部分数据'], { type: 'text/plain' });
|
Blob只有两个属性,都是只读的
| 属性 |
类型 |
描述 |
| size |
number |
Blob 对象的字节数 |
| type |
string |
Blob 对象的 MIME 类型 |
| stream |
ReadableStream |
Blob 对象的可读流 |
| bytes |
Promise |
异步读取 Blob 对象的 Uint8Array 内容 |
| const blob = new Blob(['Hello'], { type: 'text/plain' }); console.log(blob.size); console.log(blob.type);
|
Blob提供的方法主要用于读取和切片
| 方法 |
返回值 |
描述 |
| slice |
Blob 对象 |
从 Blob 对象中提取指定范围的子 Blob |
| text |
Promise |
异步读取 Blob 对象的文本内容 |
| arrayBuffer |
Promise |
异步读取 Blob 对象的 ArrayBuffer 内容 |
| const largeBlob = new Blob(['A'.repeat(1024 * 1024)]); const chunkSize = 256 * 1024; const firstChunk = largeBlob.slice(0, chunkSize);
console.log(firstChunk.size);
const text = await blob.text(); console.log(text);
const buffer = await blob.arrayBuffer(); console.log(buffer.byteLength);
|
File —— 用户文件的具体化身
File是Blob 的“亲儿子”——它继承自 Blob,在 Blob 的基础上扩展了与用户文件系统相关的元信息。MDN对File的定义是:
File 接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。
File 对象是一种特定类型的 Blob,并且可以在 Blob 可以使用的任何上下文中使用。
File对象在网页上通常来自两个渠道:
- 用户通过
<input type="file">选择文件后,从返回的 FileList 中获取
- 拖拽操作中,从
DataTransfer对象中获取
虽然实际开发中很少手动构造 File(通常由浏览器帮我们创建),但了解其构造方式有助于理解:
| const file = new File(['文件内容'], 'example.txt', { type: 'text/plain', lastModified: Date.now() });
|
File继承了Blob 的所有属性(size、type),并额外增加了:
| 属性 |
类型 |
描述 |
| name |
string |
文件名 |
| lastModified |
number |
文件最后修改时间,单位为毫秒 |
| lastModifiedDate |
Date |
文件最后修改时间,单位为 Date 对象 |
| const input = document.querySelector('input[type="file"]'); input.addEventListener('change', (e) => { const file = e.target.files[0]; console.log(file.name); console.log(file.size); console.log(file.type); console.log(file.lastModified); });
|
File 没有自己独有的实例方法,所有方法都继承自 Blob——也就是说,File 可以使用 Blob 的所有方法:slice()、arrayBuffer()、text()、stream() 等
ArrayBuffer
如果说Blob是“装着数据的文件容器”,那么 ArrayBuffer 就是一块原始的、固定长度的内存。MDN 对 ArrayBuffer 的定义是:
ArrayBuffer 对象用来表示通用的原始二进制数据缓冲区。它是一个字节数组,通常在其他语言中称为“byte array”。
它的构造方法如下:
| new ArrayBuffer(length, options)
|
- length:要创建的缓冲区大小(字节数)
- options(可选):maxByteLength,指定缓冲区可以调整到的最大大小
| const buffer = new ArrayBuffer(8); console.log(buffer.byteLength);
const resizableBuffer = new ArrayBuffer(8, { maxByteLength: 16 }); resizableBuffer.resize(12); console.log(resizableBuffer.byteLength);
|
三者关系
现在我们把三个概念串起来看,它们之间有一条清晰的“血缘线”:
File是Blob的亲儿子 —— File 继承了 Blob 的一切,你可以把 File 直接当作 Blob 来用。两者的核心区别只有一个:File 带了文件名和修改时间,而 Blob 没有。换句话说,用户通过 <input type="file"> 选中的每一个文件,本质上都是一个“带名字的 Blob”。
Blob和ArrayBuffer是“搭档关系”,而不是继承关系。一个Blob可以转换为ArrayBuffer,一个ArrayBuffer也可以包装成Blob,它们之间可以互相“变身”。
在大文件上传中,它们各司其职,主要流程如下:
- 用户选择文件时,你拿到的是 File。它告诉你文件名是什么、文件多大、最后什么时候修改的——这些信息需要展示给用户看。
- 分片时,你调用的是File的slice() 方法,返回的是 Blob。每个分片Blob被塞进FormData发送给后端。
- 计算文件哈希(MD5)时,你用ArrayBuffer。哈希计算库(spark-md5)需要逐字节处理数据,而Blob本身是一个“黑箱”,你不能直接操作它里面的字节。这时候就需要把Blob转成ArrayBuffer,让哈希库去读取。
- 秒传靠的是哈希值:前端算好 MD5,发给后端一查,存在就秒过
- 断点续传靠的是哈希值 + 已上传分片记录:前端每次恢复上传前,先拿着哈希值去问后端“哪些分片已经传过了”,然后只传缺失的那些。
一句话总结三者的关系:
- File 是用户给你的“原件”,带名字带属性,从它开始
- Blob 是File切出来的“碎片”,只管数据,不管名字,用于传输和存储
- ArrayBuffer是Blob的“内脏”,让你能直接操作字节,用于计算和校验

搞懂这三者的关系和转换,接下来的分片上传实现就会顺理成章。
前端实现:Vue3+TypeScript写分片上传
前面理论基础铺垫完成后,这一节我们来真正动手写代码。前端是大文件上传的“指挥部”——它负责切分文件、调度分片、跟踪进度、处理异常。我会用 Vue3的组合式API配合TypeScript,实现整个上传逻辑。
文件分片 —— 分而治之
切片是大文件上传的第一步,也是最基础的一步。理解切片的核心逻辑,后面的流程就顺了。切片大小(chunk size)没有标准答案,通常建议在 1MB ~ 10MB 之间。选多大取决于几个因素:
| 因素 |
切片偏小 |
切片偏大 |
| 网络 |
单片失败影响小,容易恢复 |
单片失败影响大,重传成本高 |
| 并发效率 |
可并发数多,但请求开销大 |
并发数少,请求开销小 |
| 服务器压力 |
请求频繁,CPU 负担重 |
请求次数少,但单次占用连接时间长 |
| 内存占用 |
内存占用小,但需要更多请求 |
内存占用大,但请求次数少 |
实践中,5MB是一个比较折中的选择,很多云存储服务也默认采用这个值。你也可以根据网络环境动态调整——移动端网络不稳定,可以设小一点(如 2MB);内网或高速宽带可以设大一点(如 10MB)。
切片函数利用了File对象的slice方法,返回一个新的Blob对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
export function splitFile( file: File, chunkSize: number = 5 * 1024 * 1024, ): Blob[] { const chunks: Blob[] = []; let cur = 0;
while (cur < file.size) { const chunk = file.slice(cur, cur + chunkSize); chunks.push(chunk); cur += chunkSize; }
return chunks; }
|
需要注意的时,slice函数是浅拷贝,它并没有真正复制数据,只是创建了一个指向原文件数据片段的新引用——所以哪怕切了100个分片也并不会占用100倍的内存。
文件哈希计算
在上一节我们完成了文件切片,但还差一个关键步骤:给文件算一个“身份证号”;这个“身份证号”就是文件的哈希值(通常用 MD5 算法计算)。在大文件上传中,哈希值有三个核心用途:
- 秒传:上传前先把哈希值发给后端,后端一查——这个文件已经有人传过了,直接返回成功,省去了全部上传过程
- 断点续传:用哈希值去问后端“这个文件我传了多少了”,后端返回已上传的分片列表,前端只补传缺失的分片
- 完整性校验:上传完成后,后端可以比对文件哈希,确认传输过程中数据没有被损坏
前端计算MD5最常用的库是SparkMD5。它的核心优势是支持增量计算——你可以把文件分片一块一块地喂给它,它一边吃一边算,最后吐出一个完整的MD5值。这种方式内存占用极低,非常适合大文件。核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
export async function calcFileHash(chunks: Blob[]): Promise<string> { const fileReader = new FileReader(); const spark = new SparkMD5.ArrayBuffer();
let currentChunk = 0;
function loadNext() { fileReader.readAsArrayBuffer(chunks[currentChunk]); }
return new Promise((resolve, reject) => { if (chunks.length === 0) { resolve(''); return; }
fileReader.onload = (e) => { spark.append(e.target?.result as ArrayBuffer); currentChunk++;
if (currentChunk === chunks.length) { resolve(spark.end()); } else { loadNext(); } };
fileReader.onerror = () => { resolve(""); };
loadNext(); }); }
|
这里我们传入上一节切割的文件分片Blob数组,由于SparkMD5是异步计算,我们一次只读取一个分片到内存,当所有分片都计算完成后,我们返回一个Promise<string>,值就是文件的MD希值。
除了借助FileReader,我们还可以使用Blob.arrayBuffer()函数,直接将文件转换为ArrayBuffer,再用SparkMD5计算哈希值,让代码更简洁,避免事件回调。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
export async function calcFileHashNew(chunks: Blob[]): Promise<string> { if (chunks.length === 0) { return ''; }
const spark = new SparkMD5.ArrayBuffer();
for (const chunk of chunks) { const buffer = await chunk.arrayBuffer(); spark.append(buffer); } return spark.end(); }
|
进阶:文件抽样计算哈希值
在上一节我们讨论了如何使用SparkMD5增量计算来计算大文件的哈希值。这个方案虽然已经不错,但笔者必须诚实地说——当文件达到几个GB甚至几十GB时,即便是增量计算,仍然需要耗费相当可观的时间。
笔者在开发过程中就遇到过这样的场景:用户试图上传一个5GB的虚拟机镜像,MD5计算跑了将近1分钟。这1分钟里用户盯着进度条一动不动,然后才真正进行分片传输的流程,这种体验实在谈不上优雅。
于是就有了一个在“准确性”和“计算速度”之间寻找平衡的思路:不计算完整哈希,而是通过抽样来生成一个足够可靠的“文件指纹”,这就是本节要讲的方案。
| async function calcFileHashSampling( chunks: Blob[], fileSize: number, ): Promise<string> { if (chunks.length === 0) { return ''; }
if (chunks.length <= 20) { return calcFileHashNew(chunks); } }
|
首先如果分片数只有20个(按 5MB 一片算,就是 100MB 以内的文件),抽样的意义不大——完整计算也花不了多少时间,何必为了省那零点几秒而引入额外的逻辑和风险?直接走完整哈希路径,准确又省心。
| const sampleIndices = new Set<number>([0, totalChunks - 1]);
|
文件头部往往包含元数据(比如图片的 EXIF 信息、文档的文件头),文件尾部通常包含校验信息或结尾标记。这两个位置的“信息熵”最高,区分度最强,优先把它们纳入采样范围。
| const middleSampleCount = Math.min(8, Math.max(1, Math.floor(totalChunks / 10)));
|
紧接着,有一套平滑的采样策略:分片总数除以 10,下限为 1,上限为 8。也就是说:
- 50 个分片 → 采 5 个中间点
- 100 个分片 → 采 8 个中间点(已达上限)
- 1000 个分片 → 仍然只采 8 个中间点
这样设计的用意是:对于超大文件,采样点数量不会无限制增长,计算耗时被严格控制在可接受范围内。上限取8是一个经验值——8 个采样点加上首尾 2 个,总共10个分片,即使每个分片5MB,一次抽样哈希读取的数据量也就50MB,加上SparkMD5的计算时间,用户体验几乎是瞬时的。
| const step = totalChunks / (middleSampleCount + 1); for (let i = 1; i <= middleSampleCount; i++) { const index = Math.floor(i * step); sampleIndices.add(index); }
|
想象你有一本书,你要通过读其中的几页来判断这本书是不是某个特定的版本。你不会只读前10页,也不会随机翻几页,而是会均匀地选取——开头、中间偏前、正中间、中间偏后、结尾。代码里的 step 就是干这个事的,它让采样点尽可能均匀地分布在文件各处。
| const sizeText = new TextEncoder().encode(String(fileSize)); spark.append(sizeBuffer);
|
这是防止哈希碰撞的关键一招。举个例子:文件A和文件B完全不同,但如果它们恰好在采样的那10个分片上内容完全一致(虽然概率极低),这两个文件就会被误判为相同。把文件大小作为hash输入的一部分,相当于给这个指纹加了一道“保险”——就算采样点撞了,文件大小不同,最终的hash还是不一样。
| const sortedIndices = Array.from(sampleIndices).sort((a, b) => a - b);
for (const index of sortedIndices) { const buffer = await chunks[index].arrayBuffer(); spark.append(buffer); }
return spark.end();
|
最后对sampleIndices进行排序,确保计算顺序稳定。
分片上传
上面我们的分片的函数有了,计算文件哈希值的函数也有了,下一步就是把文件“大卸八块”后,再把它们一个一个发出去;首先我们需要一个Upload组件来选择文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <template> <n-upload ref="uploadRef" @change="handleFileChange" > <n-button> 选择文件 </n-button> </n-upload> </template> <script setup lang="ts"> const currentFile = ref<File | null>(null);
const handleFileChange = (options: { file: UploadFileInfo }) => { if (options.file.file) { currentFile.value = options.file.file; } }; </script>
|
文件存到currentFile变量,我们在页面上使用一个开始上传的按钮:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <template> <n-button type="primary" @click="startUpload"> 开始上传 </n-button> </template> <script setup lang="ts">
const CHUNK_SIZE = 1024 * 1024 * 5;
const startUpload = async () => { if (!currentFile.value) return; const chunks = splitFile(currentFile.value, CHUNK_SIZE); const fileHash = await calcFileHashNew(chunks); } </script>
|
我们获取chunks分片数组以及文件Hash值,下一步就是上传分片了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const startUpload = async () => { const total = chunks.length; let completed = 0; const uploadTasks = chunks.map((chunk, index) => { return async () => { await uploadChunk(chunk, index, total, fileHash); completed++; uploadProgress.value = Math.round((completed * 100) / total); }; }); await Promise.all(uploadTasks.map((task) => task())); }
|
但是如果我们打开控制台,如果分片数量过多(上百个),浏览器会同时发起大量请求,可能导致浏览器资源紧张或服务端限流;这个时候使用p-limit库控制同时进行的请求数,避免过度并发。
| import pLimit from "p-limit"; const startUpload = async () => { const limit = pLimit(5);
const tasks = chunks.map((chunk, index) => limit(async () => { await uploadChunk(chunk, index, total, fileHash); completed++; uploadProgress.value = Math.round((completed * 100) / total); }), );
await Promise.all(tasks); }
|
这样我们的文件每次上传5个分片,避免了浏览器资源紧张或服务端限流。

后端实现:Express接收分片并合并
上一节我们完成了前端的工作——文件被切成小块,一块一块地往前端发送。现在轮到后端登场了:它要负责接收这些分片、暂存起来,并在所有分片到齐后把它们拼回一个完整的文件。
在正式实现代码之前,我们先设计一下后端上传的文件目录结构:
| 项目根目录/ └── uploads/ # 所有上传文件的根目录 ├── chunks/ # 分片暂存区 │ ├── {fileHash1}/ # 按文件 hash 分目录 │ │ ├── chunk-0 │ │ ├── chunk-1 │ │ └── chunk-2 │ └── {fileHash2}/ │ ├── chunk-0 │ └── chunk-1 └── {fileHash}-{filename} # 合并后的完整文件
|
uploads目录是我们所有上传文件保存的目录,因此需要将其添加到.gitignore文件中,避免被提交到版本控制中;chunks目录下,按照文件hash值分目录,每个目录下存储该文件的所有分片。
当每个文件的分片全部上传后,后端需要合并这些分片,重新在uploads目录下拼回一个完整的文件,文件名就是{fileHash}-{filename};并且删除chunks目录下的该文件的分片目录。
为什么不把文件名以及文件哈希值存到数据库中?本文我们只关注文件的上传和合并,不涉及文件的存储和管理;因此不设计数据库表来存储这些信息,也不考虑同一个文件重命名后上传的情况。
接收分片
分片接收其实就相当于接受普通文件,我们使用multer库来实现;但是Multer默认的diskStorage会把文件直接写入磁盘。但问题来了:分片存储的目录依赖于fileHash,而fileHash在req.body里。用diskStorage时,文件写入磁盘的时机早于我们拿到req.body的机会,这就导致我们没办法在写入时决定存到哪个目录。
所以代码里用了memoryStorage:
| const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024, }, });
|
memoryStorage会把文件数据保存在内存中(req.file.buffer),而不是直接落盘。这样我们在中间件里拿到fileHash后,再手动把buffer写入目标目录——流程完全由我们自己控制。
uploadChunkMiddleware是整个分片接收的核心,我们来拆解一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| export const uploadChunkMiddleware = (req, res, next) => { upload.single('chunk')(req, res, (err) => { if (err) { return next(err); }
const { index, fileHash } = req.body; if (!req.file || !fileHash || index === undefined) { return next(); }
const targetDir = getChunksDir(fileHash); ensureDir(targetDir);
const targetPath = path.join(targetDir, `chunk-${index}`); fs.writeFileSync(targetPath, req.file.buffer);
next(); }); };
|
我们接受分片数据后,命名规则为chunk-${index},包含索引号,这样在合并时只需要读取目录下所有 chunk-* 文件,按索引排序即可,不需要额外维护映射表。简单、可靠、一眼就能看懂。
最后我们的路由处理器POST /upload/chunk的职责很明确——确认分片已成功保存,然后返回给前端一个确认响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| router.post("/upload/chunk", uploadChunkMiddleware, (req, res) => { const { index, totalChunks, fileHash } = req.body;
if (index === undefined || !totalChunks || !fileHash) { return res.status(400).json({ success: false, message: "缺少必要的分片信息(index/totalChunks/fileHash)", }); }
if (!req.file) { return res.status(400).json({ success: false, message: "未接收到分片文件", }); }
res.json({ success: true, message: "分片接收成功", data: { index, totalChunks, fileHash, size: req.file.size }, }); });
|
看到这里你会发现,这个接口的核心逻辑其实都在中间件里完成了,路由处理器只是做了一层校验和响应封装。这种“中间件做脏活累活,路由做轻活”的模式,让代码更加清晰可维护。
合并分片
当前端把所有分片都上传完毕后,会调用合并接口POST /upload/chunk/merge,告诉后端“所有分片都齐了,可以拼起来了”。

merge接口的路由处理器如下,首先我们进行必要参数校验:
| router.post("/upload/chunk/merge", async (req, res) => { const { fileHash, filename, totalChunks } = req.body; if (!fileHash || !filename || totalChunks === undefined) { return res.status(400).json({ success: false, message: "缺少必要的合并参数(fileHash/filename/totalChunks)", }); } })
|
接着我们校验分片的数量,这一步非常关键——如果分片数量对不上,说明传输过程中有丢包,合并出来的文件必然损坏。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const chunkDir = getChunksDir(fileHash); if (!fs.existsSync(chunkDir)) { return res.status(400).json({ success: false, message: "分片目录不存在", }); }
const chunkFiles = fs .readdirSync(chunkDir) .filter((name) => name.startsWith("chunk-")) .sort((a, b) => { const indexA = Number(a.replace("chunk-", "")); const indexB = Number(b.replace("chunk-", "")); return indexA - indexB; });
if (chunkFiles.length !== Number(totalChunks)) { return res.status(400).json({ success: false, message: `分片数量不匹配,期望 ${totalChunks} 个,实际 ${chunkFiles.length} 个`, }); }
|
后端合并分片时一定要按索引顺序进行排序。
当所有分片都乖乖躺在服务器的临时目录里之后,我们就要进入最关键的“拼图”环节了——把这一堆零散的碎片拼回一个完整的文件。下面这段代码就是完成这一步的核心操作,笔者带你逐行拆解它背后的设计思路。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const uploadsDir = getUploadsDir(); const finalFilename = `${fileHash}-${filename}`; const outputPath = path.join(uploadsDir, finalFilename);
const writeStream = fs.createWriteStream(outputPath); for (const chunkFile of chunkFiles) { const chunkPath = path.join(chunkDir, chunkFile); const chunkBuffer = fs.readFileSync(chunkPath); writeStream.write(chunkBuffer); } writeStream.end();
await new Promise((resolve, reject) => { writeStream.on("finish", resolve); writeStream.on("error", reject); });
|
前三行干的事情很简单——决定合并后的文件存在哪里、叫什么名字;接下来writeStream是合并的核心,也是最值得玩味的地方。
你可以把createWriteStream想象成一根连接磁盘的“管道”。每读取一个分片,就通过writeStream.write()往这根管道里塞一段数据。管道另一端会源源不断地把这些数据写入磁盘文件。
这样做的好处是:内存里始终只保存当前正在处理的那一个分片的数据,处理完就释放。不管文件是1GB还是10GB,合并时的内存占用几乎恒定。
秒传:如何做到“1秒上传1个G”?
实际上,秒传的本质不是“传得快”,而是“不用传”。它的工作流程是这样的:当你选中一个文件准备上传时,系统会先算出一个该文件的唯一“指纹”(也就是 fileHash),然后拿着这个指纹去服务器问一句:“这个文件您这儿有吗?”
- 如果服务器说“有”——那太好了,直接告诉前端“上传成功”
- 如果服务器说“没有”——那就老老实实走正常上传流程。
应用场景:秒传在企业网盘、云存储、内容管理系统中非常实用。举个最简单的例子——你的团队里10个人都上传了同一份产品宣传视频,如果不做秒传,这份视频就要在服务器上存10份,白白浪费10倍的存储空间和带宽。而有了秒传,第2到第10个人的上传都是一瞬间完成的。
要实现秒传,首先要让后端具备“根据fileHash查找文件”的能力。

新增一个文件检查接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| router.get("/files/check", (req, res) => { const { fileHash } = req.query; if (!fileHash) { return res.status(400).json({ success: false, message: "缺少fileHash参数", }); }
const uploadsDir = getUploadsDir(); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); }
const files = fs .readdirSync(uploadsDir) .filter((filename) => !fs.statSync(path.join(uploadsDir, filename)).isDirectory()); const matchedFilename = files.find((filename) => filename.startsWith(`${fileHash}-`), );
if (matchedFilename) { const fileInfo = getFileInfo(matchedFilename); return res.json({ success: true, data: { exists: true, file: fileInfo, }, }); }
res.json({ success: true, data: { exists: false, }, }); });
|
有了检查接口,前端就能在“上传前”判断文件是否已存在。但是,如果两个用户同时上传同一个文件呢?可能出现这样一种情况:用户A的检查请求说“不存在”,用户B的检查请求也说“不存在”,然后A和B同时开始上传,最后导致文件被重复写入。
我们在分片合并接口中增加检查,作为最后的兜底:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| router.post("/upload/chunk/merge", async (req, res) => { const uploadsDir = getUploadsDir(); const finalFilename = `${fileHash}-${filename}`; const outputPath = path.join(uploadsDir, finalFilename); if (fs.existsSync(outputPath)) { fs.rmSync(chunkDir, { recursive: true, force: true }); const stats = fs.statSync(outputPath); return res.json({ success: true, message: "文件已存在,秒传成功", data: { filename: finalFilename, originalname: filename, size: stats.size, mimetype: getMimeType(finalFilename), path: outputPath, url: `/uploads/${finalFilename}`, }, }); } })
|
前端的改造主要集中在startUpload函数中。核心思路是:在文件大小判断之前,先统一计算哈希并进行秒传检测。
| const startUpload = async () => { const fileSize = file.size; const chunks = splitFile(file, CHUNK_SIZE); const fileHash = await calcFileHashSampling(chunks, fileSize);
const checkResponse = await checkFileExists(fileHash); if (checkResponse.data.success && checkResponse.data.data?.exists) { uploadMessage.value = "文件已存在,秒传成功!"; loadFiles(); return; } }
|
整个过程可以概括为一句话:同一个文件,第一次传是上传,第二次传是秒传。
断点续传:网络断了也不用从头再来
秒传解决的是“文件完全存在”的情况,断点续传解决的是“文件传了一半”的情况。你在下载一个大文件时,进度走到80%突然断网了——如果没有断点续传,重新连接后只能从 0% 开始重新下载。但有了断点续传,你只需要把缺失的 20% 补上就行。
上传也是同样的道理。我们之前已经实现了分片上传,每个分片都是独立上传的,这就为断点续传提供了天然的基础。断点续传的核心思想只有一句话:
只传没传完的,不传已经传好的。
后端的改造主要集中在/api/files/check接口,原来的逻辑很简单:查一下有没有完整文件;现在的逻辑需要升级,说白了就是多问一句:“如果完整文件不存在,那分片目录里有没有部分分片?”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| router.get("/files/check", (req, res) => { const chunkDir = getChunksDir(fileHash); const uploadedChunks = [];
fs.readdirSync(chunkDir) .filter((name) => name.startsWith("chunk-")) .forEach((name) => { const index = Number(name.replace("chunk-", "")); if (!isNaN(index)) { uploadedChunks.push(index); } }); uploadedChunks.sort((a, b) => a - b); const allIndices = Array.from({ length: total }, (_, i) => i); const uploadedSet = new Set(uploadedChunks); const missingChunks = allIndices.filter((i) => !uploadedSet.has(i)); res.json({ success: true, data: { exists: false, totalChunks: total, uploadedChunks, missingChunks, }, }); })
|
断点续传在前端的改造比后端复杂得多——后端只是加了一个接口逻辑,前端需要重写整个上传流程的控制层。为了让暂停和停止功能真的能“打断”正在进行的网络请求,我们需要给每个分片请求传入AbortController 的signal:
| const abortControllers = ref<Map<number, AbortController>>(new Map()); const uploadLargeFile = async (file: File, pendingIndices: number[]) => { const controller = new AbortController(); abortControllers.value.set(index, controller);
const formData = new FormData(); formData.append('chunk', chunk);
return request.post<UploadChunkResponse>('/api/upload/chunk', formData, { headers: { 'Content-Type': 'multipart/form-data' }, signal, }); }
|
上传时,每个分片都创建一个独立的AbortController,存到abortControllers这个Map里。暂停触发时,将所有的controller取消:
| const pauseUpload = () => { for (const [index, controller] of abortControllers.value) { controller.abort(); } abortControllers.value.clear(); };
|
恢复上传时,我们先根据check接口返回数据,判断是否可以秒传;然后再根据缺失的分片索引,重新发起上传请求。
| const startUpload = async () => { const checkResponse = await checkFileExists(); if (checkResponse.data.success && checkResponse.data.data?.exists) { showMessage("文件已存在,秒传成功!", "success"); return; } missingChunks = checkResponse.data.data?.missingChunks || []; await uploadLargeFile(file, missingChunks); }
|
总结
聊了这么多,我们最后回过头来梳理一下大文件上传这条路上的几个关键节点。
你可能会想,“大文件上传”这个话题听起来确实有点老生常谈,网上随便一搜就是一大堆文章和现成的轮子。但笔者写完这套代码之后最大的感受是:
真正把这个功能从头到尾捋清楚,和直接拿一个轮子来用,中间差的不只是几行代码,而是对整个流程的把控感。
回想一下,我们从最基础的概念出发——弄清楚 Blob 和 File 的区别、理解 ArrayBuffer 在哈希计算中的角色。然后动手用 File.slice() 把大文件切成小块,用 SparkMD5 算指纹,用 p-limit 控制并发。接着把分片发到后端,让 Express 配合 Multer 一片一片地接收、按顺序合并。
在这个基础上,我们又加了两层“魔法”:秒传和断点续传。
回过头来看,这套方案的实现思路其实并不复杂,但每一步都踩在了点子上:用哈希值给文件办“身份证”,用分片把大任务拆成小单元,用状态记录让上传过程可恢复。 这就是大文件上传的“三驾马车”——少一个都不完整,组合在一起才算真正解决了这个“老大难”问题。
希望这篇文章能帮你把大文件上传这件事彻底弄清楚。下次再遇到需要传大文件的场景,你心里就有底了——知道它在底层是怎么跑的,出了问题也知道从哪里下手排查。
本文源码地址