前言

大文件上传是一个比较通用的功能,基本上只要涉及到一些文件上传的功能,都需要使用大文件上传功能

大文件上传步骤

  1. 获取用户上传的文件
  2. 对文件进行格式校验(可选)
  3. 对文件进行切片,并计算每个切片的 hash 值
  4. 上传每个切片
  5. 合并上传的切片

具体实现

前端界面部分

我们先创建一个 html 文件,然后粘贴下面的代码进行界面的初始化

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>大文件上传</title>
</head>

<body>
<input id="fileInput" type="file" />
<button id="uploadButton">上传</button>
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.7.2/axios.min.js"></script>
<script src="./index.js"></script>
<script>
window.onload = () => {
// 获取文件选择框和上传按钮
const fileInput = document.getElementById("fileInput");
const uploadButton = document.getElementById("uploadButton");
// 全局文件对象
let file = null;
// 文件选择事件
fileInput.onchange = (event) => {
const fileList = event.target.files;
if (fileList.length) {
file = fileList[0];
}
};
// 上传按钮点击事件
uploadButton.addEventListener("click", () => {
if (file) {
uploadFile(file);
}
});
};
</script>
</body>
</html>

界面很简单,就是一个文件选择框和一个上传按钮,里面的 js 代码就是获取界面这两个元素然后绑定事件,当用户选择文件后,会将文件对象保存在全局变量 file 中,然后调用 uploadFile 函数进行上传

html 界面导入的两个库一个是 spark-md5,一个是 axios,spark-md5 用于计算文件的 hash 值,axios 用于发送请求

index.js 文件是我们待会要编写的脚本文件

js 代码部分

js 代码部分我们一步一步按照顺序编写

请求使用 axios 库进行发送,这里不对 axios 语法进行讲解

创建 uploadFile 函数

uploadFile 函数接受三个参数,分别是文件对象、切片大小、上传后的文件名称,因为上传文件是比较耗时的操作,所以我们要异步处理,返回一个 Promise 对象,并使用 try…catch 捕获异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 上传文件函数
* @param {*} file 要上传的文件对象
* @param {*} chunkSize 切片大小
* @param {*} fileName 上传后的文件名
* @returns
*/
const uploadFile = (file, chunkSize = 1024 * 1024 * 2, fileName) => {
return new Promise(async (resolve, reject) => {
try {
// 上传成功
resolve(true);
} catch (error) {
// 上传失败
reject(error);
}
});
};

接着我们继续完善

创建 cutFile 函数

上传文件之前我们肯定要对文件进行切片

那么如何切片呢,其实我们可以将一个文件理解为由许多个数据块组成,那么比如一个 11.5 兆的文件,我们可以将其分为 6 个数据块,其中 5 块是 2M,1 块是 1.5M

那么我们先计算文件有多少个数据块,用文件大小除以切片大小,然后取整,得到切片的数量,接着使用 for 循环,调用 file.slice 方法,将文件切片,并将切片对象添加到 chunks 数组中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 将文件进行切片并计算hash值
* @param {*} file 文件对象
* @param {*} chunkSize 切片大小
* @returns {Promise}
*/
const cutFile = async (file, chunkSize = 1024 * 1024 * 2) => {
// 计算切片数量
let chunkCount = Math.ceil(file.size / chunkSize);
// 切片数组
const chunks = [];
for (let i = 0; i < chunkCount; i++) {
// 对文件进行切片
const blob = file.slice(i * chunkSize, (i + 1) * chunkSize);
chunks.push({
blob,
index: i,
start: i * chunkSize,
end: (i + 1) * chunkSize,
});
}
};

创建 calculateHash 函数

将文件切片之后,我们就要计算 hash 值了,hash 值是什么?为什么要计算 hash 值

hash 值就是一种摘要算法,它将任意长度的数据转换为固定长度的字符串,这个字符串就是 hash 值,相同的数据必然会得到相同的 hash 值,不同的数据必然会得到不同的 hash 值

有了它服务器就会检查这个 hash 值来确定这个文件是否已经上传过了,如果相同的文件上传过了,就不用再上传了

