大文件上传很难?学会这招,GB文件秒传不是梦

  上传个大文件,网断了、浏览器崩了、用户跑了。别急,这一篇全搞定。

  咳咳,大伙看见”大文件上传”这几个字也别急着划走,我知道这个话题听起来有点”老生常谈”,什么分片上传、断点续传,相信大家都听过无数遍了。但笔者今天不是来给大家念PPT的——要知道以前我们聊大文件上传,要么是网上找个轮子直接用,要么是copy几段代码改改就完事,结果一遇到个实际问题就抓瞎:分片大小怎么定?进度怎么算?服务器那边怎么合并?万一中途断了怎么办?

  今天笔者就结合自己的实战经验,跟大家聊聊怎么从零实现一个大文件上传功能,核心是”分片”二字,但咱不只讲概念,而是手把手带你撸代码,保证你看完就能用到项目里。

为什么大文件上传是个”老大难”问题?

  大家平时调接口传个头像、传个文档,文件三五MB已经顶天了,用 FormData 直接一把梭就完事:

1
2
3
const formData = new FormData();
formData.append('file', file);
await axios.post('/api/upload', formData);

  但一旦遇到大文件——比如一个500MB的视频、一个2GB的设计源文件——一系列的问题就来了:

问题

  经常上传小视频的伙伴应该都遇到这样的问题;不过解决问题的思路呢也很简单:把大文件拆成小块,一块一块传,最后在服务器合并

  这就跟寄快递一样——你要寄一个大件的家具,肯定不会整体搬运,而是拆成一个个零件打包,到货了再组装。分片上传也是这个道理:

1
2
3
4
5
6
7
8
9
大文件(500MB)

拆成 10 个分片(每个 50MB)

