Nest、Vue3 实现大文件分片上传、断点续传、秒传
平时都是写写 CRUD
,或者写写表表单、表格,没遇见什么难的场景,简历写不出亮点?
那么这个学习完这个完整案例后,相信能为你在简历项目上添砖加瓦
话不多说,我们开始吧!💪
实现过程
先创建下 Nest
项目
npm install -g @nestjs/cli
nest new large-file-upload-nest
既然要做上传文件的功能,那么需要引入 multer
包用于上传文件
npm install @types/multer
前端直接请求后端服务会发送跨域,这里我们简单在main.ts
中配置下
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors(); // 跨域配置
await app.listen(3000);
}
bootstrap();
基本配置已经完成,我们来写下,上传接口
/* 上传文件 */
@Post('upload')
@UseInterceptors(
FilesInterceptor('files', 20, {
dest: 'uploads',
}),
)
uploadFiles(
@UploadedFiles() files: Array<Express.Multer.File>,
@Body() { name, hash }: { name: string; hash: string },
) {
const chunkDir = 'uploads/' + hash;
// 判断文件夹是否存在
if (!fs.existsSync(chunkDir)) {
// 创建文件夹
fs.mkdirSync(chunkDir);
}
// 拷贝文件
fs.cpSync(files[0].path, chunkDir + '/' + name);
// 删除文件
fs.rmSync(files[0].path);
}
需要传入,name
作为文件名,hash
作为一个唯一值和其他文件做区分
用 ApiPost
测试下
使用 Vue3
创建下基本项目
npm init vite-app large-file-upload-vue3
进入文件夹中
cd vue3-vite-animation
安装下依赖
cd vue3-vite-animation
启动
npm run dev
首先,需要请求后端、以及一点简单好看的 UI 组件
安装 axios
、element-plus
npm install axios element-plus --save
main.js
中全局引入 element-plus
import { createApp } from "vue";
import App from "./App.vue";
import "./index.css";
import ElementPlus from "element-plus";
import "/node_modules/element-plus/dist/index.css";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus);
app.mount("#app");
修改下 App.vue
引入上传组件
<template>
<div>
<el-upload class="upload-demo" drag multiple :auto-upload="false">
<el-icon class="el-icon--upload">
<upload-filled />
</el-icon>
<div class="el-upload__text">
拖拽到这里或者<em>点击上传</em>
</div>
</el-upload>
</div>
</template>
<script setup lang='ts'></script>
好了,我们使用 axios
对接上传接口
<template>
<div>
<el-upload class="upload-demo" drag multiple :auto-upload="false" :on-change="handleChange">
<el-icon class="el-icon--upload">
<upload-filled />
</el-icon>
<div class="el-upload__text">
拖拽到这里或者<em>点击上传</em>
</div>
</el-upload>
</div>
</template>
<script setup lang='js'>
import axios from 'axios';
const handleChange = async (files) => {
const data = new FormData();
const hash = '1213516565'
data.set('name', '15615165.jpg')
data.set('hash', hash)
data.append('files', files.raw);
await axios.post('http://localhost:3000/upload', data)
}
</script>
hash
是手动写的,并不是唯一,需要生成一个唯一的
对此,需要引入 spark-md5
这样一个插件
npm install spark-md5 --save
创建一个生成 hash
的函数,getFileHash
/* 通过 md5 加密文件 buffer 来生成唯一 hash 值 */
const getFileHash = async (file) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
const fileHash = SparkMD5.ArrayBuffer.hash(e.target.result);
resolve(fileHash);
}
fileReader.onerror = () => {
reject('文件读取失败');
}
fileReader.readAsArrayBuffer(file);
})
}
修改下 handleChange
函数
const handleChange = async (files) => {
const data = new FormData();
const file = files.raw
const hash = await getFileHash(file)
const name = file.name
data.set('name', name)
data.set('hash', hash)
data.append('files', file);
await axios.post('http://localhost:3000/upload', data)
}
再次调用,上传接口,可以看到,文件名已经变成 hash
了
【注意】如果页面有报错,可以直接重启下项目,因为刚安装的插件,没有重启,vue
是无法直接获取
接下来就是我们最重要的文件切片了,使用slice
函数进行切片
/* 分块大小 */
const chunkSize = 100 * 1024;
/* 文件切片 */
const chunks = [];
let startPos = 0;
while (startPos < file.size) {
chunks.push(file.slice(startPos, startPos + chunkSize));
startPos += chunkSize;
}
切完后,我们将每个切片,用前面的上传接口上传
为了优化多个请求,我们可以使用 promise.all
进行批量上传
/* 切片上传 */
const tasks = [];
chunks.map((chunk, index) => {
const data = new FormData();
data.set('name', hash + '_' + index)
data.set('hash', hash)
data.append('files', chunk);
tasks.push(axios.post('http://localhost:3000/upload', data, {
onUploadProgress: (progressEvent) => {
console.log(`${index}上传进度:`, (progressEvent.loaded / progressEvent.total * 100).toFixed(2) + '%');
}
}));
})
await Promise.all(tasks);
如果我们需要做上传进度条,可以使用 axios
的 onUploadProgress
,拿到每个分片的总大小 progressEvent.total
,当前已上传的大小 progressEvent.loaded
测试下效果
接下来是不是,就是把分片合并起来就可以了。
可以采用,上传完成后,后端判断进行合并,也可以使用前端来进行合并。这里采用的是前端请求接口进行合并。
写下后端合并接口
合并文件需保证合并正确,所有我们先对文件进行排序,然后将文件转为文件流,往指定位置拼接,拼接成功后删除原切片,最后更新拼接位置
/* 合并文件 */
@Get('merge')
merge(@Query('name') name: string, @Query('hash') hash: string) {
// 获取文件夹
const chunkDir = 'uploads/chunks_' + hash;
const files = fs.readdirSync(chunkDir);
// 文件排序
files.sort((f1, f2) => {
const idx1 = +f1.split('_').at(-1);
const idx2 = +f2.split('_').at(-1);
return idx1 < idx2 ? -1 : idx1 > idx2 ? 1 : 0;
});
// 切片合并
let count = 0;
let startPos = 0;
files.map((file) => {
const filePath = chunkDir + '/' + file;
// 读取文件流
const stream = createReadStream(filePath);
stream
.pipe(
createWriteStream('uploads/' + hash + '-' + name, {
start: startPos,
}), // 指定拼接位置
)
.on('finish', () => {
count++;
if (count === files.length) {
// 删除切片文件夹
rm(
chunkDir,
{
// 是否递归删除文件夹
recursive: true,
},
() => {},
);
}
});
// 更新拼接位置
startPos += statSync(filePath).size;
});
}
调用下合并接口
/* 合并切片 */
axios.get('http://localhost:3000/merge?name=' + file.name + '&hash=' + hash);
大文件上传时,有会出现各种问题,比如有小一部分切片上传失败了,难道我们要继续再全部上传吗?🤨
显然是没必要的,我通过一个请求,询问后端,还缺哪些切片,我再传一次不就行了
说干就干!!!定义下检测接口
/* 检查已上传文件或者切片 */
@Get('check-chunks')
checkChunks(
@Query('hash') hash: string,
@Query('name') name: string,
@Query('chunkTotal') chunkTotal: string,
) {
// 获取切片文件夹、或文件
const vo = {
uploadStatus: 'empty',
chunkSignsArr: [], // 例子:[1,0,1,0] 表示,第 2、4 块切片没有上传
};
const chunkDir = 'uploads/' + hash;
let directory;
if (fs.existsSync(chunkDir)) {
directory = fs.readdirSync(chunkDir);
const chunkSignsArr = new Array<number>(+chunkTotal).fill(0);
// 有文件夹,说明切片未完全上传,正序返回切片排序 (断点续传)
if (directory?.length > 0) {
directory.map((file) => {
const idx = +file.split('_').at(-1);
chunkSignsArr[idx] = 1;
});
vo.uploadStatus = 'uploading';
}
vo.chunkSignsArr = chunkSignsArr;
}
return vo;
}
uploadStatus: 'empty'
没有上传过切片,需要都上传uploadStatus: 'uploaded'
已经完整上传过文件了,秒传uploadStatus: 'uploading'
上传了一部分切片
我们先调用下上传文件接口,但是不要调用合并接口
这样就能得到文件切片,再把其中几个分片删除,模拟部分上传切片失败的情况,本来有四个切片,删除了 1
、3
把合并请求注释放开,调用下上传接口
let chunkSignsArr = new Array(chunks.length).fill(0);
/* 判断是否已有切片,有切片 (断点续传),有完整文件 (秒传) */
const {data} = await axios.get('http://localhost:3000/check-chunks?hash=' + hash + '&name=' + file.name + '&chunkTotal=' + chunks.length);
if (data.uploadStatus === 'uploading') {
chunkSignsArr = [...data.chunkSignsArr]
}
还有一种情况,那就是如果图片已经上传了,那我们就不用再传了,只需要告诉前端我们已经完成上传了,这就是所谓的 “秒传”
实现也很简单,写一下吧,为检测接口添加一个检测是否文件已经上传的判断
const fileDir = 'uploads/' + hash + '-' + name;
// 有文件,说明文件已经上传且合并,返回上传成功 (秒传)
if (fs.existsSync(fileDir)) {
vo.uploadStatus = 'uploaded';
}
前端部分修改下,这部分逻辑
测试下效果
连续传两次相同文件
完整代码
小结
好了,通过这个案例,实现大文件的切片上传,断点续传,秒传
切片上传:将文件按固定大小分割,逐个上传,然后前端请求合并切片,后端合并切片
断点续传:前后端通过传递 [0,1,1,0]
- 1 表示该位置切片已上传
- 0 表示该位置切片未上传
前端拿到后,再上传指定切片即可
秒传:后端根据文件前端传过来的文件 hash 值 和 文件名,判断该文件是否已经上传,已上传就告诉前端,可以展示“秒传”了
扩展
当然,这上面只是简单的案例
有很多情况没有考虑到,比如
-
文件要是很大呢,那么生成对应 hash 值,是不是就很慢?
-
要是多文件上传要怎么搞?
-
检测接口,要是文件很多的情况下,遍历获取文件信息是不是就很慢了,是不是可以把这部分信息存在数据库呢?下次请求的时候,我们只需要去数据库查找是否有已经存在切片信息就可以了
-
....
感兴趣的同学可以尝试优化一下
转载自:https://juejin.cn/post/7369027991441473587