Vue+NodeJS實(shí)現(xiàn)大文件上傳的示例代碼
常見的文件上傳方式可能就是new一個(gè)FormData,把文件append進(jìn)去以后post給后端就可以了。但如果采用這種方式來上傳大文件就很容易產(chǎn)生上傳超時(shí)的問題,而且一旦失敗還得從新開始,在漫長(zhǎng)的等待過程中用戶還不能刷新瀏覽器,不然前功盡棄。因此這類問題一般都是通過切片上傳。
整體思路
- 將文件切成多個(gè)小文件
- hash計(jì)算,需要計(jì)算一個(gè)文件的唯一標(biāo)識(shí),這樣下次再傳,就能篩選出剩余的切片進(jìn)行上傳。
- 所有切片上傳后,通知服務(wù)端進(jìn)行切片合成
- 上傳成功通知前端文件路徑
- 整個(gè)過程如果出現(xiàn)失敗,下次再傳時(shí),由于之前計(jì)算過文件hash,可以篩選出未傳數(shù)的切片續(xù)傳(斷點(diǎn)續(xù)傳); 如果整個(gè)文件已經(jīng)上傳過,就不需要傳輸(秒傳)
項(xià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>計(jì)算hash的進(jìn)度:</div>
<el-progress :stroke-width="20" :text-inside="true" :percentage="hashProgress"></el-progress>
</div>
<div>
<div>上傳進(jìn)度:</div>
<el-progress :stroke-width="20" :text-inside="true" :percentage="uploaedProgress"></el-progress>
</div>
</div>
</div>
</template>
文件切片
利用 File.prototype.slice 的方法可以對(duì)文件進(jìn)行切片 fileUpload.vue
const CHUNK_SIZE=1024*1024//每個(gè)切片為1M
import sparkMD5 from 'spark-md5'
export default {
name:'file-upload',
data(){
return {
file:null,//上傳的文件
chunks:[],//切片
hashProgress:0,//hash值計(jì)算進(jìn)度
hash:''
}
},
methods:{
async handleFileUpload(e){
if(!file){
return
}
this.file=file
this.upload()
},
//文件上傳
async upload(){
//切片
const chunks=this.createFileChunk(this.file)
//...
//hash計(jì)算
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計(jì)算
利用md5可以計(jì)算出文件唯一的hash值
這里可以使用 spark-md5 這個(gè)庫(kù)可以增量計(jì)算文件的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做增量計(jì)算
spark.append(e.target.result)
if(count==len){
self.hashProgress=100
//返回整個(gè)文件的hash
hash=spark.end()
resolve(hash)
}else{
//更新hash計(jì)算進(jìn)度
self.hashProgress+=100/len
loadNext(index+1)
}
}
}
loadNext(0)
})
},

