基于element-ui 自定义封装大文件上传组件

久等了,最近有点忙,一直没时间更新这个大文件上传组件的文章!接上文的demo,以element-ui基础封装大文件上传的组件,包括断点续传,秒传,上传进度条,封装思想逻辑来源于el-upload 组件源码,看下面具体案例

   <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 开始上传,下面我们一步步来解析

第一步选取文件

handleClick() {
    this.$refs.input.value = null;
    this.$refs.input.click();
},

重置上一次的文件,接着主动触发input 的click事件

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方法:

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 集合

//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参数,决定是并发还是同步
基于element-ui 自定义封装大文件上传组件

uploadSuccess 为并发时逻辑,将所有的请求放入promise数组中,如果都成功进行合并文件
基于element-ui 自定义封装大文件上传组件

这里为同步,因为上面pormise如果成功resove(true),所以成功才会继续走递归发送请求,否者立马中断上传
基于element-ui 自定义封装大文件上传组件

最后就是合并文件,合并之后根据文件的MD5匹配,然后修改对应文件的status,通过状态隐藏进度条,这里成功之后会走onSuccess方法,这时可以在父组件放开上传按钮禁用的状态(看前面的逻辑,会在选择文件之后,禁用上传按钮)

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 = [];
},

最后看下在父组件中使用的案例

<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地址

代码没有精简,时间仓促,目前是使用在自己的项目中,有不完善和错误的地方,欢迎大家指出

原文链接:https://juejin.cn/post/7329707963131035687 作者:张辽

(0)
上一篇 2024年1月31日 上午10:00
下一篇 2024年1月31日 上午10:10

相关推荐

发表回复

登录后才能评论