在Vue3和TypeScript中大文件分片上傳的實(shí)現(xiàn)與優(yōu)化
引言
在現(xiàn)代 Web 開(kāi)發(fā)中,數(shù)據(jù)上傳的需求日益增多,特別是在處理大規(guī)模數(shù)據(jù)時(shí),傳統(tǒng)的大文件上傳方式已經(jīng)難以滿(mǎn)足高效、穩(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)大的類(lèi)型檢查機(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 作為開(kāi)發(fā)和構(gòu)建工具,提升了開(kāi)發(fā)效率。
后端:Node.js + Koa.js + TypeScript + Koa Router
- 通過(guò) Koa.js 與 TypeScript 的結(jié)合,使用 Koa Router 加強(qiáng)服務(wù)端路由管理,優(yōu)化開(kāi)發(fā)體驗(yàn),并集成了全局異常攔截與日志功能。
2. 前端設(shè)計(jì)與實(shí)現(xiàn)
前端的核心在于如何高效處理大文件的上傳。傳統(tǒng)的單一文件上傳方式容易因網(wǎng)絡(luò)波動(dòng)導(dǎo)致上傳失敗,而分片上傳則能有效避免此類(lèi)問(wèn)題。以下是分片上傳的主要實(shí)現(xiàn)步驟:
文件切片: 使用
Blob.prototype.slice方法,將大文件切分為多個(gè) 10MB 的小塊。每個(gè)切片都具有唯一的標(biāo)識(shí),確保了上傳的完整性和正確性。文件秒傳,即在服務(wù)端已經(jīng)存在了上傳的資源,所以當(dāng)用戶(hù)再次上傳時(shí)會(huì)直接提示上傳成功。文件秒傳需要依賴(lài)上一步生成的 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,避免因過(guò)多并發(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(`開(kāi)始上傳切片 ${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: 為了避免阻塞主線(xiàn)程,使用 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) => {
// 接收主線(xiàn)程的通知
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ù)傳與秒傳: 通過(guò)前端判斷服務(wù)器上已有的文件切片,支持?jǐn)帱c(diǎn)續(xù)傳和秒傳功能。用戶(hù)不需要重新上傳整個(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()
}
}
用戶(hù)體驗(yàn)優(yōu)化: 為了提升用戶(hù)體驗(yàn),添加了拖拽上傳、上傳進(jìn)度顯示、文件暫停與續(xù)傳等功能。這些優(yōu)化不僅增強(qiáng)了系統(tǒng)的健壯性,還使用戶(hù)在處理大文件時(shí)體驗(yàn)更為流暢。
3. 后端實(shí)現(xiàn)與整合
后端使用 Koa.js 構(gòu)建,核心在于如何高效接收并合并前端上傳的文件切片。具體步驟如下:
文件接收與存儲(chǔ): 通過(guò) Koa Router 定義的 API 端點(diǎn)接收前端上傳的切片,使用
ctx.request.files獲取上傳的文件,并通過(guò)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(`無(wú)效的塊文件參數(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ù)前端傳來(lái)的請(qǐng)求對(duì)切片進(jìn)行合并。這里使用了 Node.js 的 Stream 進(jìn)行并發(fā)寫(xiě)入,提高了合并效率,并減少了內(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'
// 寫(xiě)入文件流
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]
})
// 寫(xiě)入文件
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 根據(jù) size 在指定位置創(chuàng)建可寫(xiě)流
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)問(wèn)題時(shí)能快速定位并修復(fù)。
4. 遇到的問(wèn)題與解決方案
在實(shí)現(xiàn)過(guò)程中,我們也遇到了一些挑戰(zhàn):
- 代碼結(jié)構(gòu)混亂:在初期開(kāi)發(fā)時(shí),大量的代碼邏輯被集中在一起,缺乏合理的抽象與封裝。我們通過(guò)組件化、工具類(lèi)方法抽取、狀態(tài)邏輯分離等方式,逐步優(yōu)化了代碼結(jié)構(gòu)。
- 網(wǎng)絡(luò)請(qǐng)求封裝:為了提高代碼的可維護(hù)性,我們封裝了 Axios,并抽離了 API 相關(guān)操作。這樣一來(lái),未來(lái)即使更換網(wǎng)絡(luò)請(qǐng)求庫(kù),也只需修改一個(gè)文件即可。
- 并發(fā)請(qǐng)求過(guò)多:通過(guò)實(shí)現(xiàn)一個(gè)帶有并發(fā)限制的
Scheduler,我們確保了系統(tǒng)的穩(wěn)定性,避免了因過(guò)多并發(fā)請(qǐng)求導(dǎo)致的系統(tǒng)性能問(wèn)題。
5. 開(kāi)發(fā)流程圖

