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,
    @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);

}
</express.multer.file>

需要传入,name 作为文件名,hash 作为一个唯一值和其他文件做区分

ApiPost 测试下

使用 Vue3 创建下基本项目

npm init vite-app large-file-upload-vue3

进入文件夹中

cd vue3-vite-animation

安装下依赖

cd vue3-vite-animation

启动

npm run dev

首先,需要请求后端、以及一点简单好看的 UI 组件

安装 axioselement-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 引入上传组件


  
拖拽到这里或者点击上传

好了,我们使用 axios 对接上传接口


  
拖拽到这里或者点击上传

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);

如果我们需要做上传进度条,可以使用 axiosonUploadProgress,拿到每个分片的总大小 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) =&gt; {
  const idx1 = +f1.split('_').at(-1);
  const idx2 = +f2.split('_').at(-1);
  return idx1 &lt; idx2 ? -1 : idx1 &gt; idx2 ? 1 : 0;
});

// 切片合并
let count = 0;
let startPos = 0;
files.map((file) =&gt; {
  const filePath = chunkDir + '/' + file;
  // 读取文件流
  const stream = createReadStream(filePath);
  stream
    .pipe(
      createWriteStream('uploads/' + hash + '-' + name, {
        start: startPos,
      }), // 指定拼接位置
    )
    .on('finish', () =&gt; {
      count++;
      if (count === files.length) {
        // 删除切片文件夹
        rm(
          chunkDir,
          {
            // 是否递归删除文件夹
            recursive: true,
          },
          () =&gt; {},
        );
      }
    });
  // 更新拼接位置
  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 &gt; 0) {
    directory.map((file) =&gt; {
      const idx = +file.split('_').at(-1);
      chunkSignsArr[idx] = 1;
    });
    vo.uploadStatus = 'uploading';
  }
  vo.chunkSignsArr = chunkSignsArr;
}

return vo;

}

  • uploadStatus: 'empty' 没有上传过切片,需要都上传
  • uploadStatus: 'uploaded' 已经完整上传过文件了,秒传
  • uploadStatus: 'uploading' 上传了一部分切片

我们先调用下上传文件接口,但是不要调用合并接口

这样就能得到文件切片,再把其中几个分片删除,模拟部分上传切片失败的情况,本来有四个切片,删除了 13

把合并请求注释放开,调用下上传接口

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';
}

前端部分修改下,这部分逻辑

测试下效果

连续传两次相同文件

完整代码

大文件分片上传、断点续传、秒传 (Vue3)

大文件分片上传、断点续传、秒传 (Nest)

小结

好了,通过这个案例,实现大文件的切片上传,断点续传,秒传

切片上传:将文件按固定大小分割,逐个上传,然后前端请求合并切片,后端合并切片

断点续传:前后端通过传递 [0,1,1,0]

  • 1 表示该位置切片已上传
  • 0 表示该位置切片未上传

前端拿到后,再上传指定切片即可

秒传:后端根据文件前端传过来的文件 hash 值 和 文件名,判断该文件是否已经上传,已上传就告诉前端,可以展示“秒传”了

扩展

当然,这上面只是简单的案例

有很多情况没有考虑到,比如

  • 文件要是很大呢,那么生成对应 hash 值,是不是就很慢?

  • 要是多文件上传要怎么搞?

  • 检测接口,要是文件很多的情况下,遍历获取文件信息是不是就很慢了,是不是可以把这部分信息存在数据库呢?下次请求的时候,我们只需要去数据库查找是否有已经存在切片信息就可以了

  • ....

感兴趣的同学可以尝试优化一下


这是一个从 https://juejin.cn/post/7369027991441473587 下的原始话题分离的讨论话题