在上一篇文章中,我们探讨了如何在浏览器端高效计算大文件的 MD5 值。有了这个基础,我们就可以进一步实现一个完整的大文件分片上传(Chunked Upload)方案。

分片上传不仅能解决大文件上传超时的问题,还能实现断点续传秒传等高级功能,极大地提升用户体验。

整体流程设计

一个健壮的分片上传流程通常包含以下三个阶段:

  1. 握手(Handshake)/ 预检查:前端发送文件的 MD5 和基本信息给后端。后端检查文件是否已存在(秒传),或者是否存在部分分片(断点续传),并返回给前端需要上传的分片列表。
  2. 分片上传(Chunk Upload):前端根据后端返回的列表,将文件切割成对应的分片,并发上传。
  3. 合并(Merge):所有分片上传完成后,前端发送合并请求,后端将所有分片合并成原始文件。

前端实现

1. 文件切片

利用 File.prototype.slice 方法,我们可以轻松地将文件切割成多个 Blob 对象。

1
2
3
4
5
6
7
8
9
const createFileChunks = (file, chunkSize = 2 * 1024 * 1024) => {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push({ index: cur / chunkSize, file: file.slice(cur, cur + chunkSize) });
cur += chunkSize;
}
return chunks;
};

2. 并发控制

为了避免一次性发出成百上千个请求导致浏览器崩溃或服务器过载,我们需要控制并发请求的数量。

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
const uploadChunks = async (chunks, uploadedList = []) => {
const requestList = chunks
.filter(({ index }) => !uploadedList.includes(index)) // 过滤掉已上传的分片
.map(({ file, index }) => {
const formData = new FormData();
formData.append("file", file);
formData.append("hash", fileHash); // 文件的 MD5
formData.append("index", index); // 分片索引
return { formData, index };
});

// 并发控制函数
const asyncPool = async (limit, array, iteratorFn) => {
const ret = [];
const executing = [];
for (const item of array) {
const p = Promise.resolve().then(() => iteratorFn(item, array));
ret.push(p);

if (limit <= array.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.all(ret);
};

await asyncPool(3, requestList, ({ formData }) => axios.post("/upload", formData));
};

后端实现 (Node.js 示例)

1. 接收分片

后端接收到分片后,将其临时存储在一个以文件 MD5 命名的目录中。

1
2
3
4
5
6
7
8
9
10
11
const fse = require("fs-extra");
const multipart = require("connect-multiparty");

app.post("/upload", multipart(), async (req, res) => {
const { index, hash } = req.body;
const chunkPath = path.resolve(UPLOAD_DIR, hash, index);

// 将切片移动到临时目录
await fse.move(req.files.file.path, chunkPath);
res.send({ msg: "切片上传成功" });
});

2. 合并分片

当所有分片上传完毕,后端读取该目录下的所有分片,按索引顺序写入到一个新文件中,最后删除临时目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const mergeFileChunks = async (filePath, fileHash, size) => {
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
const chunkPaths = await fse.readdir(chunkDir);

// 根据索引排序
chunkPaths.sort((a, b) => a - b);

await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
)
)
);

// 合并后删除切片目录
fse.rmdirSync(chunkDir);
};

核心功能点解析

秒传 (Instant Upload)

在“握手”阶段,如果后端发现数据库中已经存在该 MD5 对应的文件,则直接返回“上传成功”,前端无需发送任何数据。这就是“秒传”的原理。

断点续传 (Resumable Upload)

如果上传过程中断,下次上传时,前端先询问后端已存在哪些分片。后端扫描临时目录,返回已上传的分片索引列表(例如 [0, 1, 2])。前端在 filter 阶段就会跳过这些分片,只上传剩下的部分。

总结

大文件分片上传是一个经典的前后端协作场景。前端负责高效计算 MD5、切片和并发控制;后端负责分片存储、合并以及状态管理。通过这种方案,我们可以构建出稳定、高效且体验优秀的文件上传服务。