我们这里使用 spark-md5 库来计算 hash 值,spark-md5 库是一个快速的 md5 算法的 javascript 实现,速度非常快

同样计算 hash 是一个费时过程,我们使用 Promise 进行封装处理

这里计算的方式是,将文件的第一个数据块、最后一个数据块、中间所有数据块的前 2 字节、中间 2 字节后 2 字节拼接起来,然后形成一个 Blob 对象,使用这个 Blob 对象进行 hash 值的计算

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
/**
* 计算文件hash值
* @param {*} chunks 文件切片
* @param {*} chunkSize 每个切片大小
* @returns {Promise}
*/
const calculateHash = (chunks, chunkSize) => {
return new Promise((resolve, reject) => {
try {
const target = []; // 要计算的目标字节数组
const fileReader = new FileReader(); // 文件读取器
const spark = new SparkMD5.ArrayBuffer(); // spark-md5对象
// 文件读取完成后计算hash值
fileReader.onload = (e) => {
const data = e.target.result;
spark.append(data);
// 将计算完的hash值返回
resolve(spark.end());
};
// 计算目标字节数组
chunks.forEach((chunk, index) => {
// 第一和最后一个切片全部参与计算
if (index === 0 || index === chunks.length - 1) {
target.push(chunk.blob);
} else {
// 中间切片只参与计算前2个字节、中间2个字节、后2个字节
target.push(chunk.blob.slice(0, 2));
target.push(chunk.blob.slice(chunkSize / 2, chunkSize / 2 + 2));
target.push(chunk.blob.slice(chunkSize - 2, chunkSize));
}
});
// 将目标字节数组转换为blob对象然后进行读取
fileReader.readAsArrayBuffer(new Blob(target));
} catch (error) {
reject(error);
}
});
};

修改 cutFile 函数

接着我们修改 cutFile 函数,在 cutFile 函数中调用 calculateHash 函数,并将计算后的 hash 值添加到每个切片对象中

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
/**
* 将文件进行切片并计算hash值
* @param {*} file 文件对象
* @param {*} chunkSize 切片大小
* @returns {Promise}
*/
const cutFile = async (file, chunkSize = 1024 * 1024 * 2) => {
// 计算切片数量
let chunkCount = Math.ceil(file.size / chunkSize);
// 切片数组
const chunks = [];
for (let i = 0; i < chunkCount; i++) {
// 对文件进行切片
const blob = file.slice(i * chunkSize, (i + 1) * chunkSize);
chunks.push({
blob,
index: i,
start: i * chunkSize,
end: (i + 1) * chunkSize,
});
}
// 计算文件hash值
const fileHash = await calculateHash(chunks, chunkSize);
// 为每个切片添加hash值
chunks.forEach((chunk, index) => {
chunk.hash = `${fileHash}-${index}`;
});
return {
chunks,
fileHash,
};
};

创建 uploadChunk 函数

切完片,计算完了 hash 值,那我们是不是应该要上传了呀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 上传切片函数
* @param {*} chunk 切片
* @param {*} fileHash 文件hash值
* @returns
*/
const uploadChunk = async (chunk, fileHash) => {
// 构造FormData对象
const formData = new FormData();
// 添加表单数据
formData.append("chunkHash", chunk.hash); // 每个切片的hash值
formData.append("fileHash", fileHash); // 文件的hash值
formData.append("chunk", chunk.blob); // 每个切片的blob对象
// 发送请求
return axios.post("http://localhost:3000/chunk", formData);
};

写完了这个那我们是不是要马上遍历所有切片然后将切片上传呀?

哎,不是的,心急吃不了热豆腐

创建 uploadChunks 函数

上传前我们先思考

假如我们有一个 GB 的文件,每个数据块是 2MB,那么我们是不是要分为 512 个数据块?那么 512 个数据块一起发送到服务器吗?一次性发送 512 个请求,这样服务器会很忙的,这显然是不合适的,所以我们需要分批发送,每一批最多发送固定数量的数据块,比如 3 个,这个有点类似于生产者和消费者的关系

