前端:vue
后端:nodejs
+ express
先了解一下为什么需要切片上传,在上传文件的时候,假如文件大小高达几百上千MB的时候,上传的时间就会变得很长,如果出现网络不稳定或者浏览器、程序崩溃等,上传就会失败,而此时文件切片上传以及断点续传就很重要了,当然还有大文件秒传,这是文件的 hash值 来实现的,如果服务器存在相同 hash值 的文件则直接告诉前端文件上传成功,本文先讲切片上传 以及切片合并。
先来看前端代码: 首先我们需要一个 input
, 并且 type="file"
,以及一个上传的按钮,具体代码如下
js
<template>
<div>
<input type="file" @change="onFileChange" />
<button @click="upload">Upload</button>
</div>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
file: null,//上传的文件
filename: "",//文件名
};
},
methods: {
onFileChange(event) {
this.file = event.target.files[0];
console.log(event.target.files[0]);
this.filename = this.file.name
},
async upload() {
if (!this.file) {
return;
}
/**
* chunkSize 为切片大小,单位为字节
* totalChunks 为切片总数 Math.ceil 为向上取整
* chunkIndex 为当前切片的索引
* chunkPromises 为所有切片的 Promise
*/
const chunkSize = 10 * 1024 * 1024; // 10MB
const totalChunks = Math.ceil(this.file.size / chunkSize);
const chunkPromises = [];
// 进行切片
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
// FormData 对象,可以存储二进制数据和表单数据,注意append的顺序,chunk放在最后
// 确保后端能先解析到chunkIndex, totalChunks ,filename 用于文件命名
const formData = new FormData();
formData.append('chunkIndex', chunkIndex); //key:chunkIndex value:每个切片的索引
formData.append('totalChunks', totalChunks); //key:totalChunks value:切片总数
formData.append('filename', this.file.name); //key:filename value:文件名
formData.append('file', chunk); //key:file value:每个切片的二进制数据
const uploadPromise = axios.post('http://127.0.0.1:3000/upload', formData);
chunkPromises.push(uploadPromise);
// const fileReader = new FileReader();
// fileReader.onload = (e) => {
// console.log(e);
// }
// fileReader.readAsDataURL(chunk);
}
console.log(chunkPromises);
const res=await Promise.all(chunkPromises);
console.log('res',res);
// 所有切片上传完成后,可以发送请求告知服务器文件上传完成
await axios.post('http://127.0.0.1:3000/upload/complete', { filename: this.filename });
// 上传完成后的回调
console.log('文件上传完成');
}
}
};
</script>
再来看下nodejs实现的后端代码,先来看下依赖:
js
"dependencies": {
"express": "^4.18.2",
"multer": "1.4.5-lts.1"
}
先来引入需要用到的包以及解决跨域问题
js
const express = require('express');
const multer = require('multer');
const app = express();
const fs = require('fs');
const path = require('path');
app.use((req, res, next) => {
// 允许所有域名的跨域请求
res.header('Access-Control-Allow-Origin', '*');
// 设置允许的HTTP头部类型
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// 设置允许的HTTP方法
if (req.method === 'OPTIONS') {
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
return res.status(200).json({});
}
// 继续处理请求
next();
});
配置中间件解析 JSON 请求
js
// express.json() 是一个中间件,用于解析 JSON 格式的请求体
app.use(express.json());
写一下接收文件切片的接口,
js
// 很显然,这里使用了个中间件,是multer存储文件的
app.post('/upload',upload.single('file'),(req, res) => {
console.log('返回数据---');
res.send({
code: 200,
msg: '上传成功',
});
});
其中upload.single('file')
,这个中间件是multer
提供的,下面来配置一下,每当调用这个接口的时候,都会触发下面的配置。
配置一下 multer 包,这是一个第三方库,可以配置自动将文件存储在服务器指定的目录下,记得使用中间件upload.single('file')
js
// 配置文件存储 这个是multer库的配置,当接口使用upload.single('file')中间件时会自动调用这个配置
const storage = multer.diskStorage({
// 设置文件存储的位置,其中回调函数cb的第一个参数表示错误信息,第二个参数表示文件的存储路径
destination: function (req, file, cb) {
// 创建临时文件夹
try {
fs.mkdirSync(path.resolve(__dirname, 'temp'), { recursive: true });
} catch (error) {
console.log(' 创建临时文件夹失败',error);
}
cb(null, path.resolve(__dirname, 'temp'));
},
// 设置文件名,同上,第一个参数表示错误信息,第二个参数表示文件名
filename: function (req, file, cb) {
// console.log('reqbody',req.body);//打印req.body,
// console.log('file',file);
cb(null, file.fieldname + '_' + req.body.chunkIndex);
}
});
//这个应该就是 multer 使用刚刚那个配置需要调用的
const upload = multer({ storage: storage });
这里的 filename
回调函数是用于命名的,记得前端传过来的FormData中的切片chunk放在最后,命名格式如下:
这样可以根据后面的数字进行排序。
再来写一下切片上传完成后合并切片的接口
js
//当前端所有切片上传完成之后,会调用这个接口进行合并
app.post('/upload/complete', (req, res) => {
// console.log('filename',req.body.filename);
const filename = req.body.filename;
combineChunks(path.resolve(__dirname, 'acceptFiles/'+filename),path.resolve(__dirname, 'temp'))
res.json('文件上传完成');
});
// combineChunks 方法用于将所有切片合并到一个文件中
// 第一个参数:存放路径,其中工作目录`__dirname`,文件存放目录 `acceptFiles`,文件名 filename
// 第二个参数: 切片存放的目录路径
const combineChunks = (outputFilename, tempDir) => {
// fs.reddir 读取文件夹中的文件 , files 返回文件列表
fs.readdir(tempDir, (err, files) => {
if (err) {
console.error('Error reading directory:', err);
return;
}
//将文件列表进行排序 因为每个切片命名为,举例: hhh.mp4, 每个切片 为 file_0,file_1 ,依次类推,
//顺序对于切片合并来说非常重要,顺序错误合并的文件也会错误
files.sort((a, b) => {
return parseInt(a.split('_').pop()) - parseInt(b.split('_').pop())
})
console.log('排序后files',files);
// 获取目录路径
const directoryPath = path.dirname(outputFilename);
// 创建目录,如果它不存在
fs.mkdir(directoryPath, { recursive: true }, (err) => {
if (err) {
return console.error('创建目录时发生错误:', err);
}
});
// 创建一个可写流用于输出文件
const output = fs.createWriteStream(outputFilename);
(async function() {
for (const file of files) {
console.log('file',file)
// 对每个文件,创建一个Promise以确保按序写入
await new Promise((resolve, reject) => {
// 获取当前文件的完整路径
const filePath = path.resolve(tempDir, file);
// 创建一个读取当前文件的流
const readStream = fs.createReadStream(filePath);
// 如果读取流发生错误,拒绝这个Promise
readStream.on('error', reject);
// 如果写入流发生错误,也拒绝这个Promise
output.on('error', reject);
// 当读取流结束时,解决这个Promise,表示当前文件已经写入完毕
readStream.on('end', resolve);
// 使用 pipe 方法将读取流的内容写入 output 可写流中
// end: false 参数表示在当前文件写入完成后不关闭输出流
readStream.pipe(output, { end: false });
});
}
// 当所有文件写入完成后,关闭输出流
output.end();
})()
.then(
// 删除存放切片文件夹,recursive: true 表示递归删除
() => fs.rm(tempDir, { recursive: true },
(err) => {
if (err) {
// 处理错误
console.error(err);
} else {
// 删除成功
console.log('Directory removed successfully.');
}
})
)
.catch(e=>{
console.log('发生错误',e)
});
})
}
下面来试一下,选择一个文件
点击 upload,可以看到,temp目录下有切片,并且切片合并好放在了 acceptFiles 目录了。
当合并完成之后,会执行 fs.rm 方法删除存放切片的 temp 目录。
后端完整代码:
js
const express = require('express');
const multer = require('multer');
const app = express();
const fs = require('fs');
const path = require('path');
app.use((req, res, next) => {
// 允许所有域名的跨域请求
res.header('Access-Control-Allow-Origin', '*');
// 设置允许的HTTP头部类型
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// 设置允许的HTTP方法
if (req.method === 'OPTIONS') {
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
return res.status(200).json({});
}
// 继续处理请求
next();
});
// express.json() 是一个中间件,用于解析 JSON 格式的请求体
app.use(express.json());
// 配置文件存储 这个是multer库的配置,当接口使用upload.single('file')中间件时会自动调用这个配置
const storage = multer.diskStorage({
// 设置文件存储的位置,其中回调函数的第一个参数表示错误信息,第二个参数表示文件的存储路径
destination: function (req, file, cb) {
// 创建临时文件夹
try {
fs.mkdirSync(path.resolve(__dirname, 'temp'), { recursive: true });
} catch (error) {
console.log(' 创建临时文件夹失败',error);
}
cb(null, path.resolve(__dirname, 'temp'));
},
// 设置文件名,同上,第一个参数表示错误信息,第二个参数表示文件名,这里有坑,下面说
filename: function (req, file, cb) {
// console.log('reqbody',req.body);
// console.log('file',file);
cb(null, file.fieldname + '_' + req.body.chunkIndex);
}
});
//这个应该就是 multer 使用刚刚那个配置需要调用的
const upload = multer({ storage: storage });
app.post('/upload',upload.single('file'),(req, res) => {
console.log('返回数据---');
res.send({
code: 200,
msg: '上传成功',
});
});
//当前端所有切片上传完成之后,会调用这个接口进行合并
app.post('/upload/complete', (req, res) => {
// console.log('filename',req.body.filename);
const filename = req.body.filename;
combineChunks(path.resolve(__dirname, 'acceptFiles/'+filename),path.resolve(__dirname, 'temp'))
res.json('文件上传完成');
});
app.listen(3000, () => {
console.log('服务器已启动');
});
// 这个方法用于将所有切片合并到一个文件中
// 第一个参数:存放路径,其中工作目录`__dirname`,文件存放目录 `acceptFiles`,文件名 filename
// 第二个参数: 切片存放的目录路径
const combineChunks = (outputFilename, tempDir) => {
// fs.reddir 读取文件夹中的文件 , files 返回文件列表
fs.readdir(tempDir, (err, files) => {
if (err) {
console.error('Error reading directory:', err);
return;
}
/**
将文件列表进行排序 因为每个切片命名为,举例: hhh.mp4, 每个切片 为 hhh.mp4_0,hhh.mp4_1 ,依次类推,
顺序对于切片合并来说非常重要,顺序错误合并的文件也会错误
* */
files.sort((a, b) => {
return parseInt(a.split('_').pop()) - parseInt(b.split('_').pop())
})
console.log('排序后files',files);
// 获取目录路径
const directoryPath = path.dirname(outputFilename);
// 创建目录,如果它不存在
fs.mkdir(directoryPath, { recursive: true }, (err) => {
if (err) {
return console.error('创建目录时发生错误:', err);
}
});
// 创建一个可写流用于输出文件
const output = fs.createWriteStream(outputFilename);
(async function() {
for (const file of files) {
console.log('file',file)
// 对每个文件,创建一个Promise以确保按序写入
await new Promise((resolve, reject) => {
// 获取当前文件的完整路径
const filePath = path.resolve(tempDir, file);
// 创建一个读取当前文件的流
const readStream = fs.createReadStream(filePath);
// 如果读取流发生错误,拒绝这个Promise
readStream.on('error', reject);
// 如果写入流发生错误,也拒绝这个Promise
output.on('error', reject);
// 当读取流结束时,解决这个Promise,表示当前文件已经写入完毕
readStream.on('end', resolve);
// 使用 pipe 方法将读取流的内容写入 output 可写流中
// end: false 参数表示在当前文件写入完成后不关闭输出流
readStream.pipe(output, { end: false });
});
}
// 当所有文件写入完成后,关闭输出流
output.end();
})()
.then(
// 删除临时文件夹,recursive: true 表示递归删除
() => fs.rm(tempDir, { recursive: true },
(err) => {
if (err) {
// 处理错误
console.error(err);
} else {
// 删除成功
console.log('Directory removed successfully.');
}
})
)
.catch(e=>{
console.log('发生错误',e)
});
})
}