文件上传的方案
大文件上传
:将大文件切分成较小的片段(通常称为分片或块),然后逐个上传这些分片。这种方法可以提高上传的稳定性,因为如果某个分片上传失败,只需要重新上传该分片而不需要重新上传整个文件。同时,分片上传还可以利用多个网络连接并行上传多个分片,提高上传速度。断点续传
:在上传过程中,如果网络中断或上传被中止,断点续传技术可以记录已成功上传的分片信息,以便在恢复上传时继续上传未完成的部分,而不需要重新上传整个文件。这种技术可以大大减少上传失败的影响,并节省时间和带宽。
安装依赖:
- express:启动后端服务,并且提供接口
- multer:读取文件,存储
- cors:解决跨域
js
npm i express
npm i multer
npm i cors
multer中间件
- Multer 是一个node.js中间件,用于处理
multipart/form-data
类型的表单数据,主要用于上传文件。 - enctype = "multipart/form-data"
- Multer 不会处理任何非
multipart/form-data
类型的表单数据。 - 不要将 Multer 作为全局中间件使用,因为恶意用户可以上传文件到一个没有预料到的路由,应该只在需要处理上传文件的路由上使用。
初始化multer
Multer是一个函数,接受一个 options 配置对象,其中最基本的是storage属性,这将告诉 Multer 将上传文件保存在哪。如果省略 options 对象,这些文件将保存在内存中,永远不会写入磁盘,options 配置如下:
属性值 | 描述 |
---|---|
dest 或者 storage | 在哪里存储文件 |
limits | 限制上传数据的大小 |
fileFilter | 文件过滤器,控制哪些文件可以被接受 |
preservePath | 保存包含文件名的完整文件路径 |
- dest:指定上传文件的存储路径。文件名默认为随机字符。如果想自定义文件名称,使用storage自定义存储引擎属性,属性值用multer.diskStorage磁盘存储引擎来配置。
js
let upload = multer({dest:"attachment/"});
- storage:自定义存储引擎,可以是磁盘存储引擎,也可以是内存存储引擎。
multer.diskStorage()
是磁盘存储引擎,磁盘存储引擎可以控制文件的存储。它是一个函数,函数接受一个 options 配置对象,配置对象有两个属性,属性值都是函数。destination用于指定文件存储的路径;filename用于指定文件的存储名称。
js
import multer from 'multer' // 上传文件处理
// 自定义磁盘存储引擎
const storage = multer.diskStorage({
// 存储文件的目录
destination(req, file, callback) {
console.log(file,"destination");//打印结果如下图
callback(null, 'uploads/'); // 第一个参数是error错误对象,不需要设置为null,第二个参数是每个上传文件存储的目录
},
// 存储文件的名称
filename(req, file, callback) {
console.log(req.body,"canshu"); // 接受前端传的参数,multipart/form-data 类型
callback(null, `${req.body.index}-${req.body.filename}`); // 文件的名称
}
});
// 初始化multer中间件,multer是一个函数,接受的参数是一个配置对象
const uploadContainer = multer({
storage // 自定义存储
});
multer.memoryStorage()
是内存存储引擎
- 内存存储引擎将文件存储在内存中的 Buffer 对象,它没有任何选项。
- 当使用内存存储引擎,文件信息将包含一个 buffer 字段,里面包含了整个文件数据。
- 当使用内存存储,上传非常大的文件,或者非常多的小文件,会导致应用程序内存溢出。
js
let storage = multer.memoryStorage()
let upload = multer({storage})
- limits:用来指定一些数据大小的限制,设置 limits 可以帮助保护站点抵御拒绝服务 (DoS) 攻击。是一个对象,包含如下属性:
属性 | 值类型 | 默认值 | 描述 |
---|---|---|---|
files | Number | 无限 | 上传时,文件的最大数量 |
fileSize | Number | 无限 | 上传时,每一个文件最大长度 (单位:bytes) |
fields | Number | 无限 | 上传时,可以提交非文件的字段的数量 |
fieldNameSize | Number | 100 bytes | 上传时,每一个字段名字的最大长度 |
fieldSize | Number | 1048576 bytes,即1MB | 上传时,每一个字段名的属性值的最大长度 |
parts | Number | 无限 | 上传时,传输的最大数量(fields + files) |
headerPairs | Number | 2000 | 在上传的表单类型数据中,键值对最大组数 |
js
const multer=require("multer");
let upload=multer({
limits:{
files:2, //最多上传2个文件
fileSize:5120 //设置单个文件最大为 5kb
}
});
- fileFilter属性值是一个函数,用来控制什么文件可以上传以及什么文件应该跳过
js
let storage = multer.memoryStorage()
var upload = multer({
fileFilter(req, file, cb) {
// 通过调用cb,用boolean值来指示是否应接受该文件
// 拒绝这个文件,使用false,像这样:
cb(null, false)
// 接受这个文件,使用true,像这样:
cb(null, true)
// 如果有问题,可以总是这样发送一个错误:
cb(new Error('I don\'t have a clue!'))
}})
multer方法
multer(options).single(fieldname)
:上传单个文件,比如一次只上传一张图片。fieldname
是前端传参的上传文件的字段名称。然后它会自动将文件存储到设置的路径。
multer(options).array(fieldname[,maxCount])
:适用于同一个字段,一次上传多个文件的情况,例如用户选择多张图片发送,接受一个以 fieldname 命名的文件数组,fieldname
是前端传参的上传文件的字段名称。maxCount可选参数,可以指定限制上传的最大数量。这些文件的信息保存在 req.files
。
js
//一次最多上传3个文件
let upload=multer({dest:"attachment/"}).array("photo",3);
// 前端传参格式
let formdata = new FromData();
const fileList = [fileObj1,fileObj2,fileObj3];
formdata.append('photo',fileList)
上传的数据格式如下:
multer(options).fields(fields)
:适用于上传多个字段的情况。接受指定 fields 的混合文件。这些文件的信息保存在 req.files
。fields 是一个对象数组,具有 name 和可选的 maxCount 属性。
js
let fieldsList=[
{name:"photo1"},
{name:"photo2",maxCount:2}
]
let upload=multer({dest:"attachment/"}).fields(fieldsList);
// 前端传参格式
let formdata = new FromData();
const fileList = [fileObj2,fileObj3];
formdata.append('photo1',fileObj1)
formdata.append('photo2',fileList)
上传的数据格式如下:
multer(options).none()
:接收只有文本域的表单,如果上传任何文件,会返回 "LIMIT_UNEXPECTED_FILE" 错误。
js
let upload=multer({dest:"attachment/"}).none();
multer(options).any()
:接收一切上传的文件。
js
let upload=multer({dest:"attachment/"}).any();
错误处理机制
当遇到一个错误,multer 将会把错误发送给 express。如果想捕捉 Multer 错误,可以使用 multer 对象下的 MulterError 类 (即 err instanceof multer.MulterError)。
js
var multer = require("multer")
var upload = multer().single("photo")
upload(req, res, function (err) {
if (err instanceof multer.MulterError) {
// 捕捉 Multer 错误
} else if (err) {
// 捕捉 express 错误
} else {
// 上传成功
}
})
前端
上传部分的逻辑可以分为三部分:
- 对每个切片进行包装:首先将数据放进 formdata 中,然后发起请求
- 使用
Promise.all
发送请求 - 发起 merge 请求让后端进行切片的合并
前端代码:
vue
<script setup>
import { ref } from "vue";
import axios from "axios";
import { ElMessage } from "element-plus";
// 文件列表
const fileListArray = ref([]);
// 文件上传前限制文件类型和大小
const beforeUpload = (file) => {
// const mimeTypes = ['audio/mpeg', 'audio/x-m4a', 'audio/aac', 'video/mp4', 'video/x-m4v']
// if (!mimeTypes.includes(file.type)) {
// ElMessage({
// type: 'error',
// message: '只能上传 MP3、M4A、AAC、MP4、M4V 格式的文件',
// duration: 6000
// })
// return false
// }
if (file.size / 1024 / 1024 / 1024 > 1.5) {
ElMessage({
type: "error",
message: "文件大小不能超过 1.5G",
duration: 6000,
});
return false;
}
return true;
};
// 当前切片上传 AbortController
let controller = null;
// 上传进度
const percentage = ref(0);
const dialogVisible = ref(false);
// 取消上传
const cancelUpload = ref(false);
// 文件总大小
let fileTotal = 0;
// 已上传文件切片的大小
let loadedSize = 0;
// 文件的名字
let fileCancelName = '';
// 分割文件为多个切片
const sliceFile = (file, chunkSize = 1024 * 10) => {
// file 接受文件对象,chunkSize 文件切片大小,1024*1024表示为1M,这里默认是是10kb,实际可以根据项目情况进行设定
// 存放切片文件的数组
let chunks = [];
// file.size 文件对象取出文件大小,单位是字节byte,1024byte = 1kb
for (let i = 0; i < file.size; i += chunkSize) {
// 切割文件:[0-10kb,10kbM-20kb,20kb-30kb,...]
// i切片开始位置,i + chunkSize切片结束位置
chunks.push(file.slice(i, Math.min(i + chunkSize, file.size)));
}
return chunks;
};
// 上传单个文件切片,对每个切片进行包装并上传:首先将数据放进 formdata 中,然后发起请求
const uploadChunk = (chunk, index, filename, mimeTypes) => {
controller = new AbortController(); // 每一次上传切片都要新生成一个 AbortController ,否则重新上传会失败
return new Promise((resolve, reject) => {
// 将数据放进 formdata 中
const formData = new FormData();
// 每个切片标识字段
formData.append("index", index);
// 文件名字段
formData.append("filename", filename);
// 文件MIME类型字段
formData.append("type", mimeTypes);
// 切片文件,上传的文件必须写在最后(因为后端multer模块读取参数时,当读取到文件时就会停止读取参数,其后面的参数就不会再处理了)
formData.append("file", chunk);
if (cancelUpload.value) {
// 若已经取消上传,则不再上传切片
console.log("cacel");
return;
}
// 发起请求
axios({
url: "http://localhost:3000/upload",
method: "POST",
data: formData,
// `onUploadProgress` 允许为上传处理进度事件
onUploadProgress: (progressEvent) => {
// progressEvent.loaded 为已上传文件字节数,progressEvent.total 为文件总字节数
// 处理原生进度事件,计算上传进度,将每个已经上传的切片文件的字节数相加再除以文件总的大小
loadedSize += progressEvent.loaded;
percentage.value = Number(
((Math.min(fileTotal, loadedSize) / fileTotal) * 100).toFixed(2)
);
},
signal: controller.signal, // 取消上传
})
.then(resolve)
.catch(reject);
});
};
// 使用Promise.all上传所有切片
const uploadFileChunks = async (chunks, filename, mimeTypes) => {
// chunks 存储切割完成的文件切片数组 filename 上传的文件切片的文件名
try {
const res = await Promise.all(
chunks.map((chunk, index) =>
uploadChunk(chunk, index, filename, mimeTypes)
)
);
// 合并文件的名字
const fileName = res[0].data.data.filename;
// 文件类型
const type = res[0].data.data.type;
console.log("All chunks uploaded successfully");
setTimeout(() => {
// 延迟关闭上传进度框用户体验会更好
dialogVisible.value = false;
ElMessage({
message: "上传成功",
type: "success",
});
axios.post("http://localhost:3000/merge", {
filename: fileName,
mimeTypes: type,
}); // 调用后端合并切片接口,参数需要与后端对齐
}, 500);
} catch (error) {
console.error("Error uploading chunks", error);
}
};
// 自行实现上传文件的请求,使用Promise.all上传所有切片
const upload = async (file) => {
percentage.value = 0; // 每次上传文件前清空进度条
dialogVisible.value = true; // 显示上传进度
cancelUpload.value = false; // 每次上传文件前将取消上传标识置为 false
// 上传的文件
const uoloadfile = file.file;
fileTotal = uoloadfile.size;
// 获取文件名
const filename = uoloadfile.name.split(".")[0];
fileCancelName = filename;
// 获取文件的MIME类型
const mimeTypes = uoloadfile.name.split(".")[1];
// 将文件进行切片
const chunks = sliceFile(uoloadfile);
// 开始上传
uploadFileChunks(chunks, filename, mimeTypes);
};
// 取消上传
const cancel = () => {
dialogVisible.value = false;
cancelUpload.value = true;
controller?.abort();
axios.post("http://localhost:3000/cancelUpload", { filename: fileCancelName }); // 调用后端接口,删除已上传的切片
};
</script>
<template>
<div>
<el-upload
class="upload-demo"
:before-upload="beforeUpload"
:http-request="upload"
:show-file-list="false"
>
<template #trigger>
<el-button type="primary">选取文件</el-button>
</template>
<template #tip>
<div class="el-upload__tip">不能低于1M</div>
</template>
</el-upload>
<el-dialog
v-model="dialogVisible"
:fullscreen="true"
:show-close="false"
custom-class="dispute-upload-dialog"
>
<div class="center">
<div class="fz-18 ellipsis">正在上传</div>
<el-progress
:text-inside="true"
:stroke-width="16"
:percentage="percentage"
/>
<el-button @click="cancel">取消上传</el-button>
</div>
</el-dialog>
</div>
</template>
<style lang="scss">
.dispute-upload-dialog {
background: none;
}
</style>
<style lang="scss" scoped>
.center {
color: #fff;
width: 50%;
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
上传切片:
取消上传:
上传完成
后端(node.js)
后端主要提供两个接口
- upload:用来存储切片,用multer中间件帮忙处理文件的存储
- merge:用于合并切片,根据前端上传的文件名合并文件切片,并按照切片的索引从小到大进行合并。合并完的切片需要删除切片并将合并的文件存储。
后端代码:
js
import fs from 'node:fs' // 文件操作
import path from 'node:path' // 路劲操作
import express from 'express' // 提供接口服务
import multer from 'multer' // 上传文件处理
import cors from 'cors' // 解决跨域
// 自定义磁盘存储引擎
const storage = multer.diskStorage({
// 存储文件的目录
destination(req, file, callback) {
callback(null, 'uploads/'); // 第一个参数是error错误对象,不需要设置为null,第二个参数是每个上传文件存储的目录
},
// 存储文件的名称
filename(req, file, callback) {
// req.body接受前端传的参数,multipart/form-data 类型
callback(null, `${req.body.index}-${req.body.filename}`); // 文件的名称
}
});
// 初始化multer中间件,multer是一个函数,接受的参数是一个配置对象
const uploadContainer = multer({
storage // 自定义存储
});
const app = express();
// 注册跨域中间件,支持跨域
app.use(cors());
app.use(express.json());
// 上传切片接口,在上传文件的接口加上multer中间件
app.post('/upload', uploadContainer.single('file'), (req, res) => {
// 前端传入的字段
const { filename, type } = req.body;
res.send({
succes: true,
msg: '切片上传成功',
data: {
filename,
type
}
});
});
// 合并切片接口
app.post('/merge', async (req, res) => {
// 读取存放切片的目录:process.cwd()会返回当前文件的工作目录的绝对路径;path.join()使用系统的分隔符将路径片段进行拼接
const uploadDir = path.join(process.cwd(), 'uploads');
console.log(uploadDir, 'path join');
// 读取存放切片目录下的所有切片文件,返回的是一个数组,但是数组是乱序的
let files = fs.readdirSync(uploadDir);
console.log(files, 'files Array');
// 给数组排序,按照切片的索引进行升序排序
files = files.sort((a, b) => a.split('-')[0] - b.split('-')[0]);
console.log(files, 'files Array by order');
// 合并切片后完整的文件存放的路径
const writePath = path.join(process.cwd(), `finallyFile`, `${req.body.filename}.${req.body.mimeTypes}`);
console.log(writePath, 'writePath');
// 遍历切片数组,合并切片
files.forEach((item) => {
// 合并切片
// readFileSync()读取文件,appendFile()以追加方式写文件
// writePath:写入文件的路径
// data:要写入文件的数据此处是fs.readFileSync(path.join(uploadDir, item))读取出来的每个切片
// path.join(uploadDir, item)):拼接的每个切片的路径
fs.appendFileSync(writePath, fs.readFileSync(path.join(uploadDir, item)));
// 删除合并完的切片
fs.unlinkSync(path.join(uploadDir, item));
});
res.send({
succes: true,
msg: '文件上传成功',
});
});
// 删除切片
app.post('/cancelUpload', (req, res) => {
// 读取存放切片的目录:process.cwd()会返回当前文件的工作目录的绝对路径;path.join()使用系统的分隔符将路径片段进行拼接
const uploadDir = path.join(process.cwd(), 'uploads');
// 读取存放切片目录下的所有切片文件,返回的是一个数组
let files = fs.readdirSync(uploadDir);
// 需要删除的切片数组
const unlinkArr = files.filter(item => {
// 前端传入的文件名
if (item.indexOf(req.body.filename) != -1) {
return true;
}
});
unlinkArr.forEach(item => {
// 删除需要删除的切片
fs.unlinkSync(path.join(uploadDir, item));
});
res.send({
succes: true,
msg: '文件取消上传成功',
});
});
app.listen(3000, () => {
console.log('3000 port is running');
});