likes
comments
collection
share

Vue3+NestJs大文件上传

作者站长头像
站长
· 阅读数 29

主要思路:

  1. 前端使用文件的slice把大文件切割成多个小文件
  2. 后端收到小文件根据索引排序,然后合并小文件,返回文件的url

前端

HTML结构

<template>
  <div>
    <el-upload
      drag
      action="#"
      :on-change="handleChange"
      :auto-upload="false"
      :on-remove="handleRemove"
      :limit="1"
    >
      <div class="">
        <div v-if="!videoUrl">
          <el-icon class="el-icon--upload"><upload-filled /></el-icon>
          <div class="el-upload__text">
            Drop file here or <em>click to upload</em>
          </div>
        </div>
        <video :src="videoUrl" v-else controls class="video-class"></video>
      </div>
      <template #tip v-if="chunks.length > 0">
        <div class="el-upload__tip">
          <div>
            总进度:
            <el-progress
              type="line"
              :percentage="uploadPercentage"
            ></el-progress>
          </div>
          <div v-if="uploadPercentage !== 100">
            <div>切片进度:</div>
            <template v-for="(chunk, index) in chunks" :key="index">
              {{ curFile.name }} - 分片{{ index + 1 }}:
              <el-progress
                type="line"
                :percentage="chunk.percentage"
              ></el-progress>
            </template>
          </div>
        </div>
      </template>
    </el-upload>
  </div>
    </template>

handleChange函数处理文件变化

  1. 更新当前文件状态;
  2. 将文件分割成块;
  3. 通过文件计算MD5;
  4. 验证文件是否已上传;
  5. 发送分块请求进行文件上传。
/**
 * 处理文件变化的事件处理器。
 * @param uploadFile 上传的文件对象,包含文件的各种信息和原始文件。
 * 该函数主要步骤包括:
 * 1. 更新当前文件状态;
 * 2. 将文件分割成块;
 * 3. 通过文件计算MD5;
 * 4. 验证文件是否已上传;
 * 5. 发送分块请求进行文件上传。
 */
const handleChange = async (uploadFile: UploadFile) => {
  curFile.value = uploadFile // 更新当前文件状态为上传的文件
  const fileChunkList = createChunk(uploadFile.raw!) // 将原始文件分割成块列表
  chunks.value = fileChunkList // 更新分块列表状态
  const { md5, suffix } = await getMd5ByFile(uploadFile.raw!, fileChunkList) // 计算文件MD5和获取文件后缀
  const uploadedFiles = await verifyFile(md5, suffix) // 验证文件是否已经上传
  await sendChunkRequest(md5, suffix, uploadedFiles) // 发送分块上传请求
    }

使用slice切割文件

/**
 * 创建文件的分块列表。
 * @param file 上传的原始文件对象,必须实现 .slice() 方法以获取文件的特定部分。
 * @returns 返回一个包含文件分块的对象数组,每个分块都包含文件的一个部分。
 */
const createChunk = (file: UploadRawFile) => {
  const chunks = [] // 用于存储文件分块的数组
  let start = 0 // 分块的起始位置

  // 循环切割文件直到达到文件的末尾
  while (start < file.size) {
    // 将文件从起始位置切割成指定大小的分块,并添加到chunks数组中
    chunks.push({ file: file.slice(start, start + chunkSize) })
    start += chunkSize // 更新起始位置为当前分块的结束位置
  }

  return chunks // 返回生成的文件分块列表
}

根据worker计算出md5

/**
 * 通过文件计算MD5值
 * @param file 上传的原始文件对象,具有文件名等信息
 * @param fileChunkList 文件切片列表,用于在worker中进行计算
 * @returns 返回一个Promise对象,解析时返回文件的MD5值和后缀名
 */
const getMd5ByFile = (file: UploadRawFile, fileChunkList: any[]): any => {
  // 提取文件后缀名
  const reg = /\.([a-zA-Z0-9]+)$/.exec(file.name)
  const suffix = reg ? reg[1] : ''
  
  return new Promise((resolve) => {
    // 创建并初始化worker用于异步计算文件的MD5值
    worker.value = new Worker('../../public/hash.js')
    worker.value.postMessage({ fileChunkList }) // 向worker发送文件切片列表进行计算
    worker.value.onmessage = (e) => { // 当worker计算完成并返回结果时
      const { hash } = e.data
      if (hash) {
        resolve({ md5: hash, suffix }) // 解析Promise返回文件的MD5值和后缀名
      }
    }
  })
}
// 导入必要的脚本
self.importScripts('/spark-md5.min.js')