逐个上传分片(1MB、2MB...

服务器按顺序合并分片

还原成完整文件

这样做有三个好处:

  1. 每个分片请求时间短,不容易超时
  2. 某个分片失败了只需重传那一个,不用全部重来
  3. 可以并发上传多个分片,速度翻倍

前置知识:File、Blob、ArrayBuffer搞清楚

  在开始说分片代码之前,咱得先搞清楚浏览器里几个跟文件相关的概念,不然后面代码里看到sliceArrayBuffer这些API就懵了。

Blob —— 二进制大对象

  Blob的全称是Binary Large Object,即二进制大对象。MDN对它的定义是:

Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成ReadableStream来用于数据操作。

  简单来说,Blob就是一个装着原始数据的容器,你可以把它想象成一个只读的文件“替身”——它像文件,但又不完全是文件。使用 Blob() 构造函数创建 Blob 对象:

1
2
3
4
5
6
7
8
9
10
11
// 创建一个文本 Blob
const textBlob = new Blob(['Hello, World!'], { type: 'text/plain' });

// 创建一个 JSON Blob
const jsonBlob = new Blob(
[JSON.stringify({ name: '大文件上传', version: '1.0' }, null, 2)],
{ type: 'application/json' }
);

// 从多个数据片段创建 Blob
const multiBlob = new Blob(['第一部分数据', '第二部分数据'], { type: 'text/plain' });

  Blob只有两个属性,都是只读的

属性 类型 描述
size number Blob 对象的字节数
type string Blob 对象的 MIME 类型
stream ReadableStream Blob 对象的可读流
bytes Promise 异步读取 Blob 对象的 Uint8Array 内容
1
2
3
const blob = new Blob(['Hello'], { type: 'text/plain' });
console.log(blob.size); // 5
console.log(blob.type); // "text/plain"

  Blob提供的方法主要用于读取和切片

方法 返回值 描述
slice Blob 对象 从 Blob 对象中提取指定范围的子 Blob
text Promise 异步读取 Blob 对象的文本内容
arrayBuffer Promise 异步读取 Blob 对象的 ArrayBuffer 内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 分片 —— 大文件上传的核心操作
const largeBlob = new Blob(['A'.repeat(1024 * 1024)]); // 1MB 数据
const chunkSize = 256 * 1024; // 256KB 一片
const firstChunk = largeBlob.slice(0, chunkSize);

console.log(firstChunk.size); // 262144

// 异步读取为文本
const text = await blob.text();
console.log(text); // "Hello"

// 异步读取为 ArrayBuffer
const buffer = await blob.arrayBuffer();
console.log(buffer.byteLength); // 5

File —— 用户文件的具体化身

  File是Blob 的“亲儿子”——它继承自 Blob,在 Blob 的基础上扩展了与用户文件系统相关的元信息。MDN对File的定义是:

File 接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。

File 对象是一种特定类型的 Blob,并且可以在 Blob 可以使用的任何上下文中使用。

  File对象在网页上通常来自两个渠道:

  1. 用户通过<input type="file">选择文件后,从返回的 FileList 中获取
  2. 拖拽操作中,从DataTransfer对象中获取

  虽然实际开发中很少手动构造 File(通常由浏览器帮我们创建),但了解其构造方式有助于理解:

1
2
3
4
const file = new File(['文件内容'], 'example.txt', {
type: 'text/plain',
lastModified: Date.now()
});

  File继承了Blob 的所有属性(size、type),并额外增加了:

属性 类型 描述
name string 文件名
lastModified number 文件最后修改时间,单位为毫秒
lastModifiedDate Date 文件最后修改时间,单位为 Date 对象
1
2
3
4
5
6
7
8
9
// 用户选择文件后获取 File 对象
const input = document.querySelector('input[type="file"]');
input.addEventListener('change', (e) => {
const file = e.target.files[0];
console.log(file.name); // "profile.jpg"
console.log(file.size); // 245760 (继承自 Blob)
console.log(file.type); // "image/jpeg" (继承自 Blob)
console.log(file.lastModified); // 1698768000000
});

  File 没有自己独有的实例方法,所有方法都继承自 Blob——也就是说,File 可以使用 Blob 的所有方法:slice()、arrayBuffer()、text()、stream() 等

ArrayBuffer

  如果说Blob是“装着数据的文件容器”,那么 ArrayBuffer 就是一块原始的、固定长度的内存。MDN 对 ArrayBuffer 的定义是:

ArrayBuffer 对象用来表示通用的原始二进制数据缓冲区。它是一个字节数组,通常在其他语言中称为“byte array”。

  它的构造方法如下:

1
new ArrayBuffer(length, options)
  • length:要创建的缓冲区大小(字节数)
  • options(可选):maxByteLength,指定缓冲区可以调整到的最大大小
1
2
3
4
5
6
7
8
// 创建一个 8 字节的 ArrayBuffer
const buffer = new ArrayBuffer(8);
console.log(buffer.byteLength); // 8

// 创建一个可调整大小的 ArrayBuffer(最大 16 字节)
const resizableBuffer = new ArrayBuffer(8, { maxByteLength: 16 });
resizableBuffer.resize(12); // 调整到 12 字节
console.log(resizableBuffer.byteLength); // 12

三者关系

  现在我们把三个概念串起来看,它们之间有一条清晰的“血缘线”:

  File是Blob的亲儿子 —— File 继承了 Blob 的一切,你可以把 File 直接当作 Blob 来用。两者的核心区别只有一个:File 带了文件名和修改时间,而 Blob 没有。换句话说,用户通过 <input type="file"> 选中的每一个文件,本质上都是一个“带名字的 Blob”。

  Blob和ArrayBuffer是“搭档关系”,而不是继承关系。一个Blob可以转换为ArrayBuffer,一个ArrayBuffer也可以包装成Blob,它们之间可以互相“变身”。

  在大文件上传中,它们各司其职,主要流程如下:

  1. 用户选择文件时,你拿到的是 File。它告诉你文件名是什么、文件多大、最后什么时候修改的——这些信息需要展示给用户看。
  2. 分片时,你调用的是File的slice() 方法,返回的是 Blob。每个分片Blob被塞进FormData发送给后端。
  3. 计算文件哈希(MD5)时,你用ArrayBuffer。哈希计算库(spark-md5)需要逐字节处理数据,而Blob本身是一个“黑箱”,你不能直接操作它里面的字节。这时候就需要把Blob转成ArrayBuffer,让哈希库去读取。
  4. 秒传靠的是哈希值:前端算好 MD5,发给后端一查,存在就秒过
  5. 断点续传靠的是哈希值 + 已上传分片记录:前端每次恢复上传前,先拿着哈希值去问后端“哪些分片已经传过了”,然后只传缺失的那些。

  一句话总结三者的关系:

  • 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
/**
* 将文件分片
* @param file 原文件
* @param chunkSize 分片大小(字节),默认 5MB
*/
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
/**
* 计算文件 hash(用于标识文件唯一性,实现秒传/断点续传)
* 使用 spark-md5 库
*/
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) => {
// 空数组直接返回空内容 hash
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
/**
* 新版文件 hash 计算(基于 Blob.arrayBuffer 实现)
* 使用原生的 Blob.arrayBuffer() 替代 FileReader,代码更简洁,且无需事件回调
*/
export async function calcFileHashNew(chunks: Blob[]): Promise<string> {
// 空数组直接返回空内容 hash
if (chunks.length === 0) {
return '';
}

const spark = new SparkMD5.ArrayBuffer();

for (const chunk of chunks) {
// 将每个分片转换为 ArrayBuffer 并追加到 md5 计算器
const buffer = await chunk.arrayBuffer();
spark.append(buffer);
}
return spark.end();
}

进阶:文件抽样计算哈希值

  在上一节我们讨论了如何使用SparkMD5增量计算来计算大文件的哈希值。这个方案虽然已经不错,但笔者必须诚实地说——当文件达到几个GB甚至几十GB时,即便是增量计算,仍然需要耗费相当可观的时间。

  笔者在开发过程中就遇到过这样的场景:用户试图上传一个5GB的虚拟机镜像,MD5计算跑了将近1分钟。这1分钟里用户盯着进度条一动不动,然后才真正进行分片传输的流程,这种体验实在谈不上优雅。

  于是就有了一个在“准确性”和“计算速度”之间寻找平衡的思路:不计算完整哈希,而是通过抽样来生成一个足够可靠的“文件指纹”,这就是本节要讲的方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function calcFileHashSampling(
chunks: Blob[],
fileSize: number,
): Promise<string> {
// 空文件或空分片数组,返回空 hash
if (chunks.length === 0) {
return '';
}

// 小文件直接计算完整 hash,避免抽样导致精度损失
if (chunks.length <= 20) {
return calcFileHashNew(chunks);
}
}

  首先如果分片数只有20个(按 5MB 一片算,就是 100MB 以内的文件),抽样的意义不大——完整计算也花不了多少时间,何必为了省那零点几秒而引入额外的逻辑和风险?直接走完整哈希路径,准确又省心。

1
2
// 采样点索引集合:始终包含首尾分片
const sampleIndices = new Set<number>([0, totalChunks - 1]);

  文件头部往往包含元数据(比如图片的 EXIF 信息、文档的文件头),文件尾部通常包含校验信息或结尾标记。这两个位置的“信息熵”最高,区分度最强,优先把它们纳入采样范围。

1
2
// 中间区域采样点数量:随总分片数增加,最多 8 个
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的计算时间,用户体验几乎是瞬时的。

1
2
3
4
5
const step = totalChunks / (middleSampleCount + 1);
for (let i = 1; i <= middleSampleCount; i++) {
const index = Math.floor(i * step);
sampleIndices.add(index);
}

  想象你有一本书,你要通过读其中的几页来判断这本书是不是某个特定的版本。你不会只读前10页,也不会随机翻几页,而是会均匀地选取——开头、中间偏前、正中间、中间偏后、结尾。代码里的 step 就是干这个事的,它让采样点尽可能均匀地分布在文件各处。

1
2
const sizeText = new TextEncoder().encode(String(fileSize));
spark.append(sizeBuffer);

  这是防止哈希碰撞的关键一招。举个例子:文件A和文件B完全不同,但如果它们恰好在采样的那10个分片上内容完全一致(虽然概率极低),这两个文件就会被误判为相同。把文件大小作为hash输入的一部分,相当于给这个指纹加了一道“保险”——就算采样点撞了,文件大小不同,最终的hash还是不一样。

1
2
3
4
5
6
7
8
9
// 按索引排序,确保 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">
// 单个分片大小为 5MB
const CHUNK_SIZE = 1024 * 1024 * 5;

const startUpload = async () => {
if (!currentFile.value) return;
// 文件分割
const chunks = splitFile(currentFile.value, CHUNK_SIZE);
// 计算文件Hash值
const fileHash = await calcFileHashNew(chunks);
// todo 上传分片
}
</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;

// 创建任务数组,每个任务返回一个 Promise
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库控制同时进行的请求数,避免过度并发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pLimit from "p-limit";
const startUpload = async () => {
const limit = pLimit(5);

// 创建带限流的任务数组,每个任务立即执行但受 concurrency 控制
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接收分片并合并

  上一节我们完成了前端的工作——文件被切成小块,一块一块地往前端发送。现在轮到后端登场了:它要负责接收这些分片、暂存起来,并在所有分片到齐后把它们拼回一个完整的文件。

  在正式实现代码之前,我们先设计一下后端上传的文件目录结构:

1
2
3
4
5
6
7
8
9
10
11
项目根目录/
└── 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

1
2
3
4
5
6
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 单个分片最大 10MB
},
});

  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接口的路由处理器如下,首先我们进行必要参数校验:

