前言
在Vue项目中,大图片和多数据Excel等大文件的上传是一个非常常见的需求。然而,由于文件大小较大,上传速度很慢,传输中断等问题也难以避免。因此,为了提高上传效率和成功率,我们需要使用切片上传的方式,实现文件秒传、断点续传、错误重试、控制并发等功能,并绘制进度条。
整体架构流程
前端:使用vue3.0的版本实现大文件上传,在前端切片传递后端
后端:使用node接收切片,存储文件夹中,进行合并,返回前端
技术名词解释
- vue
- node
- Element-Ui
- axios
- spark-md5
-
fs
-
multiparty
前端
大文件上传
- 将大文件转换成二进制流的格式
- 利用流可以切割的属性,将二进制流切割成多份
- 组装和分割块同等数量的请求块,并行或串行的形式发出请求
- 待我们监听到所有请求都成功发出去以后,再给服务端发出一个合并的信号
断点续传
- 为每一个文件切割块添加不同的标识
- 当上传成功的之后,记录上传成功的标识
- 当我们暂停或者发送失败后,可以重新发送没有上传成功的切割文件
后端
- 接收每一个切割文件,并在接收成功后,存到指定位置,并告诉前端接收成功
- 收到合并信号,将所有的切割文件排序,合并,生成最终的大文件,然后删除切割小文件,并告知前端大文件的地址
其实说到这里,如果你看懂并且理解了以上的思路,那么你已经学会了大文件上传+断点续传的 80%。下面的具体实现过程,对你来讲,就是小意思...
大文件上传代码部分
<el-upload
class="upload-demo"
drag
action
multiple
:auto-upload="false"
:show-file-list="false"
:on-change="handleBeforeUpload"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</el-upload>
js部分的逻辑,按照我们的上面的分析,我们可以写出如下的结构
// 上传前处理,返回false改为手动上传
const handleBeforeUpload = async (file) => {
dialogVisible.value = false;
if (!file) return;
file = file.raw;
let buffer = await fileParse(file, "buffer"), // 拿到二进制流
spark = new SparkMD5.ArrayBuffer(), // 我们为了避免同一个文件(改名字)多次上传,我们引入了 spark-md5 ,根据具体文件内容,生成hash值
hash,
suffix;
spark.append(buffer);
hash = spark.end();
suffix = /\.([0-9a-zA-Z]+)$/i.exec(file.name)[1];
// 创建100个切片
let partList = [],
partsize = file.size / 100,
cur = 0;
for (let i = 0; i < 100; i++) {
let item = {
chunk: file.slice(cur, cur + partsize),
filename: `${hash}_${i}.${suffix}`, // 每一个切片命名当时候,改成了 hash_1,hash_2 这种形式
};
cur += partsize;
partList.push(item);
}
partLists.value = partList;
hashs.value = hash;
sendRequest(partList, hash);
};
const sendRequest = async (partList, hash) => {
// 根据100个切片创造100个请求(集合)
let requestList = [];
partList.forEach((item, index) => {
// 每一个函数都是发送一个切片请求
let fn = () => {
// 我们发出去的数据采用的是FormData数据格式
let formData = new FormData();
formData.append("chunk", item.chunk);
formData.append("filename", item.filename);
return axios
.post("http://localhost:3000/single3", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
.then((result) => {
result = result.data;
if (result.code == 0) {
total.value += 1;
// 传完的切片我们把它移除掉
partList.splice(index, 1);
}
});
};
requestList.push(fn);
});
// 传递切片:并发/串发 并发就是一块发,串发就是一个一个发
let i = 0;
let complete = async () => {
let result = await axios.get("http://localhost:3000/merge", {
params: {
hash: hash,
},
});
result = result.data;
if (result.code == 0) {
video.value = result.path;
}
};
let send = async () => {
if (i >= requestList.length) {
// 都传完了
complete();
return;
}
await requestList[i]();
i++;
send();
};
send();
};
将文件变成二进制,方便后续分片
js常见的二进制格式有 Blob,ArrayBuffer和Buffe,这里没有采用其他文章常用的Blob,而是采用了ArrayBuffer, 又因为我们解析过程比较久,所以我们采用 promise,异步处理的方式
export function fileParse(file, type = "base64") {
return new Promise(resolve => {
let fileRead = new FileReader();
if (type === "base64") {
fileRead.readAsDataURL(file);
} else if (type === "buffer") {
fileRead.readAsArrayBuffer(file);
}
fileRead.onload = (ev) => {
// console.log(ev.target.result);
resolve(ev.target.result);
}
})
}
将大文件进行分片
在我们拿到具体的二进制流之后我们就可以进行分块了,就像操作数组一样方便。
当然了,我们在拆分切片大文件的时候,还要考虑大文件的合并,所以我们的拆分必须有规律,比如 1-1,1-2,1-3 ,1-5 这样的,到时候服务端拿到切片数据,当接收到合并信号当时候,就可以将这些切片排序合并了。
同时,我们为了避免同一个文件(改名字)多次上传,我们引入了 spark-md5 ,根据具体文件内容,生成hash值
let buffer = await fileParse(file, "buffer"), // 拿到二进制流
spark = new SparkMD5.ArrayBuffer(), // 我们为了避免同一个文件(改名字)多次上传,我们引入了 spark-md5 ,根据具体文件内容,生成hash值
hash,
suffix;
spark.append(buffer);
hash = spark.end();
suffix = /\.([0-9a-zA-Z]+)$/i.exec(file.name)[1];
而我们,为每一个切片命名当时候,也改成了 hash-1,hash-2 这种形式,
我们分割大文件的时候,可以采用 定切片数量,定切片大小,两种方式,我们这里采用了 定切片数量这个简单的方式做例子
// 创建100个切片
let partList = [],
partsize = file.size / 100,
cur = 0;
for (let i = 0; i < 100; i++) {
let item = {
chunk: file.slice(cur, cur + partsize),
filename: `${hash}_${i}.${suffix}`, // 每一个切片命名当时候,改成了 hash_1,hash_2 这种形式
};
cur += partsize;
partList.push(item);
}
当我们采用定切片数量的方式,将我们大文件切割完成,并将切割后的数据存给一个数组变量,接下来,就可以封装的切片请求了
创建切片请求
这里需要注意的就是,我们发出去的数据采用的是FormData数据格式,至于为什么大家可以找资料查询。
// 根据100个切片创造100个请求(集合)
let requestList = [];
partList.forEach((item, index) => {
// 每一个函数都是发送一个切片请求
let fn = () => {
// 我们发出去的数据采用的是FormData数据格式
let formData = new FormData();
formData.append("chunk", item.chunk);
formData.append("filename", item.filename);
return axios
.post("http://localhost:3000/single3", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
.then((result) => {
result = result.data;
if (result.code == 0) {
total.value += 1;
// 传完的切片我们把它移除掉
partList.splice(index, 1);
}
});
};
requestList.push(fn);
});
将每一个切片 并行/串行 的方式发出
目前切片已经分好了,并且我们的请求也已经包装好了。 目前我们有两个方案 并行/串行 因为串行容易理解,这里拿串行举例子。
我们每成功的发出去一个请求,那么我们对应的下标就加一,证明我们的发送成功。当 i 下标和 我们的切片数相同的时候,我们默认发送成功,触发 合并(merge)请求
// 传递切片:并发/串发 并发就是一块发,串发就是一个一个发
let i = 0;
let complete = async () => {
let result = await axios.get("http://localhost:3000/merge", {
params: {
hash: hash,
},
});
result = result.data;
if (result.code == 0) {
video.value = result.path;
}
};
let send = async () => {
if (i >= requestList.length) {
// 都传完了
complete();
return;
}
await requestList[i]();
i++;
send();
};
send();
当然了,并行发送的最大缺点就是没有串行快,但胜在代码简单,容易理解代码
后端接口逻辑
首先引入fs模块和multiparty模块,进行存储文件夹
// 大文件断点续传
// 引入fs模块
const fs = require('fs')
const multiparty = require("multiparty"),
uploadDir = `${__dirname}/upload`;
接下来是接收前端切片文件进行存储文件夹,判断有无此文件,如果有就实现秒传,如果没有就上传,上传不需要上传已经上传的切片,这就实现了断点续传问题,上面有注释,我就不多解释了
// 切片上传 && 合并
app.post('/single3', async (req, res) => {
let { fields, files } = await handleMultiparty(req, res, true);
let [chunk] = files.chunk,
[filename] = fields.filename;
let hash = /([0-9a-zA-Z]+)_\d+/.exec(filename)[1],
// suffix = /\.([0-9a-zA-Z]+)&/.exec(files.name)[1],
path = `${uploadDir}/${hash}`; //把所有切片放在这个文件夹中,会临时创建一个文件夹,然后把文件都存储到这个文件夹中
!fs.existsSync(path) ? fs.mkdirSync(path) : null; //如果不存储创建一个
path = `${path}/${filename}`; //拿到上传地址的名字
// 判断是否存储
fs.access(path, async err => {
// 存在的则不再进行任何处理
// 传过的不用在传了,就是断点续传,实现秒传
if (!err) {
res.send({
code: 0,
path: path.replace(__dirname, `http://localhost:3000`)
});
return;
}
// 为了测试出效果,延迟1秒钟
// await new Promise(resolve => {
// setTimeout(_ => {
// resolve();
// }, 200);
// })
// 不存在的在创建
// 通过fs文件流的操作,把不存在的存储到文件夹下
let readStream = fs.createReadStream(chunk.path),
writeStream = fs.createWriteStream(path);
readStream.pipe(writeStream);
readStream.on('end', function () {
fs.unlinkSync(chunk.path);
res.send({
code: 0,
path: path.replace(__dirname, `http://localhost:3000`)
})
})
})
})
而handleMultiparty是封装成了方法,里面是multiparty的一些配置,为了存储文件夹和判断文件大小问题
function handleMultiparty(req, res, temp) {
return new Promise((resolve, reject) => {
// multiparty的配置
let options = {
maxFieldsSize: 200 * 1024 * 1024 // 上传文件大小
};
!temp ? options.uploadDir = uploadDir : null;
let form = new multiparty.Form(options);
// multiparty解析
form.parse(req, function (err, fields, files) {
if (err) {
res.send({
code: 1,
reason: err
});
reject(err);
return;
}
// 成功
resolve({
fields,
files
})
})
})
}
最后进行合并切片,返回给前端
app.get('/merge', async(req, res) => {
let { hash,name } = req.query;
// 拿到叫hash哈希值的所有的切片
let path = `${uploadDir}/${hash}`,
fileList = fs.readdirSync(path),
suffix;
// 进行排序
fileList.sort((a, b) => {
let reg = /_(\d+)/;
return reg.exec(a)[1] - reg.exec(b)[1];
}).forEach(item => {
// 合并文件,通过fs中的appendFileSync全部合并到一起
!suffix ? suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1] : null;
fs.appendFileSync(`${uploadDir}/${hash}.${suffix}`, fs.readFileSync(`${path}/${item}`));
fs.unlinkSync(`${path}/${item}`);
});
// 把所以的切片文件全部删除掉
fs.rmdirSync(path);
// 把文件地址存储到数据库中
let body = {
path:`/${hash}.${suffix}`,
name:name
}
await fileModel.create(body)
res.send({
code: 0,
path: `http://localhost:3000/upload/${hash}.${suffix}`
})
})
小结
看到这里你已经会大文件上传、断点续传、秒传、多大文件上传了,这是个人的一些想法思路,如果有更好的方法可以分享,有问题可以留言评论
如果你感觉这篇文章对你有帮助,欢迎关注我的博客