所以我们需要建立一个发送列表,先发送 3 个数据块,这样会生成 3 个异步任务,将这 3 个异步任务放入发送列表,这时候停止发送数据块,等待异步任务结束后,将列表中结束的异步任务删除

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
/**
* 上传文件所有的切片
* @param {*} chunks 文件切片数组
* @param {*} fileHash 文件hash值
* @returns
*/
const uploadChunks = (chunks, fileHash) => {
return new Promise(async (resolve, reject) => {
// 最大并发上传数
const maxCount = 3;
// 当前是第几个正在上传
let index = 0;
// 上传任务列表
const taskList = [];
// 循环上传切片
while (index < chunks.length) {
// 调用上传函数,得到一个Promise对象
const task = uploadChunk(chunks[index], fileHash);
// 将Promise对象添加到任务列表中
taskList.push(task);
// 当前上传索引+1
index++;
// 上传完成后,从任务列表中移除该Promise对象
task.then(() => {
taskList.splice(taskList.indexOf(task), 1);
});
// 达到最大并发数后,等待其中一个Promise对象完成
if (taskList.length >= maxCount) {
await Promise.race(taskList);
}
}
// 因为当只剩下两个任务的时候会跳出循环,所以这里需要再等待一下
// 等待所有上传任务完成
await Promise.all(taskList);
resolve(true);
});
};

创建 mergeChunks 函数

切片全部上传完了之后我们是不是就要合并切片了呀

哎,确实是这样

切片合并函数比较简单,只需要告诉后端合并哪个文件夹的切片和合并后的文件名就好了

接口地址需要改成自己实际使用的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 合并切片函数
* @param {*} fileHash 文件hash值
* @param {*} chunkSize 切片大小
* @param {*} fileName 文件名
* @returns
*/
const mergeChunks = (fileHash, chunkSize, fileName) => {
return axios.post("http://localhost:3000/merge", {
fileHash,
chunkSize,
fileName,
});
};

修改 uploadFile 函数

这样我们的函数就编写完毕了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 上传文件函数
* @param {*} file 要上传的文件对象
* @param {*} chunkSize 切片大小
* @param {*} fileName 上传后的文件名
* @returns
*/
const uploadFile = (file, chunkSize = 1024 * 1024 * 2, fileName) => {
return new Promise(async (resolve, reject) => {
try {
// 切片并计算hash值
const { chunks, fileHash } = await cutFile(file);
// 等待上传所有切片
await uploadChunks(chunks, fileHash);
// 等待合并切片
await mergeChunks(fileHash, chunkSize, fileName);
// 上传成功
resolve(true);
} catch (error) {
reject(error);
}
});
};

到这里我们整个大文件上传前端部分的代码就结束了

最终代码

javascript 代码

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
/**
* 计算文件hash值
* @param {*} chunks 文件切片
* @param {*} chunkSize 每个切片大小
* @returns {Promise}
*/
const calculateHash = (chunks, chunkSize) => {
return new Promise((resolve, reject) => {
try {
const target = []; // 要计算的目标字节数组
const fileReader = new FileReader(); // 文件读取器
const spark = new SparkMD5.ArrayBuffer(); // spark-md5对象
// 文件读取完成后计算hash值
fileReader.onload = (e) => {
const data = e.target.result;
spark.append(data);
// 将计算完的hash值返回
resolve(spark.end());
};
// 计算目标字节数组
chunks.forEach((chunk, index) => {
// 第一和最后一个切片全部参与计算
if (index === 0 || index === chunks.length - 1) {
target.push(chunk.blob);
} else {
// 中间切片只参与计算前2个字节、中间2个字节、后2个字节
target.push(chunk.blob.slice(0, 2));
target.push(chunk.blob.slice(chunkSize / 2, chunkSize / 2 + 2));
target.push(chunk.blob.slice(chunkSize - 2, chunkSize));
}
});
// 将目标字节数组转换为blob对象然后进行读取
fileReader.readAsArrayBuffer(new Blob(target));
} catch (error) {
reject(error);
}
});
};

/**
* 将文件进行切片并计算hash值
* @param {*} file 文件对象
* @param {*} chunkSize 切片大小
* @returns {Promise}
*/
const cutFile = async (file, chunkSize = 1024 * 1024 * 2) => {
// 计算切片数量
let chunkCount = Math.ceil(file.size / chunkSize);
// 切片数组
const chunks = [];
for (let i = 0; i < chunkCount; i++) {
// 对文件进行切片
const blob = file.slice(i * chunkSize, (i + 1) * chunkSize);
chunks.push({
blob,
index: i,
start: i * chunkSize,
end: (i + 1) * chunkSize,
});
}
// 计算文件hash值
const fileHash = await calculateHash(chunks, chunkSize);
// 为每个切片添加hash值
chunks.forEach((chunk, index) => {
chunk.hash = `${fileHash}-${index}`;
});
return {
chunks,
fileHash,
};
};

/**
* 上传切片函数
* @param {*} chunk 切片
* @param {*} fileHash 文件hash值
* @returns
*/
const uploadChunk = async (chunk, fileHash) => {
// 构造FormData对象
const formData = new FormData();
// 添加表单数据
formData.append("chunkHash", chunk.hash); // 每个切片的hash值
formData.append("fileHash", fileHash); // 文件的hash值
formData.append("chunk", chunk.blob); // 每个切片的blob对象
// 发送请求
return axios.post("http://localhost:3000/chunk", formData);
};

/**
* 上传文件所有的切片
* @param {*} chunks 文件切片数组
* @param {*} fileHash 文件hash值
* @returns
*/
const uploadChunks = (chunks, fileHash) => {
return new Promise(async (resolve, reject) => {
// 最大并发上传数
const maxCount = 3;
// 当前是第几个正在上传
let index = 0;
// 上传任务列表
const taskList = [];
// 循环上传切片
while (index < chunks.length) {
// 调用上传函数,得到一个Promise对象
const task = uploadChunk(chunks[index], fileHash);
// 将Promise对象添加到任务列表中
taskList.push(task);
// 当前上传索引+1
index++;
// 上传完成后,从任务列表中移除该Promise对象
task.then(() => {
taskList.splice(taskList.indexOf(task), 1);
});
// 达到最大并发数后,等待其中一个Promise对象完成
if (taskList.length >= maxCount) {
await Promise.race(taskList);
}
}
// 因为当只剩下两个任务的时候会跳出循环,所以这里需要再等待一下
// 等待所有上传任务完成
await Promise.all(taskList);
resolve(true);
});
};

/**
* 合并切片函数
* @param {*} fileHash 文件hash值
* @param {*} chunkSize 切片大小
* @param {*} fileName 文件名
* @returns
*/
const mergeChunks = (fileHash, chunkSize, fileName) => {
return axios.post("http://localhost:3000/merge", {
fileHash,
chunkSize,
fileName,
});
};

/**
* 上传文件函数
* @param {*} file 要上传的文件对象
* @param {*} chunkSize 切片大小
* @param {*} fileName 上传后的文件名
* @returns
*/
const uploadFile = (file, chunkSize = 1024 * 1024 * 2, fileName) => {
return new Promise(async (resolve, reject) => {
try {
// 切片并计算hash值
const { chunks, fileHash } = await cutFile(file);
// 等待上传所有切片
await uploadChunks(chunks, fileHash);
// 等待合并切片
await mergeChunks(fileHash, chunkSize, fileName);
// 上传成功
resolve(true);
} catch (error) {
reject(error);
}
});
};

html 代码

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>

<body>
<input id="fileInput" type="file" />
<button id="uploadButton">上传</button>
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.7.2/axios.min.js"></script>
<script src="./index.js"></script>
<script>
window.onload = () => {
// 获取文件选择框和上传按钮
const fileInput = document.getElementById("fileInput");
const uploadButton = document.getElementById("uploadButton");
// 全局文件对象
let file = null;
// 文件选择事件
fileInput.onchange = (event) => {
const fileList = event.target.files;
if (fileList.length) {
file = fileList[0];
}
};
// 上传按钮点击事件
uploadButton.addEventListener("click", () => {
if (file) {
uploadFile(file, 1024 * 1024 * 2, file.name);
}
});
};
</script>
</body>
</html>