vue-大文件分片及断点上传
最近开发过程中,有一个大文件分片上传的功能,借鉴于网上的思路,结合自己后端的逻辑,完成了这个功能,在此记录如下:
界面展示:
一、将大文件分片上传写为一个组件,可以全局注册该组件,也可以在使用的页面注册该组件,使用vuex通讯进行组件间传值
由于我有好几个页面需要使用大文件上传,所以我是在App.vue注册该组件
<template>
<a-config-provider :locale="locale">
<div id="app">
<router-view />
<!-- 将分片上传组件全局注册 -->
<big-uploader></big-uploader>
</div>
</a-config-provider>
</template>
<script>
...
import BigUploader from './components/bigUploader.vue';
export default {
data () {
return {
...
};
},
components: { BigUploader },
...
};
</script>
二、文件选择上传框通过文件上传前的钩子,会有项目的业务逻辑判断,校验文件空间是否还有,判断文件是否为重复文件,提示是覆盖还是重新上传,校验都通过后就去调起大文件上传的组件
beforeUpload(file) {
const self = this
let size = file.size
let params = {
fileSize: size,
projectCode: self.projectCode
}
if (size === 0) {
this.$message.error('上传文件不能为0KB')
return false
}
const fileName = file.name
let fileType = fileName.split('.')
fileType = fileType[fileType.length - 1].toLowerCase()//转小写
if (this.accept.indexOf(fileType) == -1) {
this.$message.error('请上传指定文件类型')
return false;
}
if (this.uploadFlag) return false;
this.uploadFlag = true
return new Promise(() => {
try {
// 一、校验文件空间
request(xxx).then(res => {
if (res.code === 200) {
// 二、计算MD5
self.calculate(file, async function (md5) {
const par = {
fileName: file.name,
md5Value: md5,
parantCode: self.parentCode,
projectCode: self.projectCode
}
// 三、文件覆盖检查
request(xxx).then(res => {
console.log(res)
if (res.code === 200 && res.data) {
//同名同类型提示是否覆盖
self.$confirm({
title: false,
content: '该文件夹下存在同名同类型的文件,是否确认覆盖?',
onOk() {
// 覆盖
self.implementSetFile(file, md5)
},
onCancel() {
self.handelModelCancal();
},
});
} else {
// 新文件
self.implementSetFile(file, md5)
}
return false
})
})
}
}).catch(() => {
self.uploadFlag = false
return false;
})
}
catch {
self.uploadFlag = false
return false;
}
})
},
implementSetFile(file, md5) {
// 设置当前上传文件
this.setFile({
page: this.curMenuName.name,
bucket: 'privately',
projectCode: this.projectCode,
parentCode: this.parentCode,
record: this.record,
file: file,
md5: md5
})
// 设置显示上传框
this.setShowBigUpload(true)
// this.$emit('ok');
this.uploadFlag = false
this.$emit('closeUpload')
},
/**
* 计算md5,实现断点续传及秒传
* @param file
*/
calculate(file, callBack) {
var fileReader = new FileReader(),
blobSlice = File.prototype.mozSlice || File.prototype.webkitSlice || File.prototype.slice,
chunkSize = 2097152,
// read in chunks of 2MB
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5();
fileReader.onload = function (e) {
spark.appendBinary(e.target.result); // append binary string
currentChunk++;
if (currentChunk < chunks) {
loadNext();
}
else {
callBack(spark.end());
}
};
function loadNext() {
var start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsBinaryString(blobSlice.call(file, start, end));
}
loadNext();
},
三、大文件组件
data中定义当前上传下标,上传并发数,允许每个分片最多允许重传数,上传文件列队数组等值
通过mapGetters获取vuex值
...mapGetters({
showUploadBox: `${NAMESPACE_UPLOAD}/${GET_SHOW_BIG_UPLOAD}`,
file: `${NAMESPACE_UPLOAD}/${GET_FILE}`,
chunkSize: `${NAMESPACE_UPLOAD}/${GET_CHUNKSIZE}`,
threads: `${NAMESPACE_UPLOAD}/${GET_THREADS}`,
getUplodRes: `${NAMESPACE_UPLOAD}/${GET_UPLOAD_RES}`,
}),
通过监听file变化,触发大文件上传排队
watch: {
file: {
immediate: true,
handler: function (newVal) {
if (newVal) {
const tempMd5 = newVal.md5
// 判断文件是否在排队中
if (this.upFileObj[tempMd5]) {
this.$message.warn('当前文件已经在上传排队中,请不要重复选择')
} else {
newVal.fileIndex = this.uploadFiles.length
this.upFileObj[tempMd5] = newVal
this.uploadQueue(tempMd5)
}
}
}
}
}
通过uploadQueue组件上传排队当前文件的分片,通过md5校验资源是否已经存在,记录资源是否存在和是否需要上传
/*
* 上传排队
* 计算当前文件的Md5和分片
*/
uploadQueue(md5) {
console.log('1.当前文件上传排队')
const self = this;
const tempFile = self.upFileObj[md5]
const fileName = tempFile.file.name
const curFileSize = tempFile.file.size
self.handleInitRawFile(tempFile);
// 检验资源是否存在
request(xxx).then(res => {
if (res.code === 200) {
var resData = res.data
const fileIndex = tempFile.fileIndex
const filesArr = self.uploadFiles;
if (resData) {
// 资源已经存在
console.log('3.上传文件已经存在', resData)
filesArr[fileIndex].Already = true
filesArr[fileIndex].resData = resData
self.$set(filesArr, fileIndex, filesArr[fileIndex]);
} else {
// 需要上传
let needChunk = false
// 不需要分片
if (curFileSize < self.chunkSize) {
needChunk = false
} else {
needChunk = true
}
filesArr[fileIndex].needChunk = needChunk
self.$set(filesArr, fileIndex, filesArr[fileIndex]);
}
// 判断是否正在上传中
if (self.uploadLock) {
console.log('有文件正在上传中...')
} else {
self.beforeUpload()
}
}
})
},
/*
** 初始化部分自定义上传属性
*/
handleInitRawFile(rawFile) {
console.log('2.初始化部分自定义上传属性')
rawFile.status = fileStatus.md5;
rawFile.initFail = this.file
rawFile.chunkList = [];
rawFile.uploadProgress = 0;
rawFile.fakeUploadProgress = 0; // 假进度条,处理恢复上传后,进度条后移的问题
rawFile.hashProgress = 0;
this.uploadFiles.push(rawFile);
},
文件正式上传前,判断资源是否存在直接秒传,是否需要上传,上传的话是否需要进行分片,秒传和不需要分片都是简单的业务逻辑,下面展示一下主要的分片代码
需要分片,组件分片上传数组
/*
** 开始组建上传数组
*/
async handleUpload() {
console.log('6.开始组建上传数组')
if (!this.uploadLock) return;
const filesArr = this.uploadFiles;
const fileUpIdx = this.fileUpIdx
const fileChunkList = this.createFileChunk(filesArr[fileUpIdx].file);
if (filesArr[fileUpIdx].status !== 'resume') {
this.status = Status.hash;
// hash校验,是否为秒传
filesArr[fileUpIdx].hash = await this.calculateHash(fileChunkList);
// 若清空或者状态为等待,则跳出循环
if (this.status === Status.wait) {
console.log('若清空或者状态为等待,则跳出循环');
return
}
}
this.status = Status.uploading;
filesArr[fileUpIdx].status = fileStatus.uploading;
filesArr[fileUpIdx].fileHash = filesArr[fileUpIdx].hash; // 文件的hash,合并时使用
filesArr[fileUpIdx].chunkList = fileChunkList.map(({ file }, index) => ({
fileHash: filesArr[fileUpIdx].hash,
fileName: filesArr[fileUpIdx].file.name,
index,
hash: filesArr[fileUpIdx].hash + '-' + index,
chunk: file,
size: file.size,
uploaded: false, // 标识:是否已完成上传
progress: 0,
status: 'wait' // 上传状态,用作进度状态显示
}));
this.$set(filesArr, fileUpIdx, filesArr[fileUpIdx]);
...
},
分片-初始化任务的时候,将分片数组及文件md5传给后端,后端返回该文件上传的任务id,及没有上传的片段数组及小片段id,已经上传的片段,实现秒传效果,这里就是断点续传的主要逻辑思路
initJob() {
console.log('7.分片-初始化任务')
const filesArr = this.uploadFiles;
const fileUpIdx = this.fileUpIdx
const uploadFileMd5 = filesArr[fileUpIdx].md5
let detailList = []
filesArr[fileUpIdx].chunkList.forEach((item, idx) => {
detailList.push({
extInfo: '',
// file: item.chunk,
num: idx
})
})
const params = {
md5HashValue: uploadFileMd5,
detailList: detailList
}
request(xxx).then(res => {
console.log('分片-初始化任务', res)
if (res.code === 200) {
const resData = res.data
if (resData.jobStatus === 1) {
console.log('上传文件已经存在', resData)
} else {
let sliceList = []
this.jobCode = resData.jobCode
resData.sliceList.forEach(item => {
const tt = {
...item,
...filesArr[fileUpIdx].chunkList[item.num]
}
sliceList.push(tt)
})
// this.sliceList = sliceList
filesArr[fileUpIdx].chunkList = sliceList
this.$set(filesArr, fileUpIdx, filesArr[fileUpIdx]);
//没有上传的分片
let noUpSlice = [], yesUpSlice = [];
sliceList.filter((item, idx) => {
item.num = idx
if (item.uploadStatus === 0) {
noUpSlice.push({ ...item });
} else {
yesUpSlice.push({ ...item });
}
})
// const noUpSlice = sliceList.filter(({ uploadStatus }) => uploadStatus === 0)
console.log('没有上传的分片', noUpSlice)
if (noUpSlice.length === 0) {
this.mergeRequest();
} else {
if (yesUpSlice.length > 0) {
yesUpSlice.forEach(item => {
this.createProgresshandler(100, item.num)
})
}
this.uploadChunks(noUpSlice, sliceList);
}
}
}
})
},
将切片传输给服务端,进行并发上传处理,所有的分片上传完成,向服务端进行合并请求
async uploadChunks(data, allData) {
console.log('8.将切片传输给服务端')
return new Promise(async (resolve, reject) => {
const requestDataList = data.map(({ sliceCode, num, chunk }) => {
const formData = new FormData();
formData.append('sliceCode', sliceCode);
formData.append('file', chunk);
return { formData, num };
})
try {
const ret = this.sendRequest(requestDataList, data, allData);
} catch (error) {
// 上传有被reject的
this.$message.error('亲 上传失败了,考虑重试下呦' + error);
return;
}
// 合并切片
const isUpload = data.some((item) => item.uploadStatus === 0);
if (!isUpload) {
// 执行合并
try {
await this.mergeRequest();
resolve();
} catch (error) {
reject();
}
}
});
},
sendRequest(forms, chunkData, allData) {
console.log('9.分片-并发上传处理')
var finished = 0;
const total = forms.length;
const that = this;
const retryArr = []; // 数组存储每个文件hash请求的重试次数,做累加 比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次
// const md5Hash = allData[0].fileHash
const filesArr = this.uploadFiles;
const fileUpIdx = this.fileUpIdx
const md5Hash = filesArr[fileUpIdx].md5
return new Promise((resolve, reject) => {
const handler = () => {
if (forms.length) {
// 出栈
const formInfo = forms.shift();
const formData = formInfo.formData;
const index = formInfo.num;
/*
*** 开始分片上传
*/
// console.log('当前分片上传', allData[index])
if (allData[index]) {
request(xxx)
.then(res => {
// 更改状态
allData[index].uploaded = true;
allData[index].uploadStatus = 1;
allData[index].status = 'success';
finished++;
if (finished === chunkData.length) {
// 执行合并
this.mergeRequest();
}
handler();
}).catch((e) => {
// 若状态为暂停或等待,则禁止重试
if ([Status.pause, Status.wait].includes(this.status)) return;
console.warn('出现错误', e);
console.log('当前分片上传报错', retryArr);
if (typeof retryArr[index] !== 'number') {
retryArr[index] = 0;
}
// 更新状态
allData[index].status = 'warning';
// 累加错误次数
retryArr[index]++;
// 重试3次
if (retryArr[index] >= this.chunkRetry) {
console.warn(' 重试失败--- > handler -> retryArr', retryArr, allData[index].hash);
return reject('重试失败', retryArr);
}
// console.log('handler -> retryArr[finished]', `${allData[index].hash}--进行第 ${retryArr[index]} '次重试'`);
// console.log(retryArr);
this.tempThreads++; // 释放当前占用的通道
// 将失败的重新加入队列
forms.push(formInfo);
handler();
})
}
}
if (finished >= total) {
resolve('done');
}
};
// 控制并发
for (let i = 0; i < this.tempThreads; i++) {
handler();
}
});
},
通知服务端合并切片,设置总的文件进度,并设置上传结果,回传给页面展示新增文件,进行下一步业务操作
在此大文件上传的整个思路就完成了。
参考文章:http://blog.ncmem.com/wordpress/2023/11/15/vue-%e5%a4%a7%e6%96%87%e4%bb%b6%e5%88%86%e7%89%87%e5%8f%8a%e6%96%ad%e7%82%b9%e4%b8%8a%e4%bc%a0/
欢迎入群一起讨论