Vue3+NestJs大文件上传
主要思路:
- 前端使用文件的slice把大文件切割成多个小文件
- 后端收到小文件根据索引排序,然后合并小文件,返回文件的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
函数处理文件变化
- 更新当前文件状态;
- 将文件分割成块;
- 通过文件计算MD5;
- 验证文件是否已上传;
- 发送分块请求进行文件上传。
/**
* 处理文件变化的事件处理器。
* @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)
}
先筛选出未上传的文件分片,上传完成再发起合并分片请求,在axios
的onUploadProgress
获取到上传进度
/**
* 异步发送分片上传请求,并在所有分片上传完成后合并文件。
*
* @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