6. 總結(jié)
本文介紹了如何在 Vue 3 與 TypeScript 環(huán)境中實(shí)現(xiàn)大文件的分片上傳,并在此基礎(chǔ)上進(jìn)行了多方面的優(yōu)化。通過(guò)這些技術(shù)手段,我們不僅提升了系統(tǒng)的性能,還極大地改善了用戶(hù)體驗(yàn)。隨著數(shù)據(jù)量的不斷增長(zhǎng),這種分片上傳的方式將會(huì)越來(lái)越普及,并在未來(lái)的開(kāi)發(fā)中發(fā)揮重要作用。
這種架構(gòu)設(shè)計(jì)為處理大文件上傳提供了一個(gè)高效、可靠的解決方案,并且具有很強(qiáng)的擴(kuò)展性和可維護(hù)性。希望通過(guò)本文的介紹,能為大家在實(shí)際項(xiàng)目中解決類(lèi)似問(wèn)題提供一些參考和借鑒。
以上就是在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)v-model雙向綁定數(shù)據(jù)的實(shí)例代碼
vue中父子組件通信,都是單項(xiàng)的,直接在子組件中修改prop傳的值vue也會(huì)給出一個(gè)警告,接下來(lái)就用一個(gè)小列子一步一步實(shí)現(xiàn)了vue自定義的組件實(shí)現(xiàn)v-model雙向綁定,需要的朋友可以參考下2021-10-10
Django+Vue實(shí)現(xiàn)WebSocket連接的示例代碼
這篇文章主要介紹了Django+Vue實(shí)現(xiàn)WebSocket連接的示例代碼,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-05-05
Vue實(shí)例初始化為渲染函數(shù)設(shè)置檢查源碼剖析
這篇文章主要為大家介紹了Vue實(shí)例初始化為渲染函數(shù)設(shè)置檢查源碼剖析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08
Vue表單數(shù)據(jù)修改與刪除功能實(shí)現(xiàn)
本文通過(guò)實(shí)例代碼介紹了Vue表單數(shù)據(jù)修改與刪除功能實(shí)現(xiàn),結(jié)合實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友跟隨小編一起看看吧2023-10-10
解決vue數(shù)據(jù)更新但table內(nèi)容不更新的問(wèn)題
這篇文章主要給大家介紹了vue數(shù)據(jù)更新table內(nèi)容不更新解決方法,文中有詳細(xì)的代碼示例供大家作為參考,感興趣的同學(xué)可以參考閱讀一下2023-08-08
Mint UI組件庫(kù)CheckList使用及踩坑總結(jié)
這篇文章主要介紹了Mint UI組件庫(kù)CheckList使用及踩坑總結(jié),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-12-12
基于vue-element組件實(shí)現(xiàn)音樂(lè)播放器功能
這篇文章主要介紹了基于vue-element組件實(shí)現(xiàn)音樂(lè)播放器功能,代碼簡(jiǎn)單易懂,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2018-05-05
configureWebpack、chainWebpack配置vue.config.js方式
這篇文章主要介紹了configureWebpack、chainWebpack配置vue.config.js方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-01-01