1
2
3
4
5
6
7
8
9
10
// 合并分片为完整文件
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函数中。核心思路是:在文件大小判断之前,先统一计算哈希并进行秒传检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
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, // 👈 关键:传入 AbortSignal
});
}

  上传时,每个分片都创建一个独立的AbortController,存到abortControllers这个Map里。暂停触发时,将所有的controller取消:

1
2
3
4
5
6
7
const pauseUpload = () => {
// 中断所有正在进行的请求
for (const [index, controller] of abortControllers.value) {
controller.abort();
}
abortControllers.value.clear();
};

  恢复上传时,我们先根据check接口返回数据,判断是否可以秒传;然后再根据缺失的分片索引,重新发起上传请求。

1
2
3
4
5
6
7
8
9
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 一片一片地接收、按顺序合并。

  在这个基础上,我们又加了两层“魔法”:秒传和断点续传。

  回过头来看,这套方案的实现思路其实并不复杂,但每一步都踩在了点子上:用哈希值给文件办“身份证”,用分片把大任务拆成小单元,用状态记录让上传过程可恢复。 这就是大文件上传的“三驾马车”——少一个都不完整,组合在一起才算真正解决了这个“老大难”问题。

  希望这篇文章能帮你把大文件上传这件事彻底弄清楚。下次再遇到需要传大文件的场景,你心里就有底了——知道它在底层是怎么跑的,出了问题也知道从哪里下手排查。

本文源码地址


本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。