nest开发:十一 文件上传,大文件切片,断点续传(前后端)vue3+Nest.js

1,073 阅读2分钟

一、过程分析

image.png 一、要实现断点续传我们需要拿到文件的hash值,因为只有这样我们才能完成一个识别,当我们的文件名发生改变时,他的文件hash也不会改变,文件名重复的文件的hash也不相同。

  1. 前端我们用spark-md5 这个包去生成文件的hash
  2. npm install --save spark-md5(GitHub - satazor/js-spark-md5: Lightning fast normal and incremental md5 for javascript)
  3. 先安装好后备用

二、nest后端

  1. 前端需要发送一次get请求获取该文件是否存在,如果存在则获取目录的列表生成一个列表信息并返回,不存在则需要创建一个文件目录用于存储切片信息。
  2. 然后post发送切片,后端存储到刚才创建的文件目录
  3. 发送完成后发送合并请求,将文件合并。(需要携带一个合并后缀名)

一、先在main的bootstrap下创建一个images目录

  //创建images目录
  try {
    mkdirSync(join(__dirname, 'images'));
  } catch (error) {}

二、在service下创建三个api接口。切片名字(前端传入)规范需要一个自增的文件名字,以便断点和合并需要。

getDirectory(hash: string) {
    //判断目录是否存在
    try {
      mkdirSync(join(__dirname, `../images/${hash}`));
      return {
        message: '不存在',
        files: [],
      };
    } catch (e) {
      //获取目录下文件,用于断点续传和判断文件是否已经完成
      const files = readdirSync(join(__dirname, `../images/${hash}`));
      return {
        message: '存在',
        files,
      };
    }
  }
  //上传切片
  uploadFile(hash: string, myfilename: string, file: any) {
    const ws = createWriteStream(
      join(__dirname, `../images/${myfilename}`, `${hash}`),
    );
    ws.write(file.buffer);
    return true;
  }
  //合并切片
  merge(hash: string, count: number, suffix: string) {
    const Filebuffers: Buffer[] = [];
    for (let i = 1; i < count; i++) {
      const buffer = readFileSync(
        join(__dirname, `../images/${hash}`, `${hash}_${i}`),
      );
      Filebuffers.push(buffer);
    }
    const buffer = Buffer.concat(Filebuffers);
    const ws = createWriteStream(
      join(__dirname, `../images`, `${hash}.${suffix}`),
    );
    ws.write(buffer);
    return '合并成功';
  }

三、在controller下创建三个接口。nest上传文件文档

  //判断目录是否存在
  @Get('album')
  getDirectory(@Query() query) {
    return this.uploadService.getDirectory(query.hash);
  }
  //上传文件,参考官网文档
  @Post('album')
  @UseInterceptors(FileInterceptor('file'))
  uploadFile(@UploadedFile() file, @Body() body) {
    // console.log(body);
    const message = this.uploadService.uploadFile(
      body.hash,
      body.myfilename,
      file,
    );
    return message;
  }
  //切片合并
  @Post('merge')
  merge(@Body() Body) {
    // console.log(query);
    return this.uploadService.merge(Body.hash, Body.count, Body.suffix);
  }

三、前端,前端只需要切片和上传就可以

一、我们用刚才安装的spark-md5包去生成文件的唯一hash

import SparkMD5 from "spark-md5";
/**
 * @description: 拿到文件的hash值
 * @Author: hfunteam
 * @param {*} file 文件,File类型
 * @return {
 *   buffer: 文件的二进制流
 *   hash: 生成的文件hash值
 *   suffix:文件后缀名
 *   filename: 文件名称 hash
 * }
 */
const changeBuffer: any = function (file: File) {
  return new Promise((resolve) => {
    let fileReader = new FileReader();
    fileReader.readAsArrayBuffer(file);
    fileReader.onload = (ev) => {
      let buffer: ArrayBuffer = ev.target!.result as ArrayBuffer,
        spark = new SparkMD5.ArrayBuffer(),
        suffix;
      spark.append(buffer);
      // 拿到文件的hash值
      let hash = spark.end();

      // 匹配文件后缀名
      suffix = /\.([a-zA-Z0-9]+)$/.exec(file.name)![1];
      resolve({
        buffer,
        hash,
        suffix,
        filename: `${hash}`,
      });
    };
  });
};

前端完整代码