// 当接收到来自主线程的消息时,计算文件块列表的MD5哈希值
self.onmessage = (e) => {
  const { fileChunkList } = e.data // 解构获取文件块列表
  const spark = new self.SparkMD5.ArrayBuffer() // 初始化MD5计算器
  let count = 0 // 用于追踪处理的文件块数量
  /**
   * 递归加载和处理文件块
   * @param {number} index - 当前处理的文件块索引
   */
  const loadNext = (index) => {
    const reader = new FileReader() // 创建文件读取器
    reader.readAsArrayBuffer(fileChunkList[index].file) // 读取指定索引的文件块
    reader.onload = (e) => { // 文件块读取完成时的处理
      count++ // 处理的文件块计数加一
      spark.append(e.target.result) // 将读取到的文件块数据加入MD5计算
      if (count === fileChunkList.length) { // 如果所有文件块都已处理
        self.postMessage({ // 将计算得到的MD5哈希值发送回主线程
          hash: spark.end(),
        })
        self.close() // 关闭工作线程
      } else { // 如果还有文件块未处理
        loadNext(count) // 加载下一个文件块
      }
    }
  }
  // 从第一个文件块开始处理
  loadNext(0)
}

先筛选出未上传的文件分片,上传完成再发起合并分片请求,在axiosonUploadProgress获取到上传进度

/**
 * 异步发送分片上传请求,并在所有分片上传完成后合并文件。
 * 
 * @param md5 文件的MD5值,用于标识文件。
 * @param suffix 文件的后缀名。
 * @param uploadedFiles 已上传文件的名称列表。
 * 
 * 此函数首先过滤出还未上传的文件分片,然后对这些分片分别发起上传请求,
 * 在所有分片上传完成后,发起合并文件的请求,并更新视频的URL。
 */
const sendChunkRequest = async (
  md5: string,
  suffix: string,
  uploadedFiles: string[]
) => {
  // 筛选出还未上传的文件分片
  const uploadedChunks = chunks.value.filter((file, index) => {
    const name = md5 + '.' + suffix + '-' + index
    file.name = name
    if (uploadedFiles.includes(name)) file.percentage = 100
    return !uploadedFiles.includes(name)
  })

  // 准备上传列表,为每个未上传的分片发起上传请求
  const uploadList = uploadedChunks.map((file) => {
    const data = new FormData()
    data.set('name', file.name)
    data.append('files', file.file)
    return axios.post('http://localhost:3000/upload', data, {
      onUploadProgress: createProgressHandler(file),
    })
  })

  // 等待所有分片上传完成
  await Promise.all(uploadList)

  // 合并文件,并更新视频URL
  const res = await axios.get(
    'http://localhost:3000/merge?name=' + md5 + '.' + suffix
  )
  videoUrl.value = res.data.url
}

计算文件分片、整体进度

/**
 * 创建一个处理进度的函数
 * @param item 任意对象,用于存储进度百分比
 * @returns 返回一个函数,该函数接收一个包含加载进度的事件对象作为参数
 */
const createProgressHandler = (item: any) => {
  /**
   * 更新传入item对象的percentage属性为当前加载的百分比
   * @param e 任意事件对象,应包含loaded(已加载部分)和total(总加载量)属性
   */
  return (e: any) => {
    item.percentage = parseInt(String((e.loaded / e.total) * 100))
  }
}

/**
 * 计算当前文件上传的百分比。
 * @returns {Number} 返回当前文件上传的百分比,如果不存在当前文件或没有上传块,则返回0。
 */
