怎么用Vue+NodeJS实现大文件上传
怎么用Vue+NodeJS实现大文件上传
本文小编为大家详细介绍“怎么用Vue+NodeJS实现大文件上传”,内容详细,步骤清晰,细节处理妥当,希望这篇“怎么用Vue+NodeJS实现大文件上传”文章能帮助大家解决疑惑,下面跟着小编的思路慢慢深入,一起来学习新知识吧。
常见的文件上传方式可能就是new一个FormData,把文件append进去以后post给后端就可以了。但如果采用这种方式来上传大文件就很容易产生上传超时的问题,而且一旦失败还得从新开始,在漫长的等待过程中用户还不能刷新浏览器,不然前功尽弃。因此这类问题一般都是通过切片上传。
整体思路
将文件切成多个小文件
hash计算,需要计算一个文件的唯一标识,这样下次再传,就能筛选出剩余的切片进行上传。
所有切片上传后,通知服务端进行切片合成
上传成功通知前端文件路径
整个过程如果出现失败,下次再传时,由于之前计算过文件hash,可以筛选出未传数的切片续传(断点续传); 如果整个文件已经上传过,就不需要传输(秒传)
项目演示
这里用vue和node分别搭建前端和后端
前端界面
fileUpload.vue
<template><divclass="wrap"><div><el-uploadref="file":http-request="handleFileUpload"action="#"class="avatar-uploader":show-file-list='false'><el-buttontype="primary">上传文件</el-button></el-upload><div><div>计算hash的进度:</div><el-progress:stroke-width="20":text-inside="true":percentage="hashProgress"></el-progress></div><div><div>上传进度:</div><el-progress:stroke-width="20":text-inside="true":percentage="uploaedProgress"></el-progress></div></div></div></template>
文件切片
利用 File.prototype.slice 的方法可以对文件进行切片 fileUpload.vue
constCHUNK_SIZE=1024*1024//每个切片为1MimportsparkMD5from'spark-md5'exportdefault{name:'file-upload',data(){return{file:null,//上传的文件chunks:[],//切片hashProgress:0,//hash值计算进度hash:''}},methods:{asynchandleFileUpload(e){if(!file){return}this.file=filethis.upload()},//文件上传asyncupload(){//切片constchunks=this.createFileChunk(this.file)//...//hash计算consthash=awaitthis.calculateHash2(chunks)}},//文件切片createFileChunk(size=CHUNK_SIZE){constchunks=[];letcur=0;constmaxLen=Math.ceil(this.file.size/CHUNK_SIZE)while(cur<maxLen){conststart=cur*CHUNK_SIZE;constend=((start+CHUNK_SIZE)>=this.file.size)?this.file.size:start+CHUNK_SIZE;chunks.push({index:cur,file:this.file.slice(start,end)})cur++}returnchunks},}
hash计算
利用md5可以计算出文件唯一的hash值
这里可以使用 spark-md5
这个库可以增量计算文件的hash值
calculateHash2(chunks){constspark=newsparkMD5.ArrayBuffer()letcount=0constlen=chunks.lengthlethashconstself=thisconststartTime=newDate().getTime()returnnewPromise((resolve)=>{constloadNext=index=>{constreader=newFileReader()//逐片读取文件切片reader.readAsArrayBuffer(chunks[index].file)reader.onload=function(e){constendTime=newDate().getTime()chunks[count]={...chunks[count],time:endTime-startTime}count++//读取成功后利用spark做增量计算spark.append(e.target.result)if(count==len){self.hashProgress=100//返回整个文件的hashhash=spark.end()resolve(hash)}else{//更新hash计算进度self.hashProgress+=100/lenloadNext(index+1)}}}loadNext(0)})},
可以看到整个过程还是比较费时间的,有可能会导致UI阻塞(卡),因此可以通过webwork等手段优化这个过程,这点我们放在最后讨论
查询切片状态
在知道了文件的hash值以后,在上传切片前我们还要去后端查询下文件的上传状态,如果已经上传过,那就没有必要再上传,如果只上传了一部分,那就上传还没有上过过的切片(断点续传)
前端 fileUpload.vue
//...methods:{//...asyncupload(){//...切片,计算hashthis.hash=hash//查询是否上传将hash和后缀作为参数传入this.$http.post('/checkfile',{hash,ext:this.file.name.split('.').pop()}).then(res=>{//接口会返回两个值uploaded:Boolean表示整个文件是否上传过和uploadedList哪些切片已经上传const{uploaded,uploadedList}=res.data//如果已经上传过,直接提示用户(秒传)if(uploaded){returnthis.$message.success('秒传成功')}//这里我们约定上传的每个切片名字都是hash+‘-'+indexthis.chunks=chunks.map((chunk,index)=>{constname=hash+'-'+indexconstisChunkUploaded=(uploadedList.includes(name))?true:false//当前切片是否有上传return{hash,name,index,chunk:chunk.file,progress:isChunkUploaded?100:0//当前切片上传进度,如果有上传即为100否则为0,这是用来之后计算总体上传进度}})//上传切片this.uploadChunks(uploadedList)})}}
文件切片 this.chunks
服务端 server/index.js
constKoa=require('koa')constRouter=require('koa-router')constkoaBody=require('koa-body');constpath=require('path')constfse=require('fs-extra')constapp=newKoa()constrouter=newRouter()//文件存放在public下constUPLOAD_DIR=path.resolve(__dirname,'public')app.use(koaBody({multipart:true,//支持文件上传}));router.post('/checkfile',async(ctx)=>{constbody=ctx.request.body;const{ext,hash}=body//合成后的文件路径文件名hash.extconstfilePath=path.resolve(UPLOAD_DIR,`${hash}.${ext}`)letuploaded=falseletuploadedList=[]//判断文件是否已上传if(fse.existsSync(filePath)){uploaded=true}else{//所有已经上传过的切片被存放在一个文件夹,名字就是该文件的hash值uploadedList=awaitgetUploadedList(path.resolve(UPLOAD_DIR,hash))}ctx.body={code:0,data:{uploaded,uploadedList}}})asyncfunctiongetUploadedList(dirPath){//将文件夹中的所有非隐藏文件读取并返回returnfse.existsSync(dirPath)?(awaitfse.readdir(dirPath)).filter(name=>name[0]!=='.'):[]}
切片上传(断点续传)
再得知切片上传状态后,就能筛选出需要上传的切片来上传。 前端 fileUpload.vue
uploadChunks(uploadedList){//每一个要上传的切片变成一个请求constrequests=this.chunks.filter(chunk=>!uploadedList.includes(chunk.name)).map((chunk,index)=>{constform=newFormData()//所有上传的切片会被存放在一个文件夹,文件夹名字就是该文件的hash值因此需要hash和nameform.append('chunk',chunk.chunk)form.append('hash',chunk.hash)form.append('name',chunk.name)//因为切片不一定是连续的,所以index需要取chunk对象中的indexreturn{form,index:chunk.index,error:0}})//所有切片一起并发上传.map(({form,index})=>{returnthis.$http.post('/uploadfile',form,{onUploadProgress:progress=>{this.chunks[index].progress=Number(((progress.loaded/progress.total)*100).toFixed(2))//当前切片上传的进度}})})Promise.all(requests).then((res)=>{//所有请求都成功后发送请求给服务端合并文件this.mergeFile()})},
服务端
router.post('/uploadfile',async(ctx)=>{constbody=ctx.request.bodyconstfile=ctx.request.files.chunkconst{hash,name}=body//切片存放的文件夹所在路径constchunkPath=path.resolve(UPLOAD_DIR,hash)if(!fse.existsSync(chunkPath)){awaitfse.mkdir(chunkPath)}//将文件从临时路径里移动到文件夹下awaitfse.move(file.filepath,`${chunkPath}/${name}`)ctx.body={code:0,message:`切片上传成功`}})
上传后切片保存的位置
文件总体上传进度
总体上传进度取决于每个切片上传的进度和文件总体大小,可以通过计算属性来实现
fileUpload.vue
uploaedProgress(){if(!this.file||!this.chunks.length){return0}//累加每个切片已上传的部分constloaded=this.chunks.map(chunk=>{constsize=chunk.chunk.sizeconstchunk_loaded=chunk.progress/100*sizereturnchunk_loaded}).reduce((acc,cur)=>acc+cur,0)returnparseInt(((loaded*100)/this.file.size).toFixed(2))},
合并文件
前端 fileUpload.vue
//要传给服务端文件后缀,切片的大小和hash值mergeFile(){this.$http.post('/mergeFile',{ext:this.file.name.split('.').pop(),size:CHUNK_SIZE,hash:this.hash}).then(res=>{if(res&&res.data){console.log(res.data)}})},
服务端
router.post('/mergeFile',async(ctx)=>{constbody=ctx.request.bodyconst{ext,size,hash}=body//文件最终路径constfilePath=path.resolve(UPLOAD_DIR,`${hash}.${ext}`)awaitmergeFile(filePath,size,hash)ctx.body={code:0,data:{url:`/public/${hash}.${ext}`}}})asyncfunctionmergeFile(filePath,size,hash){//保存切片的文件夹地址constchunkDir=path.resolve(UPLOAD_DIR,hash)//读取切片letchunks=awaitfse.readdir(chunkDir)//切片要按顺序合并,因此需要做个排序chunks=chunks.sort((a,b)=>a.split('-')[1]-b.split('-')[1])//切片的绝对路径chunks=chunks.map(cpath=>path.resolve(chunkDir,cpath))awaitmergeChunks(chunks,filePath,size)}//边读边写至文件最终路径functionmergeChunks(files,dest,CHUNK_SIZE){constpipeStream=(filePath,writeStream)=>{returnnewPromise((resolve,reject)=>{constreadStream=fse.createReadStream(filePath)readStream.on('end',()=>{//每一个切片读取完毕后就将其删除fse.unlinkSync(filePath)resolve()})readStream.pipe(writeStream)})}constpipes=files.map((file,index)=>{returnpipeStream(file,fse.createWriteStream(dest,{start:index*CHUNK_SIZE,end:(index+1)*CHUNK_SIZE}))});returnPromise.all(pipes)}
大文件切片上传的功能已经实现,让我们来看下效果(这里顺便展示一下单个切片的上传进度)
可以看到由于大量的切片请求并发上传,虽然浏览器本身对同时并发的请求数有所限制(可以看到许多请求是pending状态),但还是造成了卡顿,因此这个流程还是需要做一个优化
优化
请求并发数控制
fileUpload.vue
逐片上传
这也是最直接的一种做法,可以看作是并发请求的另一个极端,上传成功一个再上传第二个,这里还要处理一下错误重试,如果连续失败3次,整个上传过程终止
uploadChunks(uploadedList){console.log(this.chunks)constrequests=this.chunks.filter(chunk=>!uploadedList.includes(chunk.name)).map((chunk,index)=>{constform=newFormData()form.append('chunk',chunk.chunk)form.append('hash',chunk.hash)form.append('name',chunk.name)return{form,index:chunk.index,error:0}})//.map(({form,index})=>{//returnthis.$http.post('/uploadfile',form,{//onUploadProgress:progress=>{//this.chunks[index].progress=Number(((progress.loaded/progress.total)*100).toFixed(2))//}//})//})////console.log(requests)//Promise.all(requests).then((res)=>{//console.log(res)//this.mergeFile()//})constsendRequest=()=>{returnnewPromise((resolve,reject)=>{constupLoadReq=(i)=>{constreq=requests[i]const{form,index}=reqthis.$http.post('/uploadfile',form,{onUploadProgress:progress=>{this.chunks[index].progress=Number(((progress.loaded/progress.total)*100).toFixed(2))}}).then(res=>{//最后一片上传成功,整个过程完成if(i==requests.length-1){resolve()return}upLoadReq(i+1)}).catch(err=>{this.chunks[index].progress=-1if(req.error<3){req.error++//错误累加后重试upLoadReq(i)}else{reject()}})}upLoadReq(0)})}//整个过程成功后再合并文件sendRequest().then(()=>{this.mergeFile()})},
可以看到每次只有一个上传请求
最终生成的文件
多个请求并发
逐个请求的确是可以解决卡顿的问题,但是效率有点低,我们还可以在这个基础上做到有限个数的并发
一般这种问题的思路就是要形成一个任务队列,开始的时候先从requests中取出指定并发数的请求对象(假设是3个)塞满队列并各自开始请求任务,每一个任务结束后将该任务关闭退出队列然后再从request说中取出一个元素加入队列并执行,直到requests清空,这里如果某一片请求失败的话那还要再塞入request队首,这样下次执行时还能从这个请求开始达到了重试的目的
asyncuploadChunks(uploadedList){console.log(this.chunks)constrequests=this.chunks.filter(chunk=>!uploadedList.includes(chunk.name)).map((chunk,index)=>{constform=newFormData()form.append('chunk',chunk.chunk)form.append('hash',chunk.hash)form.append('name',chunk.name)return{form,index:chunk.index,error:0}})constsendRequest=(limit=1,task=[])=>{letcount=0//用于记录请求成功次数当其等于len-1时所有切片都已上传成功letisStop=false//标记错误情况,如果某一片错误数大于3整个任务标记失败并且其他并发的请求凭次标记也不在递归执行constlen=requests.lengthreturnnewPromise((resolve,reject)=>{constupLoadReq=()=>{if(isStop){return}constreq=requests.shift()if(!req){return}const{form,index}=reqthis.$http.post('/uploadfile',form,{onUploadProgress:progress=>{this.chunks[index].progress=Number(((progress.loaded/progress.total)*100).toFixed(2))}}).then(res=>{//最后一片if(count==len-1){resolve()}else{count++upLoadReq()}}).catch(err=>{this.chunks[index].progress=-1if(req.error<3){req.error++requests.unshift(req)upLoadReq()}else{isStop=truereject()}})}while(limit>0){//模拟形成了一个队列,每次结束再递归执行下一个任务upLoadReq()limit--}})}sendRequest(3).then(res=>{console.log(res)this.mergeFile()})},
hash值计算优化
除了请求并发需要控制意外,hash值的计算也需要关注,虽然我们采用了增量计算的方法,但是可以看出依旧比较费时,也有可能会阻塞UI
webWork
这相当于多开了一个线程,让hash计算在新的线程中计算,然后将结果通知会主线程
calculateHashWork(chunks){returnnewPromise((resolve)=>{//这个js得独立于项目之外this.worker=newworker('/hash.js')//切片传入现成this.worker.postMessage({chunks})this.worker.onmessage=e=>{//线程中返回的进度和hash值const{progress,hash}=e.datathis.hashProgress=Number(progress.toFixed(2))if(hash){resolve(hash)}}})},
hash.js
//独立于项目之外,得单独//引入spark-md5self.importScripts('spark-md5.min.js')self.onmessage=e=>{//接受主线程传递的数据,开始计算const{chunks}=e.dataconstspark=newself.SparkMD5.ArrayBuffer()letprogress=0letcount=0constloadNext=index=>{constreader=newFileReader()reader.readAsArrayBuffer(chunks[index].file)reader.onload=e=>{count++spark.append(e.target.result)if(count==chunks.length){//向主线程返回进度和hashself.postMessage({progress:100,hash:spark.end()})}else{progress+=100/chunks.length//向主线程返回进度self.postMessage({progress})loadNext(count)}}}loadNext(0)}
时间切片
还有一种做法就是借鉴react fiber架构,可以通过时间切片的方式在浏览器空闲的时候计算hash值,这样浏览器的渲染是联系的,就不会出现明显卡顿
calculateHashIdle(chunks){returnnewPromise(resolve=>{constspark=newsparkMD5.ArrayBuffer()letcount=0constappendToSpark=asyncfile=>{returnnewPromise(resolve=>{constreader=newFileReader()reader.readAsArrayBuffer(file)reader.onload=e=>{spark.append(e.target.result)resolve()}})}constworkLoop=asyncdeadline=>{//当切片没有读完并且浏览器有剩余时间while(count<chunks.length&&deadline.timeRemaining()>1){awaitappendToSpark(chunks[count].file)count++if(count<chunks.length){this.hashProgress=Number(((100*count)/chunks.length).toFixed(2))}else{this.hashProgress=100consthash=spark.end()resolve(hash)}}window.requestIdleCallback(workLoop)}window.requestIdleCallback(workLoop)})}
读到这里,这篇“怎么用Vue+NodeJS实现大文件上传”文章已经介绍完毕,想要掌握这篇文章的知识点还需要大家自己动手实践使用过才能领会,如果想了解更多相关内容的文章,欢迎关注恰卡编程网行业资讯频道。