<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
常見的檔案上傳方式可能就是new一個FormData,把檔案append進去以後post給後端就可以了。但如果採用這種方式來上傳大檔案就很容易產生上傳超時的問題,而且一旦失敗還得從新開始,在漫長的等待過程中使用者還不能重新整理瀏覽器,不然前功盡棄。因此這類問題一般都是通過切片上傳。
這裡用vue和node分別搭建前端和後端
fileUpload.vue
<template> <div class="wrap"> <div > <el-upload ref="file" :http-request="handleFileUpload" action="#" class="avatar-uploader" :show-file-list='false' > <el-button type="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
const CHUNK_SIZE=1024*1024//每個切片為1M import sparkMD5 from 'spark-md5' export default { name:'file-upload', data(){ return { file:null,//上傳的檔案 chunks:[],//切片 hashProgress:0,//hash值計算進度 hash:'' } }, methods:{ async handleFileUpload(e){ if(!file){ return } this.file=file this.upload() }, //檔案上傳 async upload(){ //切片 const chunks=this.createFileChunk(this.file) //... //hash計算 const hash=await this.calculateHash1(chunks) } }, //檔案切片 createFileChunk(size=CHUNK_SIZE){ const chunks=[]; let cur=0; const maxLen=Math.ceil(this.file.size/CHUNK_SIZE) while(cur<maxLen){ const start=cur*CHUNK_SIZE; const end = ((start + CHUNK_SIZE) >= this.file.size) ? this.file.size : start + CHUNK_SIZE; chunks.push({index:cur,file:this.file.slice(start,end)}) cur++ } return chunks }, }
利用md5可以計算出檔案唯一的hash值
這裡可以使用 spark-md5
這個庫可以增量計算檔案的hash值
calculateHash1(chunks){ const spark=new sparkMD5.ArrayBuffer() let count =0 const len=chunks.length let hash const self=this const startTime = new Date().getTime() return new Promise((resolve)=>{ const loadNext=index=>{ const reader=new FileReader() //逐片讀取檔案切片 reader.readAsArrayBuffer(chunks[index].file) reader.onload=function(e){ const endTime=new Date().getTime() chunks[count]={...chunks[count],time:endTime-startTime} count++ //讀取成功後利用spark做增量計算 spark.append(e.target.result) if(count==len){ self.hashProgress=100 //返回整個檔案的hash hash=spark.end() resolve(hash) }else{ //更新hash計算進度 self.hashProgress+=100/len loadNext(index+1) } } } loadNext(0) }) },
可以看到整個過程還是比較費時間的,有可能會導致UI阻塞(卡),因此可以通過webwork等手段優化這個過程,這點我們放在最後討論
在知道了檔案的hash值以後,在上傳切片前我們還要去後端查詢下檔案的上傳狀態,如果已經上傳過,那就沒有必要再上傳,如果只上傳了一部分,那就上傳還沒有上過過的切片(斷點續傳)
前端 fileUpload.vue
//... methods:{ //... async upload(){ //...切片,計算hash this.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){ return this.$message.success('秒傳成功') } //這裡我們約定上傳的每個切片名字都是 hash+‘-'+index this.chunks=chunks.map((chunk,index)=>{ const name=hash+'-'+index const isChunkUploaded=(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
const Koa=require('koa') const Router=require('koa-router') const koaBody = require('koa-body'); const path=require('path') const fse=require('fs-extra') const app=new Koa() const router=new Router() //檔案存放在public下 const UPLOAD_DIR=path.resolve(__dirname,'public') app.use(koaBody({ multipart:true, // 支援檔案上傳 })); router.post('/checkfile',async (ctx)=>{ const body=ctx.request.body; const {ext,hash}=body //合成後的檔案路徑 檔名 hash.ext const filePath=path.resolve(UPLOAD_DIR,`${hash}.${ext}`) let uploaded=false let uploadedList=[] //判斷檔案是否已上傳 if(fse.existsSync(filePath)){ uploaded=true }else{ //所有已經上傳過的切片被存放在 一個資料夾,名字就是該檔案的hash值 uploadedList=await getUploadedList(path.resolve(UPLOAD_DIR,hash)) } ctx.body={ code:0, data:{ uploaded, uploadedList } } }) async function getUploadedList(dirPath){ //將資料夾中的所有非隱藏檔案讀取並返回 return fse.existsSync(dirPath)?(await fse.readdir(dirPath)).filter(name=>name[0]!=='.'):[] }
再得知切片上傳狀態後,就能篩選出需要上傳的切片來上傳。 前端 fileUpload.vue
uploadChunks(uploadedList){ //每一個要上傳的切片變成一個請求 const requests=this.chunks.filter(chunk=>!uploadedList.includes(chunk.name)) .map((chunk,index)=>{ const form=new FormData() //所有上傳的切片會被存放在 一個資料夾,資料夾名字就是該檔案的hash值 因此需要hash和name form.append('chunk',chunk.chunk) form.append('hash',chunk.hash) form.append('name',chunk.name) //因為切片不一定是連續的,所以index需要取chunk物件中的index return {form,index:chunk.index,error:0} })//所有切片一起並行上傳 .map(({form,index})=>{ return this.$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)=>{ const body=ctx.request.body const file=ctx.request.files.chunk const {hash,name}=body //切片存放的資料夾所在路徑 const chunkPath=path.resolve(UPLOAD_DIR,hash) if(!fse.existsSync(chunkPath)){ await fse.mkdir(chunkPath) } //將檔案從臨時路徑裡移動到資料夾下 await fse.move(file.filepath,`${chunkPath}/${name}`) ctx.body={ code:0, message:`切片上傳成功` } })
上傳後切片儲存的位置
總體上傳進度取決於每個切片上傳的進度和檔案總體大小,可以通過計算屬性來實現
fileUpload.vue
uploaedProgress(){ if(!this.file || !this.chunks.length){ return 0 } //累加每個切片已上傳的部分 const loaded =this.chunks.map(chunk=>{ const size=chunk.chunk.size const chunk_loaded=chunk.progress/100*size return chunk_loaded }).reduce((acc,cur)=>acc+cur,0) return parseInt(((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)=>{ const body=ctx.request.body const {ext,size,hash}=body //檔案最終路徑 const filePath=path.resolve(UPLOAD_DIR,`${hash}.${ext}`) await mergeFile(filePath,size,hash) ctx.body={ code:0, data:{ url:`/public/${hash}.${ext}` } } }) async function mergeFile(filePath,size,hash){ //儲存切片的資料夾地址 const chunkDir=path.resolve(UPLOAD_DIR,hash) //讀取切片 let chunks=await fse.readdir(chunkDir) //切片要按順序合併,因此需要做個排序 chunks=chunks.sort((a,b)=>a.split('-')[1]-b.split('-')[1]) //切片的絕對路徑 chunks=chunks.map(cpath=>path.resolve(chunkDir,cpath)) await mergeChunks(chunks,filePath,size) } //邊讀邊寫至檔案最終路徑 function mergeChunks(files,dest,CHUNK_SIZE){ const pipeStream=(filePath,writeStream)=>{ return new Promise((resolve,reject)=>{ const readStream=fse.createReadStream(filePath) readStream.on('end',()=>{ //每一個切片讀取完畢後就將其刪除 fse.unlinkSync(filePath) resolve() }) readStream.pipe(writeStream) }) } const pipes=files.map((file,index) => { return pipeStream(file,fse.createWriteStream(dest,{ start:index*CHUNK_SIZE, end:(index+1)*CHUNK_SIZE })) }); return Promise.all(pipes) }
大檔案切片上傳的功能已經實現,讓我們來看下效果(這裡順便展示一下單個切片的上傳進度)
可以看到由於大量的切片請求並行上傳,雖然瀏覽器本身對同時並行的請求數有所限制(可以看到許多請求是pending狀態),但還是造成了卡頓,因此這個流程還是需要做一個優化
fileUpload.vue
逐片上傳
這也是最直接的一種做法,可以看作是並行請求的另一個極端,上傳成功一個再上傳第二個,這裡還要處理一下錯誤重試,如果連續失敗3次,整個上傳過程終止
uploadChunks(uploadedList){ console.log(this.chunks) const requests=this.chunks.filter(chunk=>!uploadedList.includes(chunk.name)) .map((chunk,index)=>{ const form=new FormData() 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})=>{ // return this.$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() // }) const sendRequest=()=>{ return new Promise((resolve,reject)=>{ const upLoadReq=(i)=>{ const req=requests[i] const {form,index}=req this.$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=-1 if(req.error<3){ req.error++ //錯誤累加後重試 upLoadReq(i) }else{ reject() } }) } upLoadReq(0) }) } //整個過程成功後再合併檔案 sendRequest() .then(()=>{ this.mergeFile() }) },
可以看到每次只有一個上傳請求
最終生成的檔案
多個請求並行
逐個請求的確是可以解決卡頓的問題,但是效率有點低,我們還可以在這個基礎上做到有限個數的並行
一般這種問題的思路就是要形成一個任務佇列,開始的時候先從requests中取出指定並行數的請求物件(假設是3個)塞滿佇列並各自開始請求任務,每一個任務結束後將該任務關閉退出佇列然後再從request說中取出一個元素加入佇列並執行,直到requests清空,這裡如果某一片請求失敗的話那還要再塞入request隊首,這樣下次執行時還能從這個請求開始達到了重試的目的
async uploadChunks(uploadedList){ console.log(this.chunks) const requests=this.chunks.filter(chunk=>!uploadedList.includes(chunk.name)) .map((chunk,index)=>{ const form=new FormData() form.append('chunk',chunk.chunk) form.append('hash',chunk.hash) form.append('name',chunk.name) return {form,index:chunk.index,error:0} }) const sendRequest=(limit=1,task=[])=>{ let count=0 //用於記錄請求成功次數當其等於len-1時所有切片都已上傳成功 let isStop=false //標記錯誤情況,如果某一片錯誤數大於3整個任務標記失敗 並且其他並行的請求憑次標記也不在遞迴執行 const len=requests.length return new Promise((resolve,reject)=>{ const upLoadReq=()=>{ if(isStop){ return } const req=requests.shift() if(!req){ return } const {form,index}=req this.$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=-1 if(req.error<3){ req.error++ requests.unshift(req) upLoadReq() }else{ isStop=true reject() } }) } while(limit>0){ //模擬形成了一個佇列,每次結束再遞迴執行下一個任務 upLoadReq() limit-- } }) } sendRequest(3).then(res=>{ console.log(res) this.mergeFile() }) },
除了請求並行需要控制意外,hash值的計算也需要關注,雖然我們採用了增量計算的方法,但是可以看出依舊比較費時,也有可能會阻塞UI
webWork
這相當於多開了一個執行緒,讓hash計算在新的執行緒中計算,然後將結果通知會主執行緒
calculateHashWork(chunks){ return new Promise((resolve)=>{ //這個js得獨立於專案之外 this.worker=new worker('/hash.js') //切片傳入現成 this.worker.postMessage({chunks}) this.worker.onmessage=e=>{ //執行緒中返回的進度和hash值 const {progress,hash}=e.data this.hashProgress=Number(progress.toFixed(2)) if(hash){ resolve(hash) } } }) },
hash.js
//獨立於專案之外,得單獨 // 引入spark-md5 self.importScripts('spark-md5.min.js') self.onmessage = e=>{ // 接受主執行緒傳遞的資料,開始計算 const {chunks } = e.data const spark = new self.SparkMD5.ArrayBuffer() let progress = 0 let count = 0 const loadNext = index=>{ const reader = new FileReader() reader.readAsArrayBuffer(chunks[index].file) reader.onload = e=>{ count ++ spark.append(e.target.result) if(count==chunks.length){ //向主執行緒返回進度和hash self.postMessage({ progress:100, hash:spark.end() }) }else{ progress += 100/chunks.length //向主執行緒返回進度 self.postMessage({ progress }) loadNext(count) } } } loadNext(0) }
時間切片
還有一種做法就是借鑑react fiber架構,可以通過時間切片的方式在瀏覽器空閒的時候計算hash值,這樣瀏覽器的渲染是聯絡的,就不會出現明顯示卡頓
calculateHashIdle(chunks){ return new Promise(resolve=>{ const spark=new sparkMD5.ArrayBuffer() let count=0 const appendToSpark=async file=>{ return new Promise(resolve=>{ const reader=new FileReader() reader.readAsArrayBuffer(file) reader.onload=e=>{ spark.append(e.target.result) resolve() } }) } const workLoop=async deadline=>{ //當切片沒有讀完並且瀏覽器有剩餘時間 while(count<chunks.length && deadline.timeRemaining()>1){ await appendToSpark(chunks[count].file) count++ if(count<chunks.length){ this.hashProgress=Number(((100*count)/chunks.length).toFixed(2)) }else{ this.hashProgress=100 const hash=spark.end() resolve(hash) } } window.requestIdleCallback(workLoop) } window.requestIdleCallback(workLoop) }) }
以上就是Vue+NodeJS實現大檔案上傳的範例程式碼的詳細內容,更多關於Vue NodeJS大檔案上傳的資料請關注it145.com其它相關文章!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45