const uploadPercentage = computed(() => {
  // 检查当前文件是否存在以及上传块是否有内容,若无,则直接返回0
  if (!curFile.value || !chunks.value.length) return 0
  
  // 计算已上传的块数
  let uploaded = chunks.value.filter((item) => item.percentage).length
  
  // 根据已上传的块数计算并返回上传百分比,保留两位小数
  return Number(((uploaded / chunks.value.length) * 100).toFixed(2))
})

全部代码

<template>
  <div>
    <el-upload
      drag
      action="#"
      :on-change="handleChange"
      :auto-upload="false"
      :on-remove="handleRemove"
      :limit="1"
    >
      <div class="">
        <div v-if="!videoUrl">
          <el-icon class="el-icon--upload"><upload-filled /></el-icon>
          <div class="el-upload__text">
            Drop file here or <em>click to upload</em>
          </div>
        </div>
        <video :src="videoUrl" v-else controls class="video-class"></video>
      </div>
      <template #tip v-if="chunks.length > 0">
        <div class="el-upload__tip">
          <div>
            总进度:
            <el-progress
              type="line"
              :percentage="uploadPercentage"
            ></el-progress>
          </div>
          <div v-if="uploadPercentage !== 100">
            <div>切片进度:</div>
            <template v-for="(chunk, index) in chunks" :key="index">
              {{ curFile.name }} - 分片{{ index + 1 }}:
              <el-progress
                type="line"
                :percentage="chunk.percentage"
              ></el-progress>
            </template>
          </div>
        </div>
      </template>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import axios from 'axios'
import { UploadFile, UploadRawFile } from 'element-plus'
import { computed, ref } from 'vue'

const videoUrl = ref('')
const chunks = ref<any[]>([])
const curFile = ref<any>()
const chunkSize = 1 * 1024 * 1024
const worker = ref<Worker>()

const handleRemove = () => {
  chunks.value = []
  videoUrl.value = ''
  curFile.value = null
}

/**
 * 处理文件变化的事件处理器。
 * @param uploadFile 上传的文件对象,包含文件的各种信息和原始文件。
 * 该函数主要步骤包括:
 * 1. 更新当前文件状态;
 * 2. 将文件分割成块;
 * 3. 通过文件计算MD5和获取文件后缀;
 * 4. 验证文件是否已上传;
 * 5. 发送分块请求进行文件上传。
 */
const handleChange = async (uploadFile: UploadFile) => {
  curFile.value = uploadFile // 更新当前文件状态为上传的文件
  const fileChunkList = createChunk(uploadFile.raw!) // 将原始文件分割成块列表
  chunks.value = fileChunkList // 更新分块列表状态
  const { md5, suffix } = await getMd5ByFile(uploadFile.raw!, fileChunkList) // 计算文件MD5和获取文件后缀
  console.log('md5', md5)
  const uploadedFiles = await verifyFile(md5, suffix) // 验证文件是否已经上传
  await sendChunkRequest(md5, suffix, uploadedFiles) // 发送分块上传请求
}

/**
 * 通过文件计算MD5值
 * @param file 上传的原始文件对象,具有文件名等信息
 * @param fileChunkList 文件切片列表,用于在worker中进行计算
 * @returns 返回一个Promise对象,解析时返回文件的MD5值和后缀名
 */
const getMd5ByFile = (file: UploadRawFile, fileChunkList: any[]): any => {
  // 提取文件后缀名
  const reg = /\.([a-zA-Z0-9]+)$/.exec(file.name)
  const suffix = reg ? reg[1] : ''

  return new Promise((resolve) => {
    // 创建并初始化worker用于异步计算文件的MD5值
    worker.value = new Worker('../../public/hash.js')
    worker.value.postMessage({ fileChunkList }) // 向worker发送文件切片列表进行计算
    worker.value.onmessage = (e) => {
      // 当worker计算完成并返回结果时
      const { hash } = e.data
      if (hash) {
        resolve({ md5: hash, suffix }) // 解析Promise返回文件的MD5值和后缀名
      }
    }
  })
}

/**
 * 创建文件的分块列表。
 * @param file 上传的原始文件对象,必须实现 .slice() 方法以获取文件的特定部分。
 * @returns 返回一个包含文件分块的对象数组,每个分块都包含文件的一个部分。
 */
