在《大文件上传实现-基础上传功能》 这篇文章中,实现了前后端基础的上传文件功能,前端包括选择文件、拖拽文件、展示文件、上传文件显示上传进度等功能,后端则是接收文件功和存储文件功能。例子中采用流的传输方式,Content-Type
用 application/octet-stream
。

这篇文章是大文件上传实现专栏的第二篇文章,将在上一篇文章实现的代码上,进行升级迭代,逐步实现文件切片上传功能,所以如果没有看上一篇,可以花点时间先看看。
先想想需求:分片上传功能,就是大文件切分小文件,同时上传,完成之后在服务端合并起来,以此来提高上传速度。那么前端要做的就是切片,即大文件切成多个文件片段,同时上传。后端要做的就分成两步,一步是接收文件片段,一步是合并文件。
下面还是先写接口,后写页面。
切片功能
后端切片上传接口
上一节,上传之后的文件放在了temp目录下面,但要做分片上传然后合并,得分两个文件夹,一个用来放分片的,一个用来放合并之后的文件,合并之后存放文件的文件夹,定 uploads 。
index.js 文件修改如下代码,建立了临时文件夹和上传文件夹。
js
// 临时文件夹,切片文件将会被上传到这个文件夹中
const TEMP_DIR = path.resolve(__dirname, "temp");
// 上传文件夹,文件将会被移动到这个文件夹中
const UPLOADS_DIR = path.resolve(__dirname, "uploads");
// 确保临时文件夹存在
fs.ensureDir(TEMP_DIR);
// 确保上传文件夹存在
fs.ensureDir(UPLOADS_DIR);
实现上传接口,将文件切片放到临时文件夹里面,按照文件名区分不同文件,属于同一文件的切片放到一起。
js
app.post("/upload/:filename", async (req, res) => {
// 获取文件名
const { filename } = req.params;
// 切片名
const { chunkFilename } = req.query;
// 拼接切片文件夹路径
const chunkDir = path.resolve(TEMP_DIR, filename);
// 确保切片文件夹存在
await fs.ensureDir(chunkDir);
// 拼接文件路径
const chunkPath = path.resolve(chunkDir, chunkFilename);
// 创建可读流
const ws = fs.createWriteStream(chunkPath);
// 将可读流中的数据写入到可写流中,完成文件上传
await pipeStream(req, ws);
// 响应结果
res.json({ success: true });
});
用 postman 测试一下:



上面用整个文件测试,换成切片也一样,都要上传文件名,切片名(序号拼接文件名)
下面我们实现一下前端的切片上传功能
前端切片上传功能
前面在做前端上传功能的时候,一直用的是文件本身的文件名,但文件名是可以改的,如果相同文件名,那么就会覆盖了上传在服务端的文件,因此对应一个文件,应该得有一个唯一的标识作为文件名,避免重复。那么就需要对文件进行"摘要"了,这里用到 SubtleCrypto.digest()
api, MDN。
src 目录下面新建一个 getFilename.js 文件,实现如下:

js
/**
* 对文件摘要拿到文件名,耗时操作
* @param {File} file 文件对象
* @returns 文件名
*/
async function getFilename(file) {
// 获取文件后缀名
const extension = file.name.split(".").pop();
// 获取文件摘要
const digestName = await calculateDigest(file);
return `${digestName}.${extension}`;
}
/**
*
* @param {File} file 文件对象
* @returns 返回十六进制的字符串
*/
async function calculateDigest(file) {
// 读取文件buffer
const arrayBuffer = await file.arrayBuffer();
// 计算摘要buffer
const digestBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
// 转换为十六进制字符串
const digestArray = Array.from(new Uint8Array(digestBuffer));
const digestHex = digestArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return digestHex;
}
export default getFilename;
看一下效果


这是一个耗时操作,在按钮上面加一个loading
效果。
html
<el-button @click="handleUpload" :loading="calculating">开始上传</el-button>

下面开始对文件进行切片处理,测试的视频文件1.5G,暂定文件切片大小 100M, src 下面新建一个 createChunks.js 文件,实现如下:

js
/**
*
* @param {File} file 文件对象
* @param {Number} chunkSize 切片大小
* @param {String} filename 文件名
* @returns 切片数组
*/
export default function createChunks(file, chunkSize, filename) {
// 兼用处理
filename = filename || file.name;
const chunks = [];
// 计算切片数量
const count = Math.ceil(file.size / chunkSize);
// 循环切片
for (let i = 0; i < count; i++) {
// 获取到切片
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
// 拼接对应的切片文件名
const chunkFilename = `${i}-${filename}`;
chunks.push({
chunk,
chunkFilename,
});
}
return chunks;
}
试一下:


效果如下,前面切片的大小都是一样的,最后一个小一点:

现在有了切片,文件名,切片名,可以进行上传了,之前是单个文件上传,现在是多个,用循环进行多个请求,在Promise.all
之后拿到最终的请求结果:
js
const handleUpload = async () => {
if (!selectedFile.value.file) {
return ElMessage.warning("请先选择文件");
}
const file = selectedFile.value.file;
calculating.value = true;
// 获取文件名
const filename = await getFilename(file);
calculating.value = false;
const CHUNK_SIZE = 100 * 1024 * 1024;
// 创建分片
const chunks = createChunks(file, CHUNK_SIZE, filename);
// 批量上传分片
const requests = chunks.map((chunkInfo) => {
return axiosInstance.post(`/upload/${filename}`, chunkInfo.chunk, {
headers: {
"Content-Type": "application/octet-stream",
},
params: {
chunkFilename: chunkInfo.chunkFilename,
},
onUploadProgress(progressEvent) {
progressInfo.value[chunkInfo.chunkFilename] = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
},
});
});
await Promise.all(requests);
ElMessage.success("上传分片完成");
};
注意这里,获取分片上传进度,改了一下,直接用分片名做键,进度做值:

模板也改下:
html
<template v-if="Object.keys(progressInfo).length > 0">
<div class="progress" v-for="(percent, name) in progressInfo" :key="name">
<span>{{ name }}</span>
<el-progress :percentage="percent"></el-progress>
</div>
</template>
来看看效果:

可以看到,并发了15个请求,由于浏览器限制了同一个域名下最多并行发送6个请求,所以看到是6个并发进度,完了再继续后面的请求。
到这里,分片部分的前端后端功能就完成了,下面进行文件合并功能开发。
合并功能
后端合并接口
合并接口需要做的事情,就是接收文件名,然后根据文件名去 temp 文件下面查找对应的文件分片,将其合并放到 uploads 文件夹下面,代码如下(以100M的切片大小为例):
js
...
// 切片大小
const CHUNK_SIZE = 1024 * 1024 * 100; // 100MB
...
app.get("/merge/:filename", async (req, res) => {
// 获取文件名
const { filename } = req.params;
// 拼接切片文件夹路径
const chunkDir = path.resolve(TEMP_DIR, filename);
// 读取切片文件夹中的文件名
const chunks = await fs.readdir(chunkDir);
if (chunks.length === 0) {
res.json({ success: false, message: "切片文件不存在" });
return;
} else {
// 按照切片的索引进行排序
chunks.sort((a, b) => a.split("-")[0] - b.split("-")[0]);
// 将合并的文件名路径
const uploadsPath = path.resolve(UPLOADS_DIR, filename);
// 写入文件的异步任务
const writeTasks = chunks.map((chunkFilename, index) => {
// 拼接切片文件夹路径
const chunkPath = path.resolve(chunkDir, chunkFilename);
// 创建可读流
const rs = fs.createReadStream(chunkPath)
// 创建可读流
const ws = fs.createWriteStream(uploadsPath, {
// 追加写入
flags: "a",
start: index * CHUNK_SIZE,
});
return pipeStream(rs, ws);
});
await Promise.all(writeTasks);
// 合并完成后,删除切片文件夹
await fs.rm(chunkDir, { recursive: true, force: true });
return res.json({ success: true });
}
});
整体合并流程就是:根据分片创建可读流,以追加方式写入待合并的文件中(可写流),注意看可写流的配置参数start: index * CHUNK_SIZE
, 它控制可写流起始的偏移。
辅助函数pipeStream
是上一篇文章提到的,这里再看一遍:
js
/**
* 数据从可读流流向可写流
* @param {ReadableStream} rs 可读流
* @param {WritableStream} ws 可写流
* @returns 返回一个Promise,当流结束时,Promise会被resolve
*/
function pipeStream(rs, ws) {
return new Promise((resolve, reject) => {
rs.pipe(ws).on("finish", resolve).on("error", reject);
});
}
前面我们计算得到的文件名是321f84eca184e49fbdaea3683e6e6d0e4a1bd69bee988e6c3232ab2930574e85.mp4
,

用 postman 来试一下:


没有问题,请求之后,切片被从temp文件夹下面,合并到了uploads文件夹下面了。
到这里,合并的后端功能就算完成了,下面前端部分,前端上传完切片之后,再请求一下合并的接口就好了。
前端合并请求

js
// 合并分片
axiosInstance.get(`/merge/${filename}`).then(() => {
ElMessage.success("合并分片完成");
});
看一下效果:

总结
这篇文章在第一篇文章《大文件上传实现-基础上传功能》 功能实现的基础上,进行分片上传的改造,完成了大文件切片上传的前后端功能,代码放在gitee上面。下一篇文章,将在这篇文章的功能实现基础上,实现"秒传、断点续传"功能,敬请期待!