文件上传的方案
大文件上传
:将大文件切分成较小的片段(通常称为分片或块),然后逐个上传这些分片。这种方法可以提高上传的稳定性,因为如果某个分片上传失败,只需要重新上传该分片而不需要重新上传整个文件。同时,分片上传还可以利用多个网络连接并行上传多个分片,提高上传速度。断点续传
:在上传过程中,如果网络中断或上传被中止,断点续传技术可以记录已成功上传的分片信息,以便在恢复上传时继续上传未完成的部分,而不需要重新上传整个文件。这种技术可以大大减少上传失败的影响,并节省时间和带宽。
安装依赖:
express:启动后端服务,并且提供接口
multer:读取文件,存储
cors:解决跨域
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磁盘存储引擎来配置。
let upload = multer({dest:"attachment/"});
storage:自定义存储引擎,可以是磁盘存储引擎,也可以是内存存储引擎。
multer.diskStorage()
是磁盘存储引擎,磁盘存储引擎可以控制文件的存储。它是一个函数,函数接受一个 options 配置对象,配置对象有两个属性,属性值都是函数。destination用于指定文件存储的路径;filename用于指定文件的存储名称。
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 字段,里面包含了整个文件数据。
当使用内存存储,上传非常大的文件,或者非常多的小文件,会导致应用程序内存溢出。
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 | 在上传的表单类型数据中,键值对最大组数 |
const multer=require("multer"); let upload=multer({ limits:{ files:2, //最多上传2个文件 fileSize:5120 //设置单个文件最大为 5kb } });
fileFilter属性值是一个函数,用来控制什么文件可以上传以及什么文件应该跳过
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
。
//一次最多上传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 属性。
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” 错误。
let upload=multer({dest:"attachment/"}).none();
multer(options).any()
:接收一切上传的文件。
let upload=multer({dest:"attachment/"}).any();
错误处理机制
当遇到一个错误,multer 将会把错误发送给 express。如果想捕捉 Multer 错误,可以使用 multer 对象下的 MulterError 类 (即 err instanceof multer.MulterError)。
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 请求让后端进行切片的合并
前端代码:
<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 :before-upload="beforeUpload" :http-request="upload" :show-file-list="false" > <template #trigger> <el-button type="primary">选取文件</el-button> </template> <template #tip> <div>不能低于1M</div> </template> </el-upload> <el-dialog v-model="dialogVisible" :fullscreen="true" :show-close="false" custom-class="dispute-upload-dialog" > <div> <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> .dispute-upload-dialog { background: none; } </style> <style 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:用于合并切片,根据前端上传的文件名合并文件切片,并按照切片的索引从小到大进行合并。合并完的切片需要删除切片并将合并的文件存储。
后端代码:
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'); });