const createChunk = (file: UploadRawFile) => {
  const chunks = [] // 用于存储文件分块的数组
  let start = 0 // 分块的起始位置

  // 循环切割文件直到达到文件的末尾
  while (start < file.size) {
    // 将文件从起始位置切割成指定大小的分块,并添加到chunks数组中
    chunks.push({ file: file.slice(start, start + chunkSize) })
    start += chunkSize // 更新起始位置为当前分块的结束位置
  }

  return chunks // 返回生成的文件分块列表
}

const verifyFile = async (md5: string, suffix: string) => {
  const res = await axios.get(
    'http://localhost:3000/verify?name=' + md5 + '.' + suffix
  )
  return res.data.files
}

/**
 * 异步发送分片上传请求,并在所有分片上传完成后合并文件。
 *
 * @param md5 文件的MD5值,用于标识文件。
 * @param suffix 文件的后缀名。
 * @param uploadedFiles 已上传文件的名称列表。
 *
 * 此函数首先过滤出还未上传的文件分片,然后对这些分片分别发起上传请求,
 * 在所有分片上传完成后,发起合并文件的请求,并更新视频的URL。
 */
const sendChunkRequest = async (
  md5: string,
  suffix: string,
  uploadedFiles: string[]
) => {
  // 筛选出还未上传的文件分片
  const uploadedChunks = chunks.value.filter((file, index) => {
    const name = md5 + '.' + suffix + '-' + index
    file.name = name
    if (uploadedFiles.includes(name)) file.percentage = 100
    return !uploadedFiles.includes(name)
  })

  // 准备上传列表,为每个未上传的分片发起上传请求
  const uploadList = uploadedChunks.map((file) => {
    const data = new FormData()
    data.set('name', file.name)
    data.append('files', file.file)
    return axios.post('http://localhost:3000/upload', data, {
      onUploadProgress: createProgressHandler(file),
    })
  })

  const enqueue = requestQueue(6)
  for (const iterator of uploadList) {
    enqueue(() => iterator)
  }
  // 等待所有分片上传完成
  // await Promise.all(uploadList)

  // 合并文件,并更新视频URL
  const res = await axios.get(
    'http://localhost:3000/merge?name=' + md5 + '.' + suffix
  )
  videoUrl.value = res.data.url
}
const requestQueue = (concurrency: number) => {
  concurrency = concurrency || 6 // 最大并发数
  const queue: any[] = [] // 请求池
  let current = 0

  const dequeue = () => {
    while (current < concurrency && queue.length) {
      current++
      const requestPromiseFactory = queue.shift() // 出列
      requestPromiseFactory()
        .then(() => {
          // 成功的请求逻辑
        })
        .catch((error: any) => {
          // 失败
          console.log(error)
        })
        .finally(() => {
          current--
          dequeue()
        })
    }
  }

  return (requestPromiseFactory: any) => {
    queue.push(requestPromiseFactory) // 入队
    dequeue()
  }
}

/**
 * 创建一个处理进度的函数
 * @param item 任意对象,用于存储进度百分比
 * @returns 返回一个函数,该函数接收一个包含加载进度的事件对象作为参数
 */
const createProgressHandler = (item: any) => {
  /**
   * 更新传入item对象的percentage属性为当前加载的百分比
   * @param e 任意事件对象,应包含loaded(已加载部分)和total(总加载量)属性
   */
  return (e: any) => {
    item.percentage = parseInt(String((e.loaded / e.total) * 100))
  }
}

/**
 * 计算当前文件上传的百分比。
 * 该计算属性不接受任何参数。
 *
 * @returns {Number} 返回当前文件上传的百分比,如果不存在当前文件或没有上传块,则返回0。
 */
const uploadPercentage = computed(() => {
  // 检查当前文件是否存在以及上传块是否有内容,若无,则直接返回0
  if (!curFile.value || !chunks.value.length) return 0

  // 计算已上传的块数
  let uploaded = chunks.value.filter((item) => item.percentage).length

  // 根据已上传的块数计算并返回上传百分比,保留两位小数
  return Number(((uploaded / chunks.value.length) * 100).toFixed(2))
})
</script>


后端

全部代码

import {
  Body,
  Controller,
  Get,
  Post,
  Query,
  UploadedFiles,
  UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { FilesInterceptor } from '@nestjs/platform-express';
import * as fs from 'fs';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  /**
   * 上传文件的处理方法。
   * 使用`FilesInterceptor`来处理多文件上传,限制同时上传的文件数量为20,并指定上传文件的存储目录。
   * 接收上传的文件和请求体中的数据,将文件按照特定的规则进行处理和移动。
   *
   * @param files 由上传的文件组成的数组,类型为`Express.Multer.File`。
   * @param body 请求体中的数据。
   * @returns 如果文件已存在,则删除新上传的文件并结束处理;否则,将文件移动到指定目录。
   */
  @Post('upload')
  @UseInterceptors(
    FilesInterceptor('files', 20, {
      dest: 'uploads',
    }),
  )
  uploadFiles(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body,
  ) {
    // 从请求体中获取文件名,并处理以分离出基本文件名
    const fileName = body.name.match(/(.+)\-\d+$/)[1];
    // 定义存储文件块的目录和完整文件的存储路径
    const chunkDir = 'uploads/chunks_' + fileName;
    const filePath = 'uploads/' + fileName;

    // 检查完整文件是否已存在,如果存在,则删除新上传的文件
    if (fs.existsSync(filePath)) {
      fs.rmSync(files[0].path);
      return;
    }
    // 检查文件块的存储目录是否存在,如果不存在,则创建
    if (!fs.existsSync(chunkDir)) {
      fs.mkdirSync(chunkDir);
    }
    // 将上传的文件移动到文件块的存储目录
    fs.cpSync(files[0].path, chunkDir + '/' + body.name);
    // 删除原上传文件
    fs.rmSync(files[0].path);
  }

  /**
   * 获取指定名称的文件分片信息。
   *
   * @param name 通过查询参数传递的文件名。
   * @returns 返回一个对象,包含一个名为`files`的数组,列出指定文件名下的所有分片文件。
   */
  @Get('verify')
  async verify(@Query('name') name: string) {
    // 构造文件分片存储目录的路径
    const chunkDir = 'uploads/chunks_' + name;

    // 检查分片目录是否存在
    if (fs.existsSync(chunkDir)) {
      // 获取并排序分片文件
      const files = fs.readdirSync(chunkDir);
      files.sort((a: any, b: any) => a.split('-')[1] - b.split('-')[1]);
      return { files };
    } else {
      // 目录不存在时,返回空的文件列表
      return { files: [] };
    }
  }

  /**
   * 将上传的文件块合并成一个完整的文件,并返回文件的URL。
   * @param name 文件名。
   * @returns 返回一个包含文件URL的对象。
   */
  @Get('merge')
  async merge(@Query('name') name: string) {
    const chunkDir = 'uploads/chunks_' + name; // 存放文件块的目录
    const filePath = 'uploads/' + name; // 完整文件的路径

    // 检查完整文件是否已经存在
    if (fs.existsSync(filePath)) {
      return { url: `http://localhost:3000/${filePath}` };
    }

    // 读取并排序文件块
    const files = fs.readdirSync(chunkDir);
    files.sort((a: any, b: any) => a.split('-')[1] - b.split('-')[1]);

    let count = 0; // 已处理的文件块计数
    let startPos = 0; // 当前文件块的开始位置
    const url = await new Promise((resolve) => {
      files.map((file) => {
        const filePath = chunkDir + '/' + file; // 单个文件块的路径
        const stream = fs.createReadStream(filePath); // 创建读取流
        stream
          .pipe(
            fs.createWriteStream('uploads/' + name, {
              start: startPos,
            }),
          )
          .on('finish', () => {
            // 当文件块写入完成时
            count++;

            // 所有文件块合并完成时,删除文件块目录并解决Promise
            if (count === files.length) {
              fs.rm(
                chunkDir,
                {
                  recursive: true,
                },
                () => {},
              );
              resolve(`http://localhost:3000/${filePath}`);
            }
          });

        startPos += fs.statSync(filePath).size; // 更新下一个文件块的开始位置
      });
    });
    return { url };
  }
}

转载自:https://juejin.cn/post/7366203409190879284
评论
请登录