可以看到整個(gè)過程還是比較費(fèi)時(shí)間的,有可能會(huì)導(dǎo)致UI阻塞(卡),因此可以通過webwork等手段優(yōu)化這個(gè)過程,這點(diǎn)我們放在最后討論
查詢切片狀態(tài)
在知道了文件的hash值以后,在上傳切片前我們還要去后端查詢下文件的上傳狀態(tài),如果已經(jīng)上傳過,那就沒有必要再上傳,如果只上傳了一部分,那就上傳還沒有上過過的切片(斷點(diǎn)續(xù)傳)
前端 fileUpload.vue
//...
methods:{
//...
async upload(){
//...切片,計(jì)算hash
this.hash=hash
//查詢是否上傳 將hash和后綴作為參數(shù)傳入
this.$http.post('/checkfile',{
hash,
ext:this.file.name.split('.').pop()
})
.then(res=>{
//接口會(huì)返回兩個(gè)值 uploaded:Boolean 表示整個(gè)文件是否上傳過 和 uploadedList 哪些切片已經(jīng)上傳
const {uploaded,uploadedList}=res.data
//如果已經(jīng)上傳過,直接提示用戶(秒傳)
if(uploaded){
return this.$message.success('秒傳成功')
}
//這里我們約定上傳的每個(gè)切片名字都是 hash+‘-'+index
this.chunks=chunks.map((chunk,index)=>{
const name=hash+'-'+index
const isChunkUploaded=(uploadedList.includes(name))?true:false//當(dāng)前切片是否有上傳
return {
hash,
name,
index,
chunk:chunk.file,
progress:isChunkUploaded?100:0//當(dāng)前切片上傳進(jìn)度,如果有上傳即為100 否則為0,這是用來之后計(jì)算總體上傳進(jìn)度
}
})
//上傳切片
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)上傳過的切片被存放在 一個(gè)文件夾,名字就是該文件的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]!=='.'):[]
}
切片上傳(斷點(diǎn)續(xù)傳)
再得知切片上傳狀態(tài)后,就能篩選出需要上傳的切片來上傳。 前端 fileUpload.vue
uploadChunks(uploadedList){
//每一個(gè)要上傳的切片變成一個(gè)請(qǐng)求
const requests=this.chunks.filter(chunk=>!uploadedList.includes(chunk.name))
.map((chunk,index)=>{
const form=new FormData()
//所有上傳的切片會(huì)被存放在 一個(gè)文件夾,文件夾名字就是該文件的hash值 因此需要hash和name
form.append('chunk',chunk.chunk)
form.append('hash',chunk.hash)
form.append('name',chunk.name)
//因?yàn)榍衅灰欢ㄊ沁B續(xù)的,所以index需要取chunk對(duì)象中的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)) //當(dāng)前切片上傳的進(jìn)度
}
})
})
Promise.all(requests).then((res)=>{
//所有請(qǐng)求都成功后發(fā)送請(qǐng)求給服務(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)
}
//將文件從臨時(shí)路徑里移動(dòng)到文件夾下
await fse.move(file.filepath,`${chunkPath}/${name}`)
ctx.body={
code:0,
message:`切片上傳成功`
}
})
上傳后切片保存的位置

文件總體上傳進(jìn)度
總體上傳進(jìn)度取決于每個(gè)切片上傳的進(jìn)度和文件總體大小,可以通過計(jì)算屬性來實(shí)現(xiàn)
fileUpload.vue
uploaedProgress(){
if(!this.file || !this.chunks.length){
return 0
}
//累加每個(gè)切片已上傳的部分
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)
//切片要按順序合并,因此需要做個(gè)排序
chunks=chunks.sort((a,b)=>a.split('-')[1]-b.split('-')[1])
//切片的絕對(duì)路徑
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',()=>{
//每一個(gè)切片讀取完畢后就將其刪除
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)實(shí)現(xiàn),讓我們來看下效果(這里順便展示一下單個(gè)切片的上傳進(jìn)度)

可以看到由于大量的切片請(qǐng)求并發(fā)上傳,雖然瀏覽器本身對(duì)同時(shí)并發(fā)的請(qǐng)求數(shù)有所限制(可以看到許多請(qǐng)求是pending狀態(tài)),但還是造成了卡頓,因此這個(gè)流程還是需要做一個(gè)優(yōu)化
優(yōu)化
請(qǐng)求并發(fā)數(shù)控制
fileUpload.vue
逐片上傳
這也是最直接的一種做法,可以看作是并發(fā)請(qǐng)求的另一個(gè)極端,上傳成功一個(gè)再上傳第二個(gè),這里還要處理一下錯(cuò)誤重試,如果連續(xù)失敗3次,整個(gè)上傳過程終止
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=>{
//最后一片上傳成功,整個(gè)過程完成
if(i==requests.length-1){
resolve()
return
}
upLoadReq(i+1)
})
.catch(err=>{
this.chunks[index].progress=-1
if(req.error<3){
req.error++
//錯(cuò)誤累加后重試
upLoadReq(i)
}else{
reject()
}
})
}
upLoadReq(0)
})
}
//整個(gè)過程成功后再合并文件
sendRequest()
.then(()=>{
this.mergeFile()
})
},

可以看到每次只有一個(gè)上傳請(qǐng)求
最終生成的文件

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

