在Vue3和TypeScript中大文件分片上傳的實(shí)現(xiàn)與優(yōu)化
引言
在現(xiàn)代 Web 開發(fā)中,數(shù)據(jù)上傳的需求日益增多,特別是在處理大規(guī)模數(shù)據(jù)時(shí),傳統(tǒng)的大文件上傳方式已經(jīng)難以滿足高效、穩(wěn)定的需求。本文將結(jié)合實(shí)際項(xiàng)目,詳細(xì)介紹如何在 Vue 3 和 TypeScript 環(huán)境中實(shí)現(xiàn)大文件分片上傳,并進(jìn)行性能優(yōu)化。
1. 項(xiàng)目技術(shù)棧
項(xiàng)目采用了以下技術(shù)棧:
前端:Vue 3 + TypeScript + Vue Router + Pinia + Element Plus + Axios + Normalize.css
- 使用 Vue 3 Composition API 和 Pinia 管理全局狀態(tài),確保代碼結(jié)構(gòu)清晰,狀態(tài)管理便捷。
- TypeScript 提供了強(qiáng)大的類型檢查機(jī)制,減少了運(yùn)行時(shí)錯(cuò)誤,增強(qiáng)了代碼的可維護(hù)性。
- Vue Router 4 負(fù)責(zé)管理應(yīng)用路由,Element Plus 提供了豐富的 UI 組件,而 Axios 則用于處理網(wǎng)絡(luò)請(qǐng)求。
- 使用 Vite 作為開發(fā)和構(gòu)建工具,提升了開發(fā)效率。
后端:Node.js + Koa.js + TypeScript + Koa Router
- 通過 Koa.js 與 TypeScript 的結(jié)合,使用 Koa Router 加強(qiáng)服務(wù)端路由管理,優(yōu)化開發(fā)體驗(yàn),并集成了全局異常攔截與日志功能。
2. 前端設(shè)計(jì)與實(shí)現(xiàn)
前端的核心在于如何高效處理大文件的上傳。傳統(tǒng)的單一文件上傳方式容易因網(wǎng)絡(luò)波動(dòng)導(dǎo)致上傳失敗,而分片上傳則能有效避免此類問題。以下是分片上傳的主要實(shí)現(xiàn)步驟:
文件切片: 使用
Blob.prototype.slice
方法,將大文件切分為多個(gè) 10MB 的小塊。每個(gè)切片都具有唯一的標(biāo)識(shí),確保了上傳的完整性和正確性。文件秒傳,即在服務(wù)端已經(jīng)存在了上傳的資源,所以當(dāng)用戶再次上傳時(shí)會(huì)直接提示上傳成功。文件秒傳需要依賴上一步生成的 hash,即在上傳前,先計(jì)算出文件 hash,并把 hash 發(fā)送給服務(wù)端進(jìn)行驗(yàn)證,由于 hash 的唯一性,所以一旦服務(wù)端能找到 hash 相同的文件,則直接返回上傳成功的信息即可。
const CHUNK_SIZE = 10 * 1024 * 1024 ? // 文件上傳服務(wù)器 async function submitUpload() { if (!file.value) { ElMessage.error('Oops, 請(qǐng)您選擇文件后再操作~~.') return } ? // 將文件切片 const chunks: IFileSlice[] = [] let cur = 0 while (cur < file.value.raw!.size) { const slice = file.value.raw!.slice(cur, cur + CHUNK_SIZE) chunks.push({ chunk: slice, size: slice.size }) cur += CHUNK_SIZE } ? // 計(jì)算hash hash.value = await calculateHash(chunks) fileChunks.value = chunks.map((item, index) => ({ ...item, hash: `${hash.value}-${index}`, progress: 0 })) // 校驗(yàn)文件是否已存在 await fileStore.verifyFileAction({ filename: file.value.name, fileHash: hash.value }) const { exists } = storeToRefs(fileStore) if (!exists.value) { await uploadChunks({ chunks, hash: hash.value, totalChunksCount: fileChunks.value.length, uploadedChunks: 0 }) } else { ElMessage.success('秒傳: 文件上傳成功') } }
并發(fā)上傳與調(diào)度: 實(shí)現(xiàn)了一個(gè)并發(fā)控制的 Scheduler
,限制同時(shí)上傳的切片數(shù)為 3,避免因過多并發(fā)請(qǐng)求導(dǎo)致的系統(tǒng)卡頓或崩潰。
// scheduler.ts export class Scheduler { private queue: (() => Promise<void>)[] = [] private maxCount: number private runCounts = 0 constructor(limit: number) { this.maxCount = limit } add(promiseCreator: () => Promise<void>) { this.queue.push(promiseCreator) this.run() } private run() { if (this.runCounts >= this.maxCount || this.queue.length === 0) { return } this.runCounts++ const task = this.queue.shift()! task().finally(() => { this.runCounts-- this.run() }) } } // UploadFile.vue // 切片上傳 limit-限制并發(fā)數(shù) async function uploadChunks({ chunks, hash, totalChunksCount, uploadedChunks, limit = 3 }: IUploadChunkParams) { const scheduler = new Scheduler(limit) const totalChunks = chunks.length let uploadedChunksCount = 0 for (let i = 0; i < chunks.length; i++) { const { chunk } = chunks[i] let h = '' if (chunks[i].hash) { h = chunks[i].hash as string } else { h = `${hash}-${chunks.indexOf(chunks[i])}` } const params = { chunk, hash: h, fileHash: hash, filename: file.value?.name as string, size: file.value?.size } as IUploadChunkControllerParams scheduler.add(() => { const controller = new AbortController() controllersMap.set(i, controller) const { signal } = controller console.log(`開始上傳切片 ${i}`) if (!upload.value) { return Promise.reject('上傳暫停') } return fileStore .uploadChunkAction(params, onTick, i, signal) .then(() => { console.log(`完成切片的上傳 ${i}`) uploadedChunksCount++ // 判斷所有切片都已上傳完成后,調(diào)用mergeRequest方法 if (uploadedChunksCount === totalChunks) { mergeRequest() } }) .catch((error) => { if (error.name === 'AbortError') { console.log('上傳被取消') } else { throw error } }) .finally(() => { // 完成后將控制器從map中移除 controllersMap.delete(i) }) }) } function onTick(index: number, percent: number) { chunks[index].percentage = percent const newChunksProgress = chunks.reduce( (sum, chunk) => sum + (chunk.percentage || 0), 0 ) const totalProgress = (newChunksProgress + uploadedChunks * 100) / totalChunksCount file.value!.percentage = Number(totalProgress.toFixed(2)) } }
Web Worker 計(jì)算文件 Hash: 為了避免阻塞主線程,使用 Web Worker 計(jì)算每個(gè)切片的 Hash 值,用于服務(wù)器端的文件校驗(yàn)。這一步確保了文件的唯一性,避免了重復(fù)上傳。
// hash.ts import SparkMD5 from 'spark-md5' const ctx: Worker = self as any ctx.onmessage = (e) => { // 接收主線程的通知 const { chunks } = e.data const blob = new Blob(chunks) const spark = new SparkMD5.ArrayBuffer() const reader = new FileReader() reader.onload = (e) => { spark.append(e.target?.result as ArrayBuffer) const hash = spark.end() ctx.postMessage({ progress: 100, hash }) } reader.onerror = (e: any) => { ctx.postMessage({ error: e.message }) } reader.onprogress = (e) => { if (e.lengthComputable) { const progress = (e.loaded / e.total) * 100 ctx.postMessage({ progress }) } } // 讀取Blob對(duì)象的內(nèi)容 reader.readAsArrayBuffer(blob) } ctx.onerror = (e) => { ctx.postMessage({ error: e.message }) } // UploadFile.vue // 使用Web Worker進(jìn)行hash計(jì)算的函數(shù) function calculateHash(fileChunks: IFileSlice[]): Promise<string> { return new Promise<string>((resolve, reject) => { const worker = new HashWorker() worker.postMessage({ chunks: fileChunks }) worker.onmessage = (e) => { const { hash } = e.data if (hash) { resolve(hash) } } worker.onerror = (event) => { worker.terminate() reject(event.error) } }) }
斷點(diǎn)續(xù)傳與秒傳: 通過前端判斷服務(wù)器上已有的文件切片,支持?jǐn)帱c(diǎn)續(xù)傳和秒傳功能。用戶不需要重新上傳整個(gè)文件,而只需上傳未完成的部分,極大地提升了上傳效率。
// 上傳暫停和繼續(xù) async function handlePause() { upload.value = !upload.value if (upload.value) { // 校驗(yàn)文件是否已存在 if (!file.value?.name) { return } await fileStore.verifyFileAction({ filename: file.value.name, fileHash: hash.value }) const { exists, existsList } = storeToRefs(fileStore) const newChunks = fileChunks.value.filter((item) => { return !existsList.value.includes(item.hash || '') }) console.log('newChunks', newChunks) if (!exists.value) { await uploadChunks({ chunks: newChunks, hash: hash.value, totalChunksCount: fileChunks.value.length, uploadedChunks: fileChunks.value.length - newChunks.length }) } else { ElMessage.success('秒傳: 文件上傳成功') } } else { console.log('暫停上傳') abortAll() } }
用戶體驗(yàn)優(yōu)化: 為了提升用戶體驗(yàn),添加了拖拽上傳、上傳進(jìn)度顯示、文件暫停與續(xù)傳等功能。這些優(yōu)化不僅增強(qiáng)了系統(tǒng)的健壯性,還使用戶在處理大文件時(shí)體驗(yàn)更為流暢。
3. 后端實(shí)現(xiàn)與整合
后端使用 Koa.js 構(gòu)建,核心在于如何高效接收并合并前端上傳的文件切片。具體步驟如下:
文件接收與存儲(chǔ): 通過 Koa Router 定義的 API 端點(diǎn)接收前端上傳的切片,使用
ctx.request.files
獲取上傳的文件,并通過ctx.request.body
獲取其他字段信息。
// verify.ts 校驗(yàn)文件是否存儲(chǔ) import { type Context } from 'koa' import { type IUploadedFile, type GetFileControllerResponse, type IVefiryFileControllerParams, type VefiryFileControllerResponse } from '../utils/types' import fileSizesStore from '../utils/fileSizesStore' import { HttpError, HttpStatus } from '../utils/http-error' import { UPLOAD_DIR, extractExt, getChunkDir, getUploadedList, isValidString } from '../utils' import { IMiddleware } from 'koa-router' import { Controller } from '../controller' import path from 'path' import fse from 'fs-extra' const fnVerify: IMiddleware = async ( ctx: Context, next: () => Promise<void> ) => { const { filename, fileHash } = ctx.request .body as IVefiryFileControllerParams if (!isValidString(fileHash)) { throw new HttpError(HttpStatus.PARAMS_ERROR, 'fileHash 不能為空') } if (!isValidString(filename)) { throw new HttpError(HttpStatus.PARAMS_ERROR, 'filename 不能為空') } const ext = extractExt(filename!) const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`) let isExist = false let existsList: string[] = [] if (fse.existsSync(filePath)) { isExist = true } else { existsList = await getUploadedList(fileHash!) } ctx.body = { code: 0, data: { exists: isExist, existsList: existsList } } as VefiryFileControllerResponse await next() } // 獲取所有已上傳文件的接口 const fnGetFile: IMiddleware = async ( ctx: Context, next: () => Promise<void> ): Promise<void> => { const files = await fse.readdir(UPLOAD_DIR).catch(() => []) const fileListPromises = files .filter((file) => !file.endsWith('.json')) .map(async (file) => { const filePath = path.resolve(UPLOAD_DIR, file) const stat = fse.statSync(filePath) const ext = extractExt(file) let fileHash = '' let size = stat.size if (file.includes('chunkDir_')) { fileHash = file.slice('chunkDir_'.length) const chunkDir = getChunkDir(fileHash) const chunks = await fse.readdir(chunkDir) let totalSize = 0 for (const chunk of chunks) { const chunkPath = path.resolve(chunkDir, chunk) const stat = await fse.stat(chunkPath) totalSize += stat.size } size = totalSize } else { fileHash = file.slice(0, file.length - ext.length) } const total = await fileSizesStore.getFileSize(fileHash) return { name: file, uploadedSize: size, totalSize: total, time: stat.mtime.toISOString(), hash: fileHash } as IUploadedFile }) const fileList = await Promise.all(fileListPromises) ctx.body = { code: 0, data: { files: fileList } } as GetFileControllerResponse await next() } const controllers: Controller[] = [ { method: 'POST', path: '/api/verify', fn: fnVerify }, { method: 'GET', path: '/api/files', fn: fnGetFile } ] export default controllers
// upload.ts 上傳切片 import { IMiddleware } from 'koa-router' import { UPLOAD_DIR, extractExt, getChunkDir, isValidString } from '../utils' import fileSizesStore from '../utils/fileSizesStore' import { HttpError, HttpStatus } from '../utils/http-error' import { type IUploadChunkControllerParams, type UploadChunkControllerResponse } from '../utils/types' import path from 'path' import fse from 'fs-extra' import { Controller } from '../controller' import { Context } from 'koa' import koaBody from 'koa-body' const fnUpload: IMiddleware = async ( ctx: Context, next: () => Promise<void> ) => { const { filename, fileHash, hash, size } = ctx.request .body as IUploadChunkControllerParams const chunkFile = ctx.request.files?.chunk if (!chunkFile || Array.isArray(chunkFile)) { throw new Error(`無效的塊文件參數(shù)`) } const chunk = await fse.readFile(chunkFile.filepath) if (!isValidString(fileHash)) { throw new HttpError(HttpStatus.PARAMS_ERROR, 'fileHash 不能為空: ') } if (isValidString(chunk)) { throw new HttpError(HttpStatus.PARAMS_ERROR, 'chunk 不能為空') } if (!isValidString(filename)) { throw new HttpError(HttpStatus.PARAMS_ERROR, 'filename 不能為空') } const params = { filename, fileHash, hash, chunk, size } as IUploadChunkControllerParams fileSizesStore.storeFileSize(fileHash, size) const ext = extractExt(params.filename!) const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`) const chunkDir = getChunkDir(params.fileHash!) const chunkPath = path.resolve(chunkDir, params.hash!) // 切片目錄不存在,創(chuàng)建切片目錄 if (!(await fse.pathExists(chunkDir))) { await fse.mkdir(chunkDir, { recursive: true }) } // 文件存在直接返回 if (await fse.pathExists(filePath)) { ctx.body = { code: 1, message: 'file exist', data: { hash: fileHash } } as UploadChunkControllerResponse return } // 切片存在直接返回 if (await fse.pathExists(chunkPath)) { ctx.body = { code: 2, message: 'chunk exist', data: { hash: fileHash } } as UploadChunkControllerResponse return } await fse.move(chunkFile.filepath, `${chunkDir}/${hash}`) ctx.body = { code: 0, message: 'received file chunk', data: { hash: params.fileHash } } as UploadChunkControllerResponse await next() } const controllers: Controller[] = [ { method: 'POST', path: '/api/upload', fn: fnUpload, middleware: [koaBody({ multipart: true })] } ] export default controllers
切片合并: 當(dāng)所有切片上傳完成后,后端會(huì)根據(jù)前端傳來的請(qǐng)求對(duì)切片進(jìn)行合并。這里使用了 Node.js 的 Stream 進(jìn)行并發(fā)寫入,提高了合并效率,并減少了內(nèi)存占用。
// merge.ts import { UPLOAD_DIR, extractExt, getChunkDir, isValidString } from '../utils' import { HttpError, HttpStatus } from '../utils/http-error' import type { IMergeChunksControllerParams, MergeChunksControllerResponse } from '../utils/types' import path from 'path' import fse from 'fs-extra' import { IMiddleware } from 'koa-router' import { Controller } from '../controller' import { Context } from 'koa' // 寫入文件流 const pipeStream = ( filePath: string, writeStream: NodeJS.WritableStream ): Promise<boolean> => { return new Promise((resolve) => { const readStream = fse.createReadStream(filePath) readStream.on('end', () => { fse.unlinkSync(filePath) resolve(true) }) readStream.pipe(writeStream) }) } const mergeFileChunk = async ( filePath: string, fileHash: string, size: number ) => { const chunkDir = getChunkDir(fileHash) const chunkPaths = await fse.readdir(chunkDir) // 切片排序 chunkPaths.sort((a, b) => { return a.split('-')[1] - b.split('-')[1] }) // 寫入文件 await Promise.all( chunkPaths.map((chunkPath, index) => pipeStream( path.resolve(chunkDir, chunkPath), // 根據(jù) size 在指定位置創(chuàng)建可寫流 fse.createWriteStream(filePath, { start: index * size }) ) ) ) // 合并后刪除保存切片的目錄 fse.rmdirSync(chunkDir) } const fnMerge: IMiddleware = async ( ctx: Context, next: () => Promise<void> ) => { const { filename, fileHash, size } = ctx.request .body as IMergeChunksControllerParams if (!isValidString(fileHash)) { throw new HttpError(HttpStatus.PARAMS_ERROR, 'fileHash 不能為空: ') } if (!isValidString(filename)) { throw new HttpError(HttpStatus.PARAMS_ERROR, 'filename 不能為空') } const ext = extractExt(filename!) const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`) await mergeFileChunk(filePath, fileHash!, size!) ctx.body = { code: 0, message: 'file merged success', data: { hash: fileHash } } as MergeChunksControllerResponse await next() } const controllers: Controller[] = [ { method: 'POST', path: '/api/merge', fn: fnMerge } ] export default controllers
全局異常處理與日志記錄: 為了保證系統(tǒng)的穩(wěn)定性,服務(wù)端實(shí)現(xiàn)了全局異常處理和日志記錄功能,確保在出現(xiàn)問題時(shí)能快速定位并修復(fù)。
4. 遇到的問題與解決方案
在實(shí)現(xiàn)過程中,我們也遇到了一些挑戰(zhàn):
- 代碼結(jié)構(gòu)混亂:在初期開發(fā)時(shí),大量的代碼邏輯被集中在一起,缺乏合理的抽象與封裝。我們通過組件化、工具類方法抽取、狀態(tài)邏輯分離等方式,逐步優(yōu)化了代碼結(jié)構(gòu)。
- 網(wǎng)絡(luò)請(qǐng)求封裝:為了提高代碼的可維護(hù)性,我們封裝了 Axios,并抽離了 API 相關(guān)操作。這樣一來,未來即使更換網(wǎng)絡(luò)請(qǐng)求庫,也只需修改一個(gè)文件即可。
- 并發(fā)請(qǐng)求過多:通過實(shí)現(xiàn)一個(gè)帶有并發(fā)限制的
Scheduler
,我們確保了系統(tǒng)的穩(wěn)定性,避免了因過多并發(fā)請(qǐng)求導(dǎo)致的系統(tǒng)性能問題。
5. 開發(fā)流程圖
6. 總結(jié)
本文介紹了如何在 Vue 3 與 TypeScript 環(huán)境中實(shí)現(xiàn)大文件的分片上傳,并在此基礎(chǔ)上進(jìn)行了多方面的優(yōu)化。通過這些技術(shù)手段,我們不僅提升了系統(tǒng)的性能,還極大地改善了用戶體驗(yàn)。隨著數(shù)據(jù)量的不斷增長(zhǎng),這種分片上傳的方式將會(huì)越來越普及,并在未來的開發(fā)中發(fā)揮重要作用。
這種架構(gòu)設(shè)計(jì)為處理大文件上傳提供了一個(gè)高效、可靠的解決方案,并且具有很強(qiáng)的擴(kuò)展性和可維護(hù)性。希望通過本文的介紹,能為大家在實(shí)際項(xiàng)目中解決類似問題提供一些參考和借鑒。
以上就是在Vue3和TypeScript中大文件分片上傳的實(shí)現(xiàn)與優(yōu)化的詳細(xì)內(nèi)容,更多關(guān)于Vue3 TypeScript大文件分片上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
在Vue中實(shí)現(xiàn)隨hash改變響應(yīng)菜單高亮
這篇文章主要介紹了在Vue中實(shí)現(xiàn)隨hash改變響應(yīng)菜單高亮的方法,文中還通過實(shí)例代碼給大家介紹了vue關(guān)于點(diǎn)擊菜單高亮與組件切換的相關(guān)知識(shí),需要的朋友可以參考下2020-03-03vue在自定義組件上使用v-model和.sync的方法實(shí)例
自定義組件的v-model和.sync修飾符其實(shí)本質(zhì)上都是vue的語法糖,用于實(shí)現(xiàn)父子組件的"數(shù)據(jù)"雙向綁定,下面這篇文章主要給大家介紹了關(guān)于vue在自定義組件上使用v-model和.sync的相關(guān)資料,需要的朋友可以參考下2022-07-07elementui中el-input回車搜索實(shí)現(xiàn)示例
這篇文章主要介紹了elementui中el-input回車搜索實(shí)現(xiàn)示例,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-06-06詳解vue-cli中的ESlint配置文件eslintrc.js
本篇文章主要介紹了vue-cli中的ESlint配置文件eslintrc.js詳解 ,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-09-09vue 導(dǎo)航內(nèi)容設(shè)置選中狀態(tài)樣式的例子
今天小編就為大家分享一篇vue 導(dǎo)航內(nèi)容設(shè)置選中狀態(tài)樣式的例子,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2019-11-11基于vue v-for 循環(huán)復(fù)選框-默認(rèn)勾選第一個(gè)的實(shí)現(xiàn)方法
下面小編就為大家分享一篇基于vue v-for 循環(huán)復(fù)選框-默認(rèn)勾選第一個(gè)的實(shí)現(xiàn)方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-03-03