Vue+NodeJS實現(xiàn)大文件上傳的示例代碼
常見的文件上傳方式可能就是new一個FormData,把文件append進去以后post給后端就可以了。但如果采用這種方式來上傳大文件就很容易產(chǎn)生上傳超時的問題,而且一旦失敗還得從新開始,在漫長的等待過程中用戶還不能刷新瀏覽器,不然前功盡棄。因此這類問題一般都是通過切片上傳。
整體思路
- 將文件切成多個小文件
- hash計算,需要計算一個文件的唯一標識,這樣下次再傳,就能篩選出剩余的切片進行上傳。
- 所有切片上傳后,通知服務(wù)端進行切片合成
- 上傳成功通知前端文件路徑
- 整個過程如果出現(xiàn)失敗,下次再傳時,由于之前計算過文件hash,可以篩選出未傳數(shù)的切片續(xù)傳(斷點續(xù)傳); 如果整個文件已經(jīng)上傳過,就不需要傳輸(秒傳)
項目演示
這里用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
},
}hash計算
利用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等手段優(yōu)化這個過程,這點我們放在最后討論
查詢切片狀態(tài)
在知道了文件的hash值以后,在上傳切片前我們還要去后端查詢下文件的上傳狀態(tài),如果已經(jīng)上傳過,那就沒有必要再上傳,如果只上傳了一部分,那就上傳還沒有上過過的切片(斷點續(xù)傳)
前端 fileUpload.vue
//...
methods:{
//...
async upload(){
//...切片,計算hash
this.hash=hash
//查詢是否上傳 將hash和后綴作為參數(shù)傳入
this.$http.post('/checkfile',{
hash,
ext:this.file.name.split('.').pop()
})
.then(res=>{
//接口會返回兩個值 uploaded:Boolean 表示整個文件是否上傳過 和 uploadedList 哪些切片已經(jīng)上傳
const {uploaded,uploadedList}=res.data
//如果已經(jīng)上傳過,直接提示用戶(秒傳)
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

服務(wù)端 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{
//所有已經(jīng)上傳過的切片被存放在 一個文件夾,名字就是該文件的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]!=='.'):[]
}
切片上傳(斷點續(xù)傳)
再得知切片上傳狀態(tài)后,就能篩選出需要上傳的切片來上傳。 前端 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)
//因為切片不一定是連續(xù)的,所以index需要取chunk對象中的index
return {form,index:chunk.index,error:0}
})//所有切片一起并發(fā)上傳
.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)=>{
//所有請求都成功后發(fā)送請求給服務(wù)端合并文件
this.mergeFile()
})
},
服務(wù)端
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:`切片上傳成功`
}
})
上傳后切片保存的位置

文件總體上傳進度
總體上傳進度取決于每個切片上傳的進度和文件總體大小,可以通過計算屬性來實現(xiàn)
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
//要傳給服務(wù)端文件后綴,切片的大小和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)
}
})
},
服務(wù)端
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)
}
大文件切片上傳的功能已經(jīng)實現(xiàn),讓我們來看下效果(這里順便展示一下單個切片的上傳進度)

可以看到由于大量的切片請求并發(fā)上傳,雖然瀏覽器本身對同時并發(fā)的請求數(shù)有所限制(可以看到許多請求是pending狀態(tài)),但還是造成了卡頓,因此這個流程還是需要做一個優(yōu)化
優(yōu)化
請求并發(fā)數(shù)控制
fileUpload.vue
逐片上傳
這也是最直接的一種做法,可以看作是并發(fā)請求的另一個極端,上傳成功一個再上傳第二個,這里還要處理一下錯誤重試,如果連續(xù)失敗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()
})
},

可以看到每次只有一個上傳請求
最終生成的文件

多個請求并發(fā)
逐個請求的確是可以解決卡頓的問題,但是效率有點低,我們還可以在這個基礎(chǔ)上做到有限個數(shù)的并發(fā)
一般這種問題的思路就是要形成一個任務(wù)隊列,開始的時候先從requests中取出指定并發(fā)數(shù)的請求對象(假設(shè)是3個)塞滿隊列并各自開始請求任務(wù),每一個任務(wù)結(jié)束后將該任務(wù)關(guān)閉退出隊列然后再從request說中取出一個元素加入隊列并執(zhí)行,直到requests清空,這里如果某一片請求失敗的話那還要再塞入request隊首,這樣下次執(zhí)行時還能從這個請求開始達到了重試的目的
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 //用于記錄請求成功次數(shù)當其等于len-1時所有切片都已上傳成功
let isStop=false //標記錯誤情況,如果某一片錯誤數(shù)大于3整個任務(wù)標記失敗 并且其他并發(fā)的請求憑次標記也不在遞歸執(zhí)行
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){
//模擬形成了一個隊列,每次結(jié)束再遞歸執(zhí)行下一個任務(wù)
upLoadReq()
limit--
}
})
}
sendRequest(3).then(res=>{
console.log(res)
this.mergeFile()
})
},
hash值計算優(yōu)化
除了請求并發(fā)需要控制意外,hash值的計算也需要關(guān)注,雖然我們采用了增量計算的方法,但是可以看出依舊比較費時,也有可能會阻塞UI
webWork
這相當于多開了一個線程,讓hash計算在新的線程中計算,然后將結(jié)果通知會主線程
calculateHashWork(chunks){
return new Promise((resolve)=>{
//這個js得獨立于項目之外
this.worker=new worker('/hash.js')
//切片傳入現(xiàn)成
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=>{
// 接受主線程傳遞的數(shù)據(jù),開始計算
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架構(gòu),可以通過時間切片的方式在瀏覽器空閑的時候計算hash值,這樣瀏覽器的渲染是聯(lián)系的,就不會出現(xiàn)明顯卡頓
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實現(xiàn)大文件上傳的示例代碼的詳細內(nèi)容,更多關(guān)于Vue NodeJS大文件上傳的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
用vue實現(xiàn)注冊頁效果?vue實現(xiàn)短信驗證碼登錄
這篇文章主要為大家詳細介紹了用vue實現(xiàn)注冊頁,短信驗證碼登錄,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-11-11
vue中使用 pako.js 解密 gzip加密字符串的方法
這篇文章主要介紹了vue項目中 使用 pako.js 解密 gzip加密字符串 的方法,本文通過實例代碼給大家介紹的非常詳細,具有一定的參考借鑒價值,需要的朋友可以參考下2019-06-06
Vue 實現(xiàn)html中根據(jù)類型顯示內(nèi)容
今天小編大家分享一篇Vue 實現(xiàn)html中根據(jù)類型顯示內(nèi)容,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2019-10-10
Vue3+TypeScript封裝axios并進行請求調(diào)用的實現(xiàn)
這篇文章主要介紹了Vue3+TypeScript封裝axios并進行請求調(diào)用的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2021-04-04

