来分享一篇技术文章,花费一天的时间,一下午的代码 一上午的文章,大文件上传,看看我的技术方案是否符合你当下的需求。不知道各位的解决方案都是怎么样的。
我这里使用的是 vue3 + express 用什么都无所谓。
源代码地址 帮忙去点下star🌟 感谢!
大文件上传的场景
如果遇到需要上传电影、视频或者特别大的数据之类的需求,那么上传的文件是非常大的,这个时候我们不能说用一个请求就直接将所有的文件传输过去,因为这个大文件上传时间相比较来说是比较长的,存在很多的弊端,假如用户刷新了页面之类的情况,这时候上传又需要重头开始上传,这对用户以及服务器都是不妥的。
首先想几个问题:
- 怎么判断文件是否上传过了,再上传就重复了?
- 有人会说 我通过判断文件名不可以吗?No
- 那我判断文件名和最后修改时间?No 有个别情况会产生重复
- 针对文件生成 hash 值(唯一标识),这种首先可以判断是否是重复的文件,又可以解决分片上传时,每一个chunk最后的归属。
- 如果一个文件已经上传过了,下次上传如何处理?
- 当然是直接返回一样文件的链接了。(秒传)
- 如果用户不小心点了刷新,再上传时如果处理?(断点续传)
- 刷新之前的chunk已经存在了,没有必要再进行重新上传,所以可以返回最后一个上传的index值。
- 上传到最后,需要将所有上传过的chunk进行合并成文件。
需要解决的基本问题
- 对大文件进行分片上传
- 对文件进行hash处理
- 上传的实时进度
- 上传中断后再次上传跳过已经上传的部分(断点续传) 这些都比较简单,难得是如何优化上传体验,以及上传效率。
前端主要代码
<template>
<input type="file" @change="fileChange" />
<button @click="uploadBtn">{{ loading ? "正在解析" : "开始上传" }}</button>
<!-- 上传进度 -->
<input type="range" name="" id="" :value="percentage" />
{{ percentage }}%
</template>
<script setup>
import SparkMD5 from "spark-md5";
...
const chunkSize = 1024 * 1024;
// 可以拿到文件对象,将文件存储起来
const fileChange = () => {...}
// 点击开始上传按钮,将文件进行切片处理、解析文件(生成hash)、开始上传
const uploadBtn = () => {
loading.value = true;
let _fileList = [];
// 切片
for (let i = 0; i < file.value.size; i += chunkSize) {
_fileList.push(file.value.slice(i, i + chunkSize));
}
fileList.value = _fileList;
const hash = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = (e) => {
hash.append(e.target.result);
fileMd5.value = hash.end();
upload(0);
};
fileReader.readAsArrayBuffer(file.value);
};
const upload = async (index) => {
if (index === fileList.value.length) {
mergeUpload();
return;
}
const formData = new FormData();
formData.append("chunk", fileList.value[index]);
formData.append("index", index);
/**
* 这里的名字特别约定一下
* 为什么不使用 fileMd5.value + "@" + index?
* 如果是断点续传的时候,需要拿到最后一次上传的index。
* 上边这种方式还需要进行排序,这种的话直接取到最后一个就可以了
*/
formData.append("name", index + "@" + fileMd5.value);
formData.append("filename", fileMd5.value);
formData.append("extname", "png"); // 测试
let { data } = await axios.post("http://localhost:3000/upload", formData, {
header: {
"Content-Type": "multipart/form-data",
},
});
if (data.code === 300) {
// 证明已经存在部分文件
percentage.value = ((data.index / fileList.value.length) * 100).toFixed(2);
upload(data.index + 1);
} else if (data.code === 200) {
percentage.value = (((index + 1) / fileList.value.length) * 100).toFixed(2);
upload(data.index + 1);
} else if (data.code === 201) {
console.log(
"%c [ ]-60",
"font-size:13px; background:pink; color:#bf2c9f;",
data
);
} else {
upload(index);
}
};
// 合并请求
const mergeUpload = async () => {
let { data } = await axios.post("http://localhost:3000/mergeFile", {
filename: fileMd5.value,
extname: "png",
});
};
...
</script>
后端主要代码
// 上传
app.post("/upload", (req, res, next) => {
const form = new multiparty.Form();
form.parse(req, (err, fields, files) => {
console.log(fields, files);
if (err) {
next(err);
return;
}
let pa = path.join(
__dirname,
"./public/upload/chunk/" + fields["filename"][0]
);
let filePa = path.join(
__dirname,
"./assets/",
`${fields.filename}.${fields.extname}`
);
// 判断是否是断点续传
if (fs.existsSync(pa) && parseInt(fields.index[0]) === 0) {
// 存在该目录
// 返回最大的索引
let maxIndex = 0;
let arr = fs.readdirSync(pa);
/**
* 如果前端传递的文件名称为 index + @ + name 的话
* 就不需要这种方式进行过滤出最后一次上传时的index了
*/
// for (let i = 0; i < arr.length; i++) {
// let str = parseInt(arr[i].split("@")[0]);
// if (str > maxIndex) {
// maxIndex = str;
// }
// }
res.send({
code: 300,
msg: "存在该目录,请继续上传",
index: arr.at(-1).split("@")[0],
});
}
// 判断文件是否已经存在
else if (fs.existsSync(filePa)) {
res.send({
code: 201,
data: "/" + fields.filename + "." + fields.extname,
});
} else {
// 将每一次上传的数据进行统一的存储
const oldName = files.chunk[0].path;
const newName = path.join(
__dirname,
"./public/upload/chunk/" +
fields["filename"][0] +
"/" +
fields["name"][0]
);
// 创建临时存储目录
fs.mkdirSync("./public/upload/chunk/" + fields["filename"][0], {
recursive: true,
});
fs.copyFile(oldName, newName, (err) => {
if (err) {
console.error(err);
} else {
fs.unlink(oldName, (err) => {
if (err) {
console.error(err);
} else {
console.log("文件复制和删除成功");
}
});
}
});
res.send({
code: 200,
msg: "分片上传成功",
index: parseInt(fields["index"][0]),
});
}
});
});
// 合并请求
app.post("/mergeFile", (req, res, next) => {
const fields = req.body;
thunkStreamMerge(
path.join(__dirname, "./public/upload/chunk/", fields.filename),
path.join(__dirname, "./assets/", fields.filename + "." + fields.extname)
);
res.send({
code: 200,
data: "/" + fields.filename + "." + fields.extname,
});
});
const thunkStreamMerge = (sourceFiles, targetFile) => {
const list = fs.readdirSync(sourceFiles);
console.log(list);
const chunkFilePathList = list.map((name) => ({
name,
filePath: path.resolve(sourceFiles, name),
}));
const fileWriteStream = fs.createWriteStream(targetFile);
thunkStreamMergeProgress(chunkFilePathList, fileWriteStream, sourceFiles);
};
const thunkStreamMergeProgress = (fileList, fileWriteStream, sourceFiles) => {
if (!fileList.length) {
fileWriteStream.end("完成了");
if (sourceFiles)
fs.rm(sourceFiles, { recursive: true, force: true }, (error) => {
console.error(error);
});
return;
}
const data = fileList.shift();
const { filePath: chunkFilePath } = data;
const currentReadStream = fs.createReadStream(chunkFilePath);
currentReadStream.pipe(fileWriteStream, { end: false });
currentReadStream.on("end", () => {
thunkStreamMergeProgress(fileList, fileWriteStream, sourceFiles);
});
};
这样就已经实现了 断点续传、分片上传、秒传功能,但是呢!有问题,如果你不方便执行代码,直接看我这里的效果。
有问题
时间长短问题这个避免不了,但是有没有注意到 我在疯狂的按键盘,就是显示不出来,当解析完成之后全部都显示出来了。我的页面是卡住的状态。
查看控制台打印的执行时间:
...
console.time("文件加载使用的时间");
fileReader.onload = (e) => {
console.timeEnd("文件加载使用的时间");
console.time("文件生成hash使用的时间");
hash.append(e.target.result);
fileMd5.value = hash.end();
console.timeEnd("文件生成hash使用的时间");
// upload(0);
};
...
添加 console.time 查看时间消耗,就是生成hash阻塞了。时间还是非常的长。
所以要进行优化
根据上边提供的 npm spark-md5 的链接,查看官方案例可以进行追加内容,生成最后的 hash 值。其实这种又是分片,如果是分片那么我们就可以提升一下用户体验,添加一个实时解析的进度。上代码。
...
fileReader.onload = (e) => {
hash.append(e.target.result);
ii++;
if (ii < fileList.value.length) {
readerChunk();
} else {
ii = 0;
fileMd5.value = hash.end();
loading.value = false;
upload(0);
}
};
function readerChunk() {
// 解析的进度
parsePercentage.value = (((ii + 1) / fileList.value.length) * 100).toFixed(
2
);
fileReader.readAsArrayBuffer(fileList.value[ii]);
}
readerChunk();
...
这样做 页面也可以正常的操作,不会出现卡死的情况,并且还会反馈用户解析的实时进度。老规矩如果不方便执行代码,看我的效果。
目前的效果就先这样吧,后边才追加新的功能。
其实还有好几个点可以做优化:
- 前端可以并发的去发请求。
- 添加暂停功能
- 如果第一次上传没有上传完,中断了,那么间隔多长时间必须来进行下面的上传,如果不上传则清除之前的chunk。
- 美化一下样式
- 管理所有上传完成的文件