本文将介绍如何利用 async-pool 这个库提供的 asyncPool 函数来实现大文件的并发分片上传。相信有些小伙伴已经了解大文件上传的解决方案,在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后通过多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。
在大文件上传的场景中,我们将使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后对分块进行并行上传。接下来,我们来看一下具体如何实现大文件上传。
看完上图相信你对大文件上传的方案,已经有了一定的了解。
1.如何实现大文件上传?
为了让大家能够更好地理解后面的内容,我们先来看一下整体的流程图:
了解完大文件上传的流程之后,我们先来定义上述流程中涉及的一些辅助函数。
1.1 定义辅助函数
1.1.1 定义 calcFileMD5 函数
顾名思义 calcFileMD5 函数,用于计算文件的 MD5 值(数字指纹)。在该函数中,我们使用 FileReader API 分块读取文件的内容,然后通过 spark-md5 这个库提供的方法来计算文件的 MD5 值。
function calcFileMD5(file) {
return new Promise((resolve, reject) => {
let chunkSize = 2097152, // 2M
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
fileReader.onerror = (e) => {
reject(fileReader.error);
reader.abort();
};
function loadNext() {
let start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
});
}
1.1.2 定义 asyncPool 函数
它用于实现异步任务的并发控制。该函数接收 3 个参数:
poolLimit(数字类型):表示限制的并发数;
array(数组类型):表示任务数组;
iteratorFn(函数类型):表示迭代函数,用于实现对每个任务项进行处理,该函数会返回一个 Promise 对象或异步函数。
async function asyncPool(poolLimit, array, iteratorFn) {
const ret = []; // 存储所有的异步任务
const executing = []; // 存储正在执行的异步任务
for (const item of array) {
// 调用iteratorFn函数创建异步任务
const p = Promise.resolve().then(() => iteratorFn(item, array));
ret.push(p); // 保存新的异步任务
// 当poolLimit值小于或等于总任务个数时,进行并发控制
if (poolLimit <= array.length) {
// 当任务完成后,从正在执行的任务数组中移除已完成的任务
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e); // 保存正在执行的异步任务
if (executing.length >= poolLimit) {
await Promise.race(executing); // 等待较快的任务执行完成
}
}
}
return Promise.all(ret);
}
1.1.3 定义 upload 函数
在 upload 函数内,我们使用了前面介绍的 asyncPool 函数来实现异步任务的并发控制,具体如下所示:
function upload({url, file, fileMd5, fileSize, chunkSize, chunkIds, poolLimit = 1}) {
const chunks = typeof chunkSize === "number" ? Math.ceil(fileSize / chunkSize) : 1;
return asyncPool(poolLimit, [...new Array(chunks).keys()], (i) => {
if (chunkIds.indexOf(i + "") !== -1) { // 已上传的分块直接跳过
return Promise.resolve();
}
let start = i * chunkSize;
let end = i + 1 == chunks ? fileSize : (i + 1) * chunkSize;
const chunk = file.slice(start, end); // 对文件进行切割
return uploadChunk({
url,
chunk,
chunkIndex: i,
fileMd5,
fileName: file.name,
});
});
}
对于切割完的文件块,会通过 uploadChunk 函数,来执行实际的上传操作:
function uploadChunk({ url, chunk, chunkIndex, fileMd5, fileName }) {
let formData = new FormData();
formData.set("file1", chunk, fileMd5 + "-" + chunkIndex);
formData.set("name", fileName);
formData.set("timestamp", Date.now());
return request.post(url, formData);
}
1.1.4 定义 concatFiles 函数
当所有分块都上传完成之后,我们需要通知服务端执行分块合并操作,这里我们定义了 concatFiles 函数来实现该功能:
function concatFiles(url, name, md5) {
return request.get(url, { params: { name, md5 } });
}
1.1.5 定义 uploadFile 函数
async function uploadFile() {
// files文件上传对象
if (!files) return;
const fileMd5 = await calcFileMD5(files); // 计算文件的MD5
await upload({
url: "/upload",
file: files, // 文件对象
fileMd5, // 文件MD5值
fileSize: files.size, // 文件大小
chunkSize: 0.1 * 1024 * 1024, // 分块大小
chunkIds: [], // 已上传的分块列表
poolLimit: 3, // 限制的并发数
});
console.log('完成');
const resp = await concatFiles("/concatFiles", files.name, fileMd5);
console.log(resp);
}
1.2 大文件并发上传示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.0/spark-md5.min.js"></script>
</head>
<body>
<input type="file" id="uploadFile" onchange="handleChange(event)"/>
<button id="submit" onclick="uploadFile()">上传文件</button>
</body>
<script>
let files = null;
function handleChange(e) {
try{
files = e.target.files[0];
e.target.value = '';
}catch (e) {
files = null;
}
}
function calcFileMD5(file) {
return new Promise((resolve, reject) => {
let chunkSize = 2097152, // 2M
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
fileReader.onerror = (e) => {
reject(fileReader.error);
reader.abort();
};
function loadNext() {
let start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
});
}
async function checkFileExist(url, fileName, fileMd5) {
return new Promise(((resolve, reject) => {
setTimeout(() => {
randomNum = Math.random()
const obj = {
data: {
isExists: randomNum > 5,
url: randomNum > 5 ? 'https://www.baidu.com/' : null,
chunkIds: []
}
}
resolve(obj)
}, 500)
}))
}
function uploadChunk({ url, chunk, chunkIndex, fileMd5, fileName }) {
let formData = new FormData();
formData.set("file1", chunk, fileMd5 + "-" + chunkIndex);
formData.set("name", fileName);
formData.set("timestamp", Date.now());
const data = {
file: chunk,
fileMd5: fileMd5 + "-" + chunkIndex,
name: fileName,
timestamp: Date.now()
}
console.log(chunkIndex, data);
// 调用后端接口,这里模拟了后端接口
return new Promise(((resolve, reject) => {
setTimeout(() => {
resolve(data)
}, ~~(Math.random() * (3000 - 1000 + 1) + 1000))
}))
}
async function asyncPool(poolLimit, array, iteratorFn) {
const ret = []; // 存储所有的异步任务
const executing = []; // 存储正在执行的异步任务
for (const item of array) {
// 调用iteratorFn函数创建异步任务
const p = Promise.resolve().then(() => iteratorFn(item, array));
ret.push(p); // 保存新的异步任务
// 当poolLimit值小于或等于总任务个数时,进行并发控制
if (poolLimit <= array.length) {
// 当任务完成后,从正在执行的任务数组中移除已完成的任务
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e); // 保存正在执行的异步任务
if (executing.length >= poolLimit) {
await Promise.race(executing); // 等待较快的任务执行完成
}
}
}
return Promise.all(ret);
}
function upload({url, file, fileMd5, fileSize, chunkSize, chunkIds, poolLimit = 1}) {
const chunks = typeof chunkSize === "number" ? Math.ceil(fileSize / chunkSize) : 1;
return asyncPool(poolLimit, [...new Array(chunks).keys()], (i) => {
if (chunkIds.indexOf(i + "") !== -1) { // 已上传的分块直接跳过
return Promise.resolve();
}
let start = i * chunkSize;
let end = i + 1 == chunks ? fileSize : (i + 1) * chunkSize;
const chunk = file.slice(start, end); // 对文件进行切割
return uploadChunk({
url,
chunk,
chunkIndex: i,
fileMd5,
fileName: file.name,
});
});
}
function concatFiles(url, name, md5) {
// 调用后端接口,这里模拟了后端接口
return new Promise((resolve, reject) => {
randomNum = Math.random()
const data = {
status: randomNum > 0.5 ? 1 : 0,
url: randomNum > 0.5 ? 'https://www.baidu.com/' : null
}
resolve(data)
})
}
async function uploadFile() {
if (!files) return;
const fileMd5 = await calcFileMD5(files); // 计算文件的MD5, 这里也可以自己实现一个唯一值,也可后端生成主键ID
await upload({
url: "/single",
file: files, // 文件对象
fileMd5, // 文件MD5值
fileSize: files.size, // 文件大小
chunkSize: 0.1 * 1024 * 1024, // 分块大小
chunkIds: [], // 已上传的分块列表
poolLimit: 3, // 限制的并发数
});
console.log('完成');
const resp = await concatFiles("/concatFiles", files.name, fileMd5);
console.log(resp);
}
</script>
</html>
由于完整的示例代码内容比较多,这里就不放具体的代码了。
2、总结
本文介绍了在 JavaScript 中如何利用 async-pool 这个库提供的 asyncPool 函数,来实现大文件的并发上传。此外,文中我们也使用了 spark-md5 这个库来计算文件的数字指纹。
如果有遇到不清楚的地方,欢迎随时交流哟。