Vue3實(shí)現(xiàn)大文件上傳:切片上傳 / 斷點(diǎn)續(xù)傳 / 秒傳 / 暫?;謴?fù) / 全局并發(fā)控制
概述
本文基于 Vue3 實(shí)現(xiàn)一個(gè)大文件上傳方案,完整覆蓋以下核心能力:
- 切片上傳
- 斷點(diǎn)續(xù)傳
- 秒傳(存在即返回)
- 暫停 / 恢復(fù)
- 全局并發(fā)控制與實(shí)時(shí)進(jìn)度
本文采用全局并發(fā)限流器作為上傳調(diào)度核心,讓多個(gè)文件、多個(gè)切片在總并發(fā)可控的前提下高效上傳。
技術(shù)架構(gòu)
- 前端:Vue3(Composition API)
- 并發(fā)控制:全局 ConcurrencyLimiter(信號(hào)量/隊(duì)列)
- 進(jìn)度統(tǒng)計(jì):切片級(jí)別累加 → 文件級(jí)別 → 全局
- 傳輸庫(kù):axios(支持上傳進(jìn)度與取消)
- 切片與 Hash:Web Worker + SparkMD5
整體上傳流程圖

目錄結(jié)構(gòu)建議
僅供參考,便于組織代碼:
src/
api/
index.ts // axios 實(shí)例與 API 函數(shù)
worker/
hash-worker.js // 切片與文件Hash計(jì)算
upload/
limiter.ts // ConcurrencyLimiter
types.ts // 類型定義
helpers.ts // 進(jìn)度、重試、工具函數(shù)
views/
Uploader.vue // 上傳頁面(UI + 調(diào)度)
類型定義(upload/types.ts)
統(tǒng)一、清晰的命名有助于維護(hù)與協(xié)作。
/**
* 文件上傳狀態(tài)枚舉
* - idle: 初始狀態(tài),未開始上傳
* - uploading: 上傳中
* - paused: 已暫停
* - completed: 上傳完成
* - failed: 上傳失敗
*/
export type FileState = 'idle' | 'uploading' | 'paused' | 'completed' | 'failed';
/**
* 分片上傳狀態(tài)枚舉
* - idle: 初始狀態(tài),未開始上傳
* - uploading: 上傳中
* - completed: 上傳完成
* - failed: 上傳失敗
*/
export type ChunkState = 'idle' | 'uploading' | 'completed' | 'failed';
/**
* 分片上傳任務(wù)信息
*/
export interface UploadChunkTask {
fileId: string; // 所屬文件ID
chunkId: string; // 分片唯一標(biāo)識(shí),格式如 `${fileId}-${chunkIndex}`
chunkIndex: number; // 分片序號(hào)(從0開始)
size: number; // 分片大?。ㄗ止?jié))
blob: Blob; // 分片二進(jìn)制數(shù)據(jù)
chunkHash: string; // 分片哈希值,格式如 `${fileHash}-${chunkIndex}`,用于斷點(diǎn)續(xù)傳識(shí)別
uploadedBytes: number; // 已上傳字節(jié)數(shù)
attemptCount: number; // 當(dāng)前重試次數(shù)
maxAttempts: number; // 最大重試次數(shù)
abortController: AbortController; // 用于取消分片上傳的中止控制器
state: ChunkState; // 當(dāng)前分片狀態(tài)
}
/**
* 文件上傳任務(wù)信息
*/
export interface UploadFileTask {
fileId: string; // 文件唯一標(biāo)識(shí)
fileName: string; // 文件名
fileSize: number; // 文件總大小(字節(jié))
fileHash: string; // 文件哈希值(用于整體校驗(yàn))
chunkSize: number; // 每個(gè)分片的大小(字節(jié))
totalChunks: number; // 總分片數(shù)
chunkTasks: UploadChunkTask[]; // 所有分片任務(wù)數(shù)組
state: FileState; // 文件整體上傳狀態(tài)
isPaused: boolean; // 是否手動(dòng)暫停
inflightChunks: Set<UploadChunkTask>; // 正在上傳中的分片集合
uploadedBytes: number; // 文件級(jí)已上傳字節(jié)數(shù)(累計(jì)所有分片)
totalBytes: number; // 文件級(jí)總字節(jié)數(shù)(等于fileSize)
percent: number; // 文件級(jí)上傳進(jìn)度百分比(0-100)
}
/**
* 全局上傳進(jìn)度信息
*/
export interface GlobalProgress {
uploadedBytes: number; // 全局已上傳字節(jié)數(shù)(累計(jì)所有文件)
totalBytes: number; // 全局總字節(jié)數(shù)(累計(jì)所有文件)
percent: number; // 全局上傳進(jìn)度百分比(0-100)
}
并發(fā)限流器(upload/limiter.ts)
核心目標(biāo):嚴(yán)格限制“所有文件的切片總并發(fā)”不超過上限;支持暫停時(shí)移除某文件的待執(zhí)行項(xiàng)。
/**
* 并發(fā)限制器,用于控制同時(shí)執(zhí)行的任務(wù)數(shù)量。
* 當(dāng)達(dá)到最大并發(fā)數(shù)時(shí),新任務(wù)會(huì)進(jìn)入隊(duì)列等待。
*/
export class ConcurrencyLimiter {
// 最大并發(fā)任務(wù)數(shù)
private maxConcurrent: number;
// 當(dāng)前正在執(zhí)行的任務(wù)數(shù)
private activeCount = 0;
// 等待隊(duì)列,存儲(chǔ)尚未執(zhí)行的任務(wù)
private pendingQueue: Array<{
runTask: () => Promise<unknown>; // 任務(wù)執(zhí)行函數(shù)(返回 Promise)
resolve: (v: unknown) => void; // 任務(wù)成功時(shí)的回調(diào)
reject: (e: unknown) => void; // 任務(wù)失敗時(shí)的回調(diào)
fileId: string; // 任務(wù)關(guān)聯(lián)的文件ID(用于取消)
}> = [];
constructor(maxConcurrent: number) {
this.maxConcurrent = maxConcurrent;
}
/**
* 將任務(wù)加入隊(duì)列,并返回一個(gè) Promise。
* 當(dāng)任務(wù)開始執(zhí)行時(shí),Promise 會(huì)根據(jù)任務(wù)結(jié)果 resolve/reject。
* @param runTask 待執(zhí)行的任務(wù)函數(shù)
* @param fileId 關(guān)聯(lián)的文件ID(用于后續(xù)取消)
*/
enqueue(runTask: () => Promise<unknown>, fileId: string) {
return new Promise((resolve, reject) => {
// 將任務(wù)推入等待隊(duì)列
this.pendingQueue.push({ runTask, resolve, reject, fileId });
// 嘗試啟動(dòng)下一個(gè)任務(wù)(如果未達(dá)到并發(fā)上限)
this.tryStartNext();
});
}
/**
* 根據(jù)文件ID移除隊(duì)列中的待執(zhí)行任務(wù)(例如取消上傳)。
* @param fileId 要移除的任務(wù)關(guān)聯(lián)的文件ID
*/
removePendingByFileId(fileId: string) {
this.pendingQueue = this.pendingQueue.filter(item => item.fileId !== fileId);
}
/**
* 嘗試從隊(duì)列中啟動(dòng)下一個(gè)任務(wù)(如果未達(dá)到并發(fā)上限)。
*/
private tryStartNext() {
// 只要當(dāng)前活躍任務(wù)數(shù)未達(dá)上限且隊(duì)列非空,就繼續(xù)啟動(dòng)任務(wù)
while (this.activeCount < this.maxConcurrent && this.pendingQueue.length > 0) {
const next = this.pendingQueue.shift()!; // 從隊(duì)列頭部取出任務(wù)
this.activeCount++; // 增加活躍任務(wù)計(jì)數(shù)
// 立即執(zhí)行任務(wù)(包裝在 Promise.resolve 中以捕獲同步錯(cuò)誤)
Promise.resolve()
.then(() => next.runTask()) // 執(zhí)行任務(wù)函數(shù)
.then(res => {
this.activeCount--; // 任務(wù)完成,減少活躍計(jì)數(shù)
next.resolve(res); // 通知外部任務(wù)成功
this.tryStartNext(); // 遞歸檢查是否能啟動(dòng)新任務(wù)
})
.catch(err => {
this.activeCount--; // 任務(wù)失敗,減少活躍計(jì)數(shù)
next.reject(err); // 通知外部任務(wù)失敗
this.tryStartNext(); // 遞歸檢查是否能啟動(dòng)新任務(wù)
});
}
}
}
API 層(api/index.ts)
根據(jù)你服務(wù)端接口適配。示例包含:checkFile、uploadChunk、mergeChunk。axios 支持 signal 取消。
import axios from 'axios';
// 秒傳/斷點(diǎn)續(xù)傳 預(yù)檢查
export async function checkFile(params: {
fileHash: string;
fileName: string;
}) {
}
// 上傳切片
export async function uploadChunk(data: FormData, signal: AbortSignal) {
}
// 合并切片
export async function mergeChunk(params: {
fileHash: string;
fileName: string;
chunkSize: number;
}) {
}
Web Worker:切片 + 文件 Hash(worker/hash-worker.js)
- 切片:Blob.slice 按 chunkSize 切分
- 文件 Hash:SparkMD5(按切片依次讀取并 append)
- 返回:fileHash + fileChunkList
// worker/hash-worker.js
self.importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');
/**
* 將文件切割為多個(gè)分片
* @param {File} file - 待分片的文件對(duì)象
* @param {number} chunkSize - 每個(gè)分片的大小(字節(jié))
* @returns {Array<{blob: Blob, size: number}>} 分片數(shù)組,包含每個(gè)分片的Blob對(duì)象和大小
*/
function createFileChunks(file, chunkSize) {
const chunks = [];
let offset = 0; // 當(dāng)前分片的起始字節(jié)位置
// 循環(huán)切割直到覆蓋整個(gè)文件
while (offset < file.size) {
// 使用slice方法切割文件(兼容大文件)
const blob = file.slice(offset, offset + chunkSize);
chunks.push({
blob, // 分片二進(jìn)制數(shù)據(jù)
size: blob.size // 記錄分片實(shí)際大小(最后一片可能小于chunkSize)
});
offset += chunkSize; // 移動(dòng)切割位置
}
return chunks;
}
/**
* 計(jì)算文件哈希(基于所有分片的增量計(jì)算)
* @param {Array<{blob: Blob}>} chunks - 分片數(shù)組
* @returns {Promise<string>} 文件的MD5哈希值
*/
async function calcFileHash(chunks) {
// 使用SparkMD5庫(kù)進(jìn)行增量哈希計(jì)算
const spark = new self.SparkMD5.ArrayBuffer();
let processedCount = 0; // 已處理分片計(jì)數(shù)
for (let i = 0; i < chunks.length; i++) {
const { blob } = chunks[i];
// 將Blob轉(zhuǎn)換為ArrayBuffer進(jìn)行哈希計(jì)算
const buf = await blob.arrayBuffer();
spark.append(buf); // 增量更新哈希
processedCount++;
// 計(jì)算并上報(bào)當(dāng)前進(jìn)度
const percentage = Math.round((processedCount / chunks.length) * 100);
self.postMessage({
type: 'progress',
percentage // 進(jìn)度百分比(0-100)
});
}
return spark.end(); // 返回最終哈希值
}
// WebWorker消息處理器
self.onmessage = async (e) => {
const { file, chunkSize } = e.data; // 從主線程接收的參數(shù)
try {
// 1. 文件分片
const fileChunkList = createFileChunks(file, chunkSize);
// 2. 計(jì)算文件哈希(包含進(jìn)度上報(bào))
const fileHash = await calcFileHash(fileChunkList);
// 處理成功,返回結(jié)果給主線程
self.postMessage({
type: 'done',
fileHash, // 文件完整哈希值
fileChunkList // 分片結(jié)果數(shù)組
});
self.close(); // 關(guān)閉Worker
} catch (err) {
// 錯(cuò)誤處理
self.postMessage({
type: 'error',
error: String(err) // 錯(cuò)誤信息轉(zhuǎn)字符串
});
self.close();
}
};
Vue3 視圖與調(diào)度(views/Uploader.vue)
選擇文件 → 準(zhǔn)備任務(wù) → 秒傳/斷點(diǎn)續(xù)傳 → 全局并發(fā)上傳 → 合并
<template>
<!-- 上傳器主界面 -->
<div class="uploader">
<!-- 文件選擇區(qū)域 -->
<label class="btn">
選擇文件
<input type="file" multiple @change="onSelectFiles" hidden />
</label>
<!-- 上傳控制參數(shù) -->
<div class="controls">
<label>
切片大小(MB):
<input type="number" v-model.number="chunkSizeMB" min="1" max="16" />
</label>
<label>
全局并發(fā):
<input type="number" v-model.number="maxGlobalConcurrency" min="1" max="12" />
</label>
</div>
<!-- 全局進(jìn)度顯示 -->
<div class="global-progress">
全局進(jìn)度:{{ globalProgress.percent }}%({{ formatBytes(globalProgress.uploadedBytes) }} / {{ formatBytes(globalProgress.totalBytes) }})
</div>
<!-- 文件列表展示 -->
<ul class="file-list">
<li v-for="f in fileTasks" :key="f.fileId">
<div class="row">
<div class="name">{{ f.fileName }}</div>
<div class="state">{{ f.state }}</div>
<div class="progress">
{{ f.percent }}%({{ formatBytes(f.uploadedBytes) }} / {{ formatBytes(f.totalBytes) }})
</div>
<div class="actions">
<button v-if="f.state !== 'paused' && f.state !== 'completed'" @click="pauseFileUpload(f.fileId)">暫停</button>
<button v-if="f.state === 'paused'" @click="resumeFileUpload(f.fileId)">恢復(fù)</button>
</div>
</div>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { checkFile, uploadChunk as apiUploadChunk, mergeChunk } from '@/api';
import { ConcurrencyLimiter } from '@/upload/limiter';
import type { UploadFileTask, UploadChunkTask, GlobalProgress } from '@/upload/types';
// 配置參數(shù)
const DEFAULT_CHUNK_SIZE_MB = 1; // 默認(rèn)切片大小1MB
const DEFAULT_MAX_CONCURRENCY = 6; // 默認(rèn)并發(fā)數(shù)6
const chunkSizeMB = ref<number>(DEFAULT_CHUNK_SIZE_MB);
const maxGlobalConcurrency = ref<number>(DEFAULT_MAX_CONCURRENCY);
// 狀態(tài)管理
const fileTasks = reactive<UploadFileTask[]>([]);
const globalProgress = reactive<GlobalProgress>({
uploadedBytes: 0,
totalBytes: 0,
percent: 0
});
// 并發(fā)控制(建議使用工廠模式動(dòng)態(tài)重建)
const limiter = new ConcurrencyLimiter(maxGlobalConcurrency.value);
/* 核心方法 */
/**
* 文件選擇處理
* 1. 獲取文件列表
* 2. 為每個(gè)文件創(chuàng)建上傳任務(wù)
*/
async function onSelectFiles(e: Event) {
const input = e.target as HTMLInputElement;
if (!input?.files?.length) return;
// 并行處理多個(gè)文件(注意瀏覽器并發(fā)限制)
await Promise.all(
Array.from(input.files).map(file =>
prepareAndStartFileTask(file)
)
);
input.value = ''; // 重置input
}
/**
* 文件上傳預(yù)處理(核心流程)
* 1. 創(chuàng)建文件任務(wù)對(duì)象
* 2. 切片+計(jì)算hash(Worker線程)
* 3. 檢查秒傳/斷點(diǎn)續(xù)傳
* 4. 啟動(dòng)上傳調(diào)度
*/
async function prepareAndStartFileTask(file: File) {
// 生成唯一文件ID(實(shí)際項(xiàng)目建議使用更可靠的生成方式)
const fileId = `${file.name}-${file.size}-${Date.now()}`;
const chunkSize = Math.max(1, chunkSizeMB.value) * 1024 * 1024;
// 初始化文件任務(wù)(響應(yīng)式對(duì)象)
const task: UploadFileTask = reactive({
fileId,
fileName: file.name,
fileSize: file.size,
fileHash: '',
chunkSize,
totalChunks: 0,
chunkTasks: [],
state: 'idle',
isPaused: false,
inflightChunks: new Set(),
uploadedBytes: 0,
totalBytes: file.size,
percent: 0
});
fileTasks.push(task); // 立即加入列表顯示
try {
// Step 1: 文件切片+計(jì)算hash(Worker線程)
const { fileHash, chunkTasks } = await createChunksAndHashInWorker(file, chunkSize, fileId);
task.fileHash = fileHash;
task.chunkTasks = chunkTasks.map(ct => reactive(ct)); // 轉(zhuǎn)為響應(yīng)式
task.totalChunks = chunkTasks.length;
// Step 2: 秒傳/斷點(diǎn)檢查
const check = await checkFile({
fileHash: `${fileHash}${file.name}`,
fileName: file.name
});
if (check?.code === 0) {
const { shouldUpload, uploadedList = [] } = check.data || {};
// 秒傳處理
if (!shouldUpload) {
completeFileTask(task);
return;
}
// 斷點(diǎn)續(xù)傳:過濾已上傳切片
if (uploadedList.length > 0) {
task.chunkTasks = task.chunkTasks.filter(ct =>
!uploadedList.includes(ct.chunkHash)
);
// 所有切片已上傳,嘗試合并
if (task.chunkTasks.length === 0) {
await tryMerge(task);
return;
}
}
}
// Step 3: 啟動(dòng)上傳
task.state = 'uploading';
scheduleAllChunks(task);
} catch (err) {
console.error('文件預(yù)處理失敗:', file.name, err);
task.state = 'failed';
}
}
/**
* Web Worker 通信封裝
* 職責(zé):文件切片 + 計(jì)算MD5
*/
function createChunksAndHashInWorker(file: File, chunkSize: number, fileId: string) {
return new Promise<{ fileHash: string; chunkTasks: UploadChunkTask[] }>((resolve, reject) => {
const worker = new Worker(new URL('@/worker/hash-worker.js', import.meta.url));
worker.postMessage({ file, chunkSize });
worker.onmessage = (e: MessageEvent) => {
const { type } = e.data || {};
if (type === 'progress') {
// 可在此處更新hash計(jì)算進(jìn)度
}
else if (type === 'done') {
const { fileHash, fileChunkList } = e.data;
// 構(gòu)建分片任務(wù)數(shù)組
const chunkTasks: UploadChunkTask[] = fileChunkList.map((c: any, index: number) => ({
fileId,
chunkId: `${fileId}-${index}`,
chunkIndex: index,
size: c.size,
blob: c.blob,
chunkHash: `${fileHash}-${index}`,
uploadedBytes: 0,
attemptCount: 0,
maxAttempts: 3,
abortController: new AbortController(),
state: 'idle'
}));
resolve({ fileHash, chunkTasks });
worker.terminate();
}
else if (type === 'error') {
reject(new Error(e.data?.error || 'hash計(jì)算失敗'));
worker.terminate();
}
};
});
}
/**
* 調(diào)度文件的所有分片上傳
* 注意:實(shí)際項(xiàng)目建議使用全局交錯(cuò)調(diào)度(buildInterleavedChunks)
*/
function scheduleAllChunks(fileTask: UploadFileTask) {
fileTask.chunkTasks
.filter(ct => ct.state !== 'completed')
.forEach(ct => enqueueChunkUpload(fileTask, ct));
updateGlobalTotals();
}
/**
* 分片上傳任務(wù)封裝
* 1. 加入并發(fā)隊(duì)列
* 2. 處理重試邏輯
* 3. 完成檢查
*/
function enqueueChunkUpload(fileTask: UploadFileTask, chunkTask: UploadChunkTask) {
// 標(biāo)記為進(jìn)行中
fileTask.inflightChunks.add(chunkTask);
limiter.enqueue(
async () => {
try {
await runWithRetry(
() => uploadOneChunk(fileTask, chunkTask),
chunkTask
);
} finally {
fileTask.inflightChunks.delete(chunkTask);
}
},
fileTask.fileId
).then(async () => {
// 檢查文件是否全部完成
if (isFileUploadComplete(fileTask)) {
await tryMerge(fileTask);
}
}).catch(err => {
console.error('分片上傳失敗:', chunkTask.chunkId, err);
});
}
/**
* 單個(gè)分片上傳實(shí)現(xiàn)
* 關(guān)鍵點(diǎn):
* - 支持取消(AbortController)
* - 進(jìn)度上報(bào)(實(shí)際項(xiàng)目需實(shí)現(xiàn))
*/
async function uploadOneChunk(fileTask: UploadFileTask, chunkTask: UploadChunkTask) {
chunkTask.state = 'uploading';
const formData = new FormData();
formData.append('fileHash', `${fileTask.fileHash}${fileTask.fileName}`);
formData.append('fileName', fileTask.fileName);
formData.append('index', String(chunkTask.chunkIndex));
formData.append('chunkFile', chunkTask.blob);
formData.append('chunkHash', chunkTask.chunkHash);
formData.append('chunkSize', String(fileTask.chunkSize));
formData.append('chunkNumber', String(fileTask.totalChunks));
// 執(zhí)行上傳(帶取消支持)
await apiUploadChunk(formData, chunkTask.abortController.signal);
// 更新狀態(tài)
chunkTask.uploadedBytes = chunkTask.size;
chunkTask.state = 'completed';
// 更新進(jìn)度
updateFileProgress(fileTask);
updateGlobalProgress();
}
/* 上傳控制方法 */
// 暫停上傳
function pauseFileUpload(fileId: string) {
const fileTask = fileTasks.find(f => f.fileId === fileId);
if (!fileTask || fileTask.state === 'completed') return;
fileTask.isPaused = true;
fileTask.state = 'paused';
// 取消隊(duì)列中的任務(wù)
limiter.removePendingByFileId(fileId);
// 中止進(jìn)行中的上傳
fileTask.inflightChunks.forEach(ct => {
ct.abortController.abort();
});
}
// 恢復(fù)上傳
function resumeFileUpload(fileId: string) {
const fileTask = fileTasks.find(f => f.fileId === fileId);
if (!fileTask) return;
fileTask.isPaused = false;
fileTask.state = 'uploading';
// 重置未完成切片的狀態(tài)
fileTask.chunkTasks
.filter(ct => ct.state !== 'completed')
.forEach(ct => {
ct.abortController = new AbortController();
enqueueChunkUpload(fileTask, ct);
});
}
/* 工具方法 */
// 帶重試的執(zhí)行(指數(shù)退避)
async function runWithRetry(
taskFn: () => Promise<unknown>,
chunkTask: UploadChunkTask,
maxAttempts = 3,
baseDelay = 500
) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
chunkTask.attemptCount = attempt;
try {
return await taskFn();
} catch (err: any) {
// 主動(dòng)取消不重試
if (chunkTask.abortController.signal.aborted) throw err;
// 最后一次嘗試失敗
if (attempt === maxAttempts) {
chunkTask.state = 'failed';
throw err;
}
// 延遲重試
await sleep(baseDelay * Math.pow(2, attempt - 1));
}
}
}
// 文件上傳完成檢查
function isFileUploadComplete(fileTask: UploadFileTask) {
return fileTask.chunkTasks.every(c => c.state === 'completed') &&
fileTask.state !== 'paused';
}
// 合并文件請(qǐng)求
async function tryMerge(fileTask: UploadFileTask) {
const res = await mergeChunk({
fileHash: fileTask.fileHash,
fileName: fileTask.fileName,
chunkSize: fileTask.chunkSize
}).catch(() => null);
if (res?.code === 0) {
completeFileTask(fileTask);
} else {
fileTask.state = 'failed';
}
}
// 更新文件進(jìn)度
function updateFileProgress(fileTask: UploadFileTask) {
const uploaded = fileTask.chunkTasks.reduce((s, c) => s + c.uploadedBytes, 0);
fileTask.uploadedBytes = uploaded;
fileTask.percent = Math.round((uploaded / fileTask.totalBytes) * 100);
}
// 更新全局統(tǒng)計(jì)
function updateGlobalTotals() {
globalProgress.totalBytes = fileTasks.reduce((s, f) => s + f.totalBytes, 0);
}
function updateGlobalProgress() {
globalProgress.uploadedBytes = fileTasks.reduce((s, f) => s + f.uploadedBytes, 0);
globalProgress.percent = Math.round(
(globalProgress.uploadedBytes / globalProgress.totalBytes) * 100
);
}
// 標(biāo)記文件上傳完成
function completeFileTask(fileTask: UploadFileTask) {
fileTask.state = 'completed';
fileTask.percent = 100;
fileTask.uploadedBytes = fileTask.totalBytes;
updateGlobalProgress();
}
/* 輔助工具 */
function sleep(ms: number) {
return new Promise(res => setTimeout(res, ms));
}
function formatBytes(n: number) {
if (!n) return '0 B';
const units = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(n)/Math.log(1024));
return `${(n/Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
}
</script>
關(guān)鍵點(diǎn)詳解
- 全局并發(fā)控制優(yōu)先于“按文件分配”
將所有切片視為統(tǒng)一資源池,由 ConcurrencyLimiter 控制啟動(dòng)。
防止多文件時(shí)各自“自增并發(fā)”導(dǎo)致總并發(fā)超標(biāo)。
通過交錯(cuò)入隊(duì)(interleaved)實(shí)現(xiàn)公平性,避免某個(gè)大文件占滿資源。 - 進(jìn)度統(tǒng)計(jì)
切片 onUploadProgress 事件能獲得 e.loaded,但 axios 對(duì)同一請(qǐng)求是累積的,簡(jiǎn)化起見以“完成即視為 size”統(tǒng)計(jì)也可滿足絕大多數(shù) UI 需求。
若需更精確的實(shí)時(shí)數(shù)值,可在 api 層把 e.loaded 透?jìng)魃蠈?,?duì) chunkTask.uploadedBytes 動(dòng)態(tài)賦值并觸發(fā) updateFileProgress / updateGlobalProgress。 - 斷點(diǎn)續(xù)傳與秒傳
chunkHash 建議采用 f i l e H a s h − {fileHash}- fileHash−{chunkIndex},服務(wù)端據(jù)此判斷某切片是否已存在。
文件 Hash 可用 SparkMD5;若需更強(qiáng),考慮 SHA-256(但更耗時(shí))。
秒傳:checkFile 返回 shouldUpload=false 直接完成。 - 暫停 / 恢復(fù)
暫停:removePendingByFileId + abort inflight,一次到位。
恢復(fù):為未完成切片重建 AbortController 并重新入隊(duì)。
注意暫停后不要丟失切片狀態(tài),resume 時(shí)應(yīng)按 chunkIndex 順序重排可選。 - 錯(cuò)誤重試
建議指數(shù)退避:500ms, 1s, 2s…
區(qū)分可重試(網(wǎng)絡(luò)錯(cuò)誤、5xx、超時(shí))與不可重試(4xx 參數(shù)錯(cuò)誤、鑒權(quán)失?。?。
超過最大重試次數(shù)可標(biāo)記文件 failed 并提示用戶。 - 配置與兼容
HTTP/2 多路復(fù)用下“同域6并發(fā)”限制不再剛性,但客戶端/服務(wù)端處理能力仍有限,maxGlobalConcurrency 保持可配置。
Safari 對(duì) fetch 上傳進(jìn)度支持欠佳,推薦 axios/XHR。
chunkSize 一般 1–4MB 權(quán)衡較好;過小請(qǐng)求數(shù)過多,過大重試成本高。
服務(wù)端對(duì)接要點(diǎn)(簡(jiǎn)述)
checkFile(fileHash, fileName)
返回 shouldUpload 和已存在的 uploadedList(切片 hash 列表)。
upload 接口
校驗(yàn)必要字段:fileHash、fileName、index、chunkHash、chunkSize、chunkNumber。
存儲(chǔ)到臨時(shí)目錄:/upload_tmp/{fileHash}/{index}
merge 接口
校驗(yàn)切片數(shù)量完整性與哈希一致性(可選)。
合并為最終文件并清理臨時(shí)目錄。
冪等:重復(fù)合并時(shí)應(yīng)安全返回成功。
小結(jié)
本文給出了一個(gè)基于 Vue3 的大文件上傳完整實(shí)現(xiàn)方案,重點(diǎn)在于:
- 使用 Web Worker 進(jìn)行切片和文件 Hash 計(jì)算,主線程不卡頓;
- 通過全局并發(fā)限流器 ConcurrencyLimiter 嚴(yán)格控制總并發(fā),支持暫停/恢復(fù),且多文件之間相對(duì)公平;
- 利用秒傳與斷點(diǎn)續(xù)傳節(jié)省帶寬與時(shí)間;
- 細(xì)粒度進(jìn)度統(tǒng)計(jì)與指數(shù)退避重試,提升穩(wěn)定性與用戶體驗(yàn)。
到此這篇關(guān)于Vue3實(shí)現(xiàn)大文件上傳:切片上傳 / 斷點(diǎn)續(xù)傳 / 秒傳 / 暫?;謴?fù) / 全局并發(fā)控制的文章就介紹到這了,更多相關(guān)Vue3實(shí)現(xiàn)大文件上傳內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
一文詳解Vue3組件通信輕松玩轉(zhuǎn)復(fù)雜數(shù)據(jù)流
在大型Vue項(xiàng)目中,組件通信如同神經(jīng)網(wǎng)絡(luò)般貫穿整個(gè)應(yīng)用,這篇文章將為大家詳細(xì)介紹一下Vue3中的組件通信方式,有需要的小伙伴可以了解下2025-02-02
vue打包更新packge.json版本號(hào)的全過程
這篇文章主要介紹了vue打包更新packge.json版本號(hào)的全過程,文章通過圖文結(jié)合的方式給大家講解的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2024-08-08
JavaScript的Vue.js庫(kù)入門學(xué)習(xí)教程
Vue的很多思想借鑒于Angular,但卻比較輕量和自由,這里我們整理了JavaScript的Vue.js庫(kù)入門學(xué)習(xí)教程,包括其架構(gòu)思想與核心的數(shù)據(jù)綁定方式等,需要的朋友可以參考下2016-05-05
vue結(jié)合echarts繪制一個(gè)支持切換的折線圖實(shí)例
這篇文章主要介紹了vue結(jié)合echarts繪制一個(gè)支持切換的折線圖實(shí)例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-10-10
Vue項(xiàng)目啟動(dòng)時(shí)的端口占用問題分析與解決方案
你有沒有遇到過這種情況:當(dāng)你滿懷期待地輸入 npm run serve,準(zhǔn)備啟動(dòng) Vue 項(xiàng)目時(shí),突然蹦出一堆紅色錯(cuò)誤信息,其中最顯眼的就是 EADDRINUSE?本文我們就來深入分析這個(gè)問題,并手把手教你如何解決它,需要的朋友可以參考下2025-09-09
jenkins自動(dòng)構(gòu)建發(fā)布vue項(xiàng)目的方法步驟
這篇文章主要介紹了jenkins自動(dòng)構(gòu)建發(fā)布vue項(xiàng)目的方法步驟,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-01-01