<template>
  <div>上传</div>
  <input type="file" ref="input" @change="upload" />
  <div style="width: 100%; border: 1px black solid">
    <div id="progress" style="background-color: red; width: 0">
      {{ (progressindex / mycount) * 100 }}%
    </div>
  </div>
</template>

<script setup lang="ts">
import SparkMD5 from "spark-md5";
import axios from "axios";
import { getCurrentInstance, ref } from "vue";
const instance = getCurrentInstance();
let progressindex = ref(0);
let mycount = ref(1);
/**
 * @description: 拿到文件的hash值
 * @Author: hfunteam
 * @param {*} file 文件,File类型
 * @return {
 *   buffer: 文件的二进制流
 *   hash: 生成的文件hash值
 *   suffix:文件后缀名
 *   filename: 文件名称 hash
 * }
 */
const changeBuffer: any = function (file: File) {
  return new Promise((resolve) => {
    let fileReader = new FileReader();
    fileReader.readAsArrayBuffer(file);
    fileReader.onload = (ev) => {
      let buffer: ArrayBuffer = ev.target!.result as ArrayBuffer,
        spark = new SparkMD5.ArrayBuffer(),
        suffix;
      spark.append(buffer);
      // 拿到文件的hash值
      let hash = spark.end();

      // 匹配文件后缀名
      suffix = /\.([a-zA-Z0-9]+)$/.exec(file.name)![1];
      resolve({
        buffer,
        hash,
        suffix,
        filename: `${hash}`,
      });
    };
  });
};
const upload = async function () {
  let ln = 0;
  let files: any[] = [];
  let file = (instance?.proxy?.$refs.input as any).files[0];
  console.log(file);
  // 获取文件的HASH
  let alreadyUploadChunks: any[] = [], // 当前已经上传的切片
    { hash, suffix, filename } = await changeBuffer(file);
  // 获取已经上传的切片信息
  let Ifexits: any = await axios.get("/upload/album", {
    params: {
      hash,
    },
  });
  console.log(Ifexits);
  if (Ifexits.data.message == "存在") {
    // 从后端拿到已经上传的切片列表
    files = Ifexits.data.files;
    progressindex.value = files.length;
    // ln = files.length
  }

  // 实现文件切片处理 「固定数量 或者 固定大小」
  let max = 1024 * 100, // 切片大小
    count = Math.ceil(file.size / max), // 切片总数
    index = 0, // 当前上传的切片索引值
    chunks = []; // 存放切片的数组
  mycount.value = count;
  console.log(count, "??", index);
  if (count == index) {
    console.log("文件已存在");
    (document.getElementById("progress") as HTMLElement).style.width = `100%`;
  } else {
    // if (count > 100) {
    //   max = file.size / 100;
    //   count = 100;
    // }
    // 存放切片,注意此处的切片名称,hash+index+suffix
    while (index < count) {
      chunks.push({
        file: file.slice(index * max, (index + 1) * max),
        filename: `${hash}_${index + 1}`,
      });
      index++;
    }
    // 把每一个切片都上传到服务器上
    chunks.forEach((chunk) => {
      // 这里进行断点续传:已经上传的无需在上传
      if (!files.includes(chunk.filename)) {
        let formdata = new FormData();
        formdata.append("file", chunk.file);
        formdata.append("hash", chunk.filename);
        formdata.append("myfilename", filename);
        axios
          .post("/upload/album", formdata)
          .then((data: any) => {
            // 管控进度条
            progressindex.value++;
            manageProgress(progressindex.value, count, hash, suffix);
          })
          .catch(() => {
            // alert("当前切片上传失败,请您稍后再试");
          });
      }
    });
  }
};
const manageProgress = async function (
  index: any,
  count: any,
  hash: any,
  suffix: string
) {
  // console.log(index, "---", count)
  // 管控进度
  (document.getElementById("progress") as HTMLElement).style.width = `${
    (index / count) * 100
  }%`;
  // 当所有切片都上传成功,我们合并切片
  if (index < count) return;
  (document.getElementById("progress") as HTMLElement).style.width = `100%`;
  try {
    let data: any = await axios.post(
      "/upload/merge",
      {
        hash,
        count,
        suffix,
      },
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    );
    if (data.code === 0) {
      alert(`恭喜您,文件上传成功`);
      return;
    }
    throw data.codeText;
  } catch (err) {
    // alert("切片合并失败,请您稍后再试");
  }
};
// setTimeout(() => {
//   axios.get("http://localhost:3000/upload");
// }, 1000);
</script>
<style lang="scss" scoped></style>