久等了,最近有点忙,一直没时间更新这个大文件上传组件的文章!接上文的demo,以element-ui基础封装大文件上传的组件,包括断点续传,秒传,上传进度条,封装思想逻辑来源于el-upload 组件源码,看下面具体案例
js
<div class="big-file-box">
<div @click="handleClick">
<slot></slot>
<input class="el-upload__input" type="file" ref="input" name="请上传文件" @change='handleChange' :multiple='multiple' :accept='accept'></input>
</div>
<slot name="tip"></slot>
<div class="file-box">
<transition-group tag="ul">
<li v-for="item in uploadFiles"
:key="item.uid"
tabindex="0"
:class="{'file-li':true}"
@mouseover="handleMouseover(item.uid)"
@mouseleave="handleMouseleave(item.uid)"
>
<i class="el-icon-document"></i>
<span class="file-name" @click="handleClickFile(item)">{{item.name || item.url}}</span>
<i v-if="item.status === 'success'" :class="item.uid === listChecked ? 'el-icon-close deleteColor': 'el-icon-circle-check passColor'" @click="hanldeRemoveFile(item)"></i>
<el-progress
v-if="item.status === 'uploading'"
type="line"
:stroke-width="2"
:percentage="Number(item.percentage.toFixed(1))">
</el-progress>
</li>
</transition-group>
</div>
</div>
看上面代码,主要分为3部分:
- 1、文件按钮部分,一个默认插槽加一个input框,默认插槽用来自定义上传框的样式,input大家都懂就是原生的上传框,注意这个input 是需要隐藏的,这里偷懒直接用了element的类名
- 2、上传文件类型提示部分,一个文件类型提示的具名插槽 name="tip",用来自定义样式给出提示的文案
- 3、已上传的文件列表,用来点击预览,删除,以及上传进度条的展示,进度条部分会有status ,是文件上传的状态,当为uploading 时渲染
接下来是js 部分,分片部分的逻辑就不在这篇文章里面赘述了,不太清楚的小伙伴可以去看我上一篇文章 (### 前后端大文件上传,断点续传、分片上传、秒传的完整实例)
首先看组件的props
prop | 类型 | 描述 |
---|---|---|
beforeUpload | Function(file) | 文件上传前钩子上传文件之前的钩子,参数为上传的文件,若返回 false 则停止上传 |
onExceed | Function(file,fileList) | 文件超出个数限制时的钩子 |
limit | Number | 文件限制数量 |
uploadApi | String | 上传文件的接口 |
mergeApi | String | 文件上传成功后,合并文件接口 |
checkApi | String | 检测文件上传分片接口,返回已上传所有片段的索引 |
accept | String | 允许上传的文件类型 |
concurrentUpload | Boolean | 是否允许并发请求(后端服务带宽受限,可能需要同步依次上传分片,而不是瞬间发起几百个请求) |
fileList | Array | 上传的文件列表, 例如: [{name: 'food.jpg', url: 'xxx.cdn.com/xxx.jpg'}] |
onRemove | Function(file,fileList) | 文件列表移除文件时的钩子 |
onChange | Function(file,fileList) | 文件状态改变时的钩子,添加文件时调用 |
onPreview | Function(file) | 文件预览钩子 |
onSuccess | Function(file,url) | 文件合并成功钩子,返回成功文件,和后端存储到S3的url |
onError | Function(file) | 文件上传失败钩子 |
onProgress | Function(file,percentage) | 文件上传进度钩子 |
onReadingFile | Function(status) | 读取文件md5时的钩子函数,参数 start:开始读取,end:读取结束 |
chunckSize | Number | 文件切片大小 |
request | Function | 封装好的axios,用于请求的工具函数 |
apiHeader | Object | 需要的特殊请求头 |
SparkMD5 | Function | 读取文件md5的工具函数(spark-md5直接安装这个包) |
multiple | Boolean | 是否可多选文件(建议不多选,大文件有瓶颈) |
ok 开始上传,下面我们一步步来解析
第一步选取文件
js
handleClick() {
this.$refs.input.value = null;
this.$refs.input.click();
},
重置上一次的文件,接着主动触发input 的click事件
js
async handleChange(ev){
const files = ev.target.files;
if (!files) return;
this.uploadExceedFiles(files);
},
uploadExceedFiles(files) {
if (this.limit && this.fileList.length + files.length > this.limit) {
//大于限制数量,父组件处理自己的逻辑
this.onExceed && this.onExceed(files, this.fileList);
return;
}
this.upload(files)
},
async upload(files){
if (!this.beforeUpload) {
this.readFile(files[0]);
}
const before = this.beforeUpload(files[0]);
if(before) {
this.readFile(files[0])
}
},
触发input的change事件,开始判断是否已选取文件,接着判断文件个数,如果超出限制,会直接终止当前逻辑并将文件,以及文件列表抛给父组件的onExceed 函数,父组件自行给出提示,如果未超过限制,继续执行上传逻辑执行 upload 方法, upload 方法会调用beforeUpload 方法是否符合文件类型,如果返回ture, 继续执行,开始读取大文件的md5(这里是关键)
继续看readFile方法:
js
async readFile(files) {
this.sliceFile(files);
//注意这里,开始读取文件,会回调父组件的onreadingFile,告诉组件开始读取,此时父组件开始设置读取的loading 状态,读取完成之后再次调用会返回end表示读取结束,此时将loading状态改为false
this.onReadingFile('start');
const data = await this.getFileMD5(files);
this.onReadingFile('end');
//判断是否上传重复文件
const hasSameFile = this.uploadFiles.findIndex(item=> item.hash ===data);
if(hasSameFile === -1) {
this.fileSparkMD5 = {md5Value:data,fileKey:files.name};
const hasChuncks = await this.checkFile(data,files.name); //是否上传过
let isSuccess = true; //同步上传成功标记
//断点续传
if(hasChuncks) {
const hasEmptyChunk = this.isUploadChuncks.findIndex(item => item === 0);
//上传过,并且已经完整上传,直接提示上传成功(秒传)
if(hasEmptyChunk === -1) {
let file = {
status: 'success',
percentage: 100,
uid: Date.now() + this.tempIndex++,
hash:data,
name:'',
url:''
};
this.uploadFiles.push(file);
this.onSuccess(file);
return;
} else {
//处理续传逻辑,上传检测之后未上传的分片
this.onStart(files,data);
const emptyLength = this.isUploadChuncks.filter(item => item === 0);
for(let k = 0; k < this.isUploadChuncks.length; k++) {
if(this.isUploadChuncks[k] !== 1) {
let formData = new FormData();
formData.append('totalNumber',this.fileChuncks.length);
formData.append("chunkSize",this.chunckSize);
formData.append("partNumber",k);
formData.append('uuid',this.fileSparkMD5.md5Value);
formData.append('name',this.fileSparkMD5.fileKey);
formData.append('file',new File([this.fileChuncks[k].fileChuncks],this.fileSparkMD5.fileKey))
//如果并发请求,走这里
if(this.concurrentUpload) {
this.post(formData,k,emptyLength.length,data);
}else {
isSuccess = await this.post(formData,k,emptyLength.length,data);//这注意分片总数,因为进度条是根据分片个数来算的,所以分母应该是未上传的分片总数
if(!isSuccess) {
break;
}
}
}
}
//兼容并发与同步请求操作,受服务器带宽影响,做并发与同步请求处理
if(this.concurrentUpload) {
this.uploadSuccess();
}else {
if(isSuccess) {
//执行玩循环,如果isSuccess还是true,说明所有分片已上传,可执行合并文件接口
this.mergeFile(this.fileSparkMD5,this.fileChuncks.length);
}else {
const index = this.uploadFiles.findIndex(item => item.hash === this.fileSparkMD5.md5Value);
this.$set(this.uploadFiles,index,{...this.uploadFiles[index],...{status: 'error'}}); //后续拓展继续上传时可用
this.hanldeRemoveFile(this.uploadFiles[index]);
this.onError(this.uploadFiles[index]);
}
}
}
}else {
this.onStart(files,data);
// this.sliceFile(files);
//同步上传
for(let i = 0; i < this.fileChuncks.length; i++) {
let formData = new FormData();
formData.append('totalNumber',this.fileChuncks.length);
formData.append("chunkSize",this.chunckSize);
formData.append("partNumber",i);
formData.append('uuid',this.fileSparkMD5.md5Value);
formData.append('name',this.fileSparkMD5.fileKey);
formData.append('file',new File([this.fileChuncks[i].fileChuncks],this.fileSparkMD5.fileKey));
if(this.concurrentUpload) {
this.post(formData,k,emptyLength.length,data);
}else {
isSuccess = await this.post(formData,i,this.fileChuncks.length,data);//这注意分片总数,因为进度条是根据分片个数来算的,所以分母应该是未上传的分片总数
if(!isSuccess) {
break;
}
}
}
//兼容并发与同步请求操作,受服务器带宽影响,做并发与同步请求处理
if(this.concurrentUpload) {
this.uploadSuccess();
}else {
//循环所有的片段后,isSuccess依然为ture
if(isSuccess) {
this.mergeFile(this.fileSparkMD5,this.fileChuncks.length);
}else {
const index = this.uploadFiles.findIndex(item => item.hash === this.fileSparkMD5.md5Value);
this.$set(this.uploadFiles,index,{...this.uploadFiles[index],...{status: 'error'}}); //后续拓展继续上传时可用
this.hanldeRemoveFile(this.uploadFiles[index]);
this.onError(this.uploadFiles[index]);
}
}
}
}else {
this.$message.error('Please do not upload the same file repeatedly');
}
},
onStart(rawFile,hash) {
rawFile.uid = Date.now() + this.tempIndex++;
let file = {
status: 'ready',
name: rawFile.name,
size: rawFile.size,
percentage: 0,
uid: rawFile.uid,
raw: rawFile,
hash
};
this.uploadFiles.push(file);
this.onChange(file, this.uploadFiles);
},
sliceFile (file) {
//文件分片之后的集合
const chuncks = [];
let start = 0 ;
let end;
while(start < file.size) {
end = Math.min(start + this.chunckSize,file.size);
chuncks.push({fileChuncks:file.slice(start,end),fileName:file.name});
start = end;
}
this.fileChuncks = [...chuncks];
},
getFileMD5 (file){
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) =>{
const fileMd5 = this.SparkMD5.ArrayBuffer.hash(e.target.result);
resolve(fileMd5)
}
fileReader.onerror = (e) =>{
reject('file read failure',e)
this.onError(file,'file read failure')
}
fileReader.readAsArrayBuffer(file);
})
},
async checkFile(md5Hash,fileName) {
const {code,data} = await this.request({url:`${this.checkApi}?uuid=${md5Hash}&fileName=${fileName}`, method: 'get'});
if(code === 200) {
if(data.length) {
const newArr = new Array(Number(this.fileChuncks.length)).fill(0); // [1,1,0,1,1]
const chunckNumberArr = data.map(item => item);
chunckNumberArr.forEach((item,index) => {
newArr[item] = 1
});
this.isUploadChuncks = [...newArr];
return true;
}else {
return false;
}
}
}
//并发请求,推入promise 数组中,通过allSettled 方法来判断,所有任务是否都为resove状态,如果有是,就进行合并文件
uploadSuccess() {
Promise.allSettled(this.promiseArr).then(result=>{
const hasReject = result.findIndex(item => item.status === 'rejected');
if(hasReject === -1) {
this.mergeFile(this.fileSparkMD5,this.fileChuncks.length);
}else {
const index = this.uploadFiles.findIndex(item => item.hash === result[hasReject].reason);
this.$set(this.uploadFiles,index,{...this.uploadFiles[index],...{status: 'error'}}); //后续拓展继续上传时可用
this.hanldeRemoveFile(this.uploadFiles[index]);
this.onError(this.uploadFiles[index]);
}
}).catch(e=>{
this.onError(e);
}).finally(e=>{
this.promiseArr = [];
this.uploadQuantity = 0; //重置上传进度
})
},
首先将文件开始切片,放入切片list中,接着开始读取文件,这里可以自行在父组件中调用 onReadingFile 方法设置loading状态,提升用户体验度。 接着会直接调用服务单接口,检查是否已经上传过,并将已上传的分片序号写入到一个isUploadChuncks list中,然后循环上传未上片段,这里会执行 onStart 方法,给每个文件一个初始对象,设置文件的初始状态,以及文件内容,插入到已上传的文件列表 uploadFiles 中,为后根据文件状态展示进度条,以及上传失败时删除对应文件列表做准备
划重点:调用接口,处理上传逻辑,这里主要分两种。前面提到过,服务端会有上传带宽的限制,如果一次性发送很多的文件请求,服务端是接受不了的。所以分2种,并发上传,和同步上传。post 方法会返回一个promise,并生成了一个以每个promise请求,组成的promise 集合
js
//file:当前文件,nowChunck:当前分片索引,totalChunck:当前需要上传文件分片的总数,hash:文件的唯一hash值
async post(file,nowChunck,totalChunck,hash) {
let _this = this;
const index = _this.uploadFiles.findIndex(item => item.hash === hash);
_this.$set(_this.uploadFiles,index,{..._this.uploadFiles[index],...{status: 'uploading'}});
const curPormise = new Promise((resolve,reject)=>{
let xhr = new XMLHttpRequest();
// 当上传完成时调用
xhr.onload = function() {
if (xhr.status === 200) {
const index = _this.uploadFiles.findIndex(item => item.hash === hash);
//大文件上传进度
_this.uploadQuantity ++;
_this.$set(_this.uploadFiles,index,{..._this.uploadFiles[index],...{status: 'uploading',percentage:_this.uploadQuantity / totalChunck * 100}});
_this.onProgress(file,_this.uploadQuantity / totalChunck * 100);
resolve(true);
}else {
_this.errorChuncks.push({file:file,nowChunck,totalChunck,hash});
reject(false);
_this.uploadQuantity = 0;
const index = _this.uploadFiles.findIndex(item => item.hash === hash);
_this.$set(_this.uploadFiles,index,{..._this.uploadFiles[index],...{status: 'error'}}); //后续拓展继续上传时可用
_this.hanldeRemoveFile(_this.uploadFiles[index]);
_this.onError(_this.uploadFiles[index]);
}
}
xhr.onerror = function(e) {
_this.errorChuncks.push({file:file,nowChunck,totalChunck,hash});
reject(false)
_this.uploadQuantity = 0;
const index = _this.uploadFiles.findIndex(item => item.hash === hash);
_this.$set(_this.uploadFiles,index,{..._this.uploadFiles[index],...{status: 'error'}}); //后续拓展继续上传时可用
_this.hanldeRemoveFile(_this.uploadFiles[index]);
_this.onError(_this.uploadFiles[index]);
}
// 发送请求
xhr.open('POST', _this.uploadApi, true);
if (_this.apiHeader?.withCredentials && 'withCredentials' in xhr) {
xhr.withCredentials = true;
}
const headers = _this.apiHeader || {};
for (let item in headers) {
if (headers.hasOwnProperty(item) && headers[item] !== null) {
xhr.setRequestHeader(item, headers[item]);
}
}
xhr.send(file);
});
_this.promiseArr.push(curPormise);
return curPormise;
},
通过父组件传递的concurrentUpload参数,决定是并发还是同步
uploadSuccess 为并发时逻辑,将所有的请求放入promise数组中,如果都成功进行合并文件
这里为同步,因为上面pormise如果成功resove(true),所以成功才会继续走递归发送请求,否者立马中断上传
最后就是合并文件,合并之后根据文件的MD5匹配,然后修改对应文件的status,通过状态隐藏进度条,这里成功之后会走onSuccess方法,这时可以在父组件放开上传按钮禁用的状态(看前面的逻辑,会在选择文件之后,禁用上传按钮)
js
async mergeFile (fileInfo,chunckTotal){
const { md5Value,fileKey } = fileInfo;
const params = {
totalNumber:chunckTotal,
md5:md5Value,
name:fileKey
}
const index = this.uploadFiles.findIndex(item => item.hash === md5Value);
try {
const {code,data} = await this.request({url:`${this.mergeApi}?uuid=${md5Value}`, method: 'get'});
if(code === 200) {
this.$set(this.uploadFiles,index,{...this.uploadFiles[index],...{status: 'success',url:data}}); //记得绑定url
this.onSuccess(this.uploadFiles[index],data);
}
}catch(e) {
this.$set(this.uploadFiles,index,{...this.uploadFiles[index],...{status: 'error',url:''}}); //记得绑定url
this.hanldeRemoveFile(this.uploadFiles[index]);
this.onError(this.uploadFiles[index]);
}
this.uploadQuantity = 0;
this.errorChuncks = [];
},
最后看下在父组件中使用的案例
js
<BigUpload :SparkMD5="SparkMD5" :request="request"
:uploadApi="uploadApi"
:mergeApi="mergeApi"
:checkApi="checkApi"
:fileList="videoFileList"
:on-change="onChange"
:on-remove="handleRemove"
:on-progress="onProgress"
:before-upload="beforeUpload"
:on-exceed="onExceed"
:on-success="onSuccess"
:on-error="onError"
:on-preview="onPreview"
:on-reading-file="onReadingFile"
:limit="10"
:apiHeader="apiHeader"
:accept='`mp4,avi,mov,wmv,3gp`'
>
<el-button type="primary" :disabled="disabledUpload" :loading="loadingUpload">{{loadingUpload ? $t('workGuide.FileLoading') : $t('workGuide.ClickToUpload') }}</el-button>
<div slot="tip" class="el-upload__tip">只能上传mp4,avi,mov,wmv,3gp文件,且不超过2G</div>
</BigUpload>
onChange(file,fileList) {
this.disabledUpload = true;
},
//读取文件回调,大文件读取耗时较长,给一个loading状态过度
onReadingFile(value){
value === 'start' ? this.loadingUpload = true : this.loadingUpload = false;
},
beforeUpload(file) {
const type = file.type.split('/')[0];
const isVideo = type === 'video';
const isLt2G = file.size / 1024 / 1024 < 2048;
if (!isLt2G) {
this.$message.error(this.$t('KOLProductBoard.MaximumSizeImages',{m:'2048'}))
}
else if (!isVideo) {
this.$message.error(this.$t('workGuide.uploadFormatTip',{m:'mp4,avi,mov,wmv,3gp'}))
}
return isVideo && isLt2G;
},
//超过最大上传数量回调
onExceed(file,fileList) {
this.$message.warning(this.$t('KOLProductBoard.MaximumLimitImages', { m: '10'}));
},
//上传进度回调
onProgress(file,percentage) {
},
//预览回调
onPreview({url}) {
this.bigImageUrl = url;
this.showBigImage = true;
},
onSuccess(file,url) {
this.videoFileList.push(file);
this.disabledUpload = false;
this.$message.success(this.$t('KOL需求管理.UploadSuccessful'));
},
onError(file,reason) {
//reason 是在浏览器读取文件失败时特有的参数
//禁用上传
this.disabledUpload = false;
if(reason) {
this.loadingUpload = false;
this.$message.error(reason);
}else {
this.$message.success(this.$t('workGuide.UploadFailed'));
}
},
handleRemove(file,fileList) {
this.videoFileList = [...fileList];
},
这里有2个状态"disabledUpload"(当文件选择后禁用上传按钮,知道上传成功放开限制)"loadingUpload"(在读取文件md5的过程中,开启loading状态)都是通过不同的钩子函数来控制
附源码git地址
代码没有精简,时间仓促,目前是使用在自己的项目中,有不完善和错误的地方,欢迎大家指出