1、前言
在现代Web应用中,大文件上传是一个常见但充满挑战的需求。传统的一次性上传方式在面对大文件时存在诸多问题:网络中断导致重新上传、上传超时、内存占用过高等。本文将详细介绍一套基于Vue3的企业级大文件分片上传解决方案,该方案已在生产环境稳定运行,具备以下核心能力:
- 分片上传:将大文件切分为5MB的小块,降低单次请求压力
- 断点续传:基于IndexedDB持久化上传进度,刷新页面可继续上传
- 并发控制:可配置并发数,平衡上传速度与服务器压力
- 批量上传:支持多文件同时上传,统一管理上传状态
- 任务管理:实时追踪每个上传任务的状态和进度
- 暂停/继续:支持手动控制上传流程
2、技术架构
2.1 核心技术栈
Vue 3 + TypeScript // 前端框架 IndexedDB // 本地数据持久化 hash-wasm // MD5校验(秒传判断) Element Plus // UI组件库 Axios // HTTP请求2.2 架构设计图
┌─────────────┐ │ 用户上传 │ └──────┬──────┘ │ ▼ ┌─────────────────────────┐ │ 文件预处理层 │ │ - 文件切片(5MB) │ │ - MD5计算 │ │ - 去重检查 │ └──────┬──────────────────┘ │ ▼ ┌─────────────────────────┐ │ IndexedDB持久化层 │ │ - 分片信息存储 │ │ - 上传会话管理 │ │ - 断点续传支持 │ └──────┬──────────────────┘ │ ▼ ┌─────────────────────────┐ │ 并发上传控制层 │ │ - 并发队列管理 │ │ - 进度实时更新 │ │ - 错误重试机制 │ └──────┬──────────────────┘ │ ▼ ┌─────────────────────────┐ │ 服务端接口层 │ │ - 初始化上传 │ │ - 分片上传 │ │ - 合并分片 │ └─────────────────────────┘3、核心功能实现
3.1 IndexedDB数据持久化
使用IndexedDB存储分片信息是实现断点续传的关键。我设计了两个主要的对象存储:
// 分片信息结构 interface ChunkInfo { id: string; // 分片唯一标识 fileName: string; // 文件名 fileSize: number; // 文件大小 chunkIndex: number; // 分片索引 uploadId: string; // 上传ID isUploaded: boolean; // 是否已上传 eTag?: string; // 分片ETag(用于合并) totalChunks: number; // 总分片数 firstChunkMd5?: string; // 首片MD5(用于秒传) createdAt: number; // 创建时间 } // 上传会话结构 interface UploadSession { uploadId: string; // 上传ID fileName: string; // 文件名 fileSize: number; // 文件大小 totalChunks: number; // 总分片数 chunksUploaded: number; // 已上传分片数 firstChunkMd5: string; // 首片MD5 isCompleted: boolean; // 是否完成 createdAt: number; // 创建时间 lastUpdated: number; // 最后更新时间 }亮点:
- 使用
fileName + fileSize + firstChunkMd5三元组作为文件唯一标识,实现秒传功能 - 通过
uploadId索引快速检索分片信息 - 记录
lastUpdated时间戳,支持清理过期上传任务
3.2 智能断点续传
断点续传的实现逻辑:
// 1. 查找已存在的上传会话 const existingSession = await findExistingUpload( file.name, fileSize, firstChunkMd5 ); if (existingSession) { // 2. 恢复上传会话 uploadId = existingSession.uploadId; uploadedChunks = await getChunksByUploadId(uploadId); console.log(`继续上传 ${file.name}`); } else { // 3. 创建新上传会话 const resp = await multipartUpload({ ossStatus: 'initiate', originalName: file.name, md5Digest: firstChunkMd5 }); uploadId = resp.data.uploadId; }实现要点:
- 首次上传时计算首片MD5,后续上传通过MD5匹配历史会话
- 已上传的分片直接跳过,只上传未完成的分片
- 支持跨浏览器会话的断点续传
3.3 并发控制策略
采用批量并发上传策略,既保证上传速度,又避免过载服务器:
// 并发上传核心逻辑 const uploadChunksConcurrently = async ( chunks: Blob[], uploadId: string, // ... 其他参数 options: UploadOptions = {} ) => { const { concurrency = 3 } = options; // 默认并发数为3 const pendingChunks = []; // 待上传分片索引 // 1. 过滤已上传的分片 for (let i = 0; i < totalChunks; i++) { if (!partUploadList[i]) { pendingChunks.push(i); } } // 2. 分批并发上传 for (let i = 0; i < pendingChunks.length; i += concurrency) { const batch = pendingChunks.slice(i, i + concurrency); // 3. Promise.all 并发执行 const batchPromises = batch.map(chunkIndex => uploadChunk(chunks[chunkIndex], chunkIndex, ...) ); const results = await Promise.all(batchPromises); // 4. 更新进度 await updateProgress(uploadId, completed, totalChunks); } };性能优化:
- 默认并发数为3,可根据网络环境动态调整
- 使用
Promise.all确保一批分片全部完成后再进行下一批 - 实时更新进度条,提升用户体验
3.4 批量文件上传
支持多文件同时上传,每个文件独立管理:
const handleBatchFileUpload = async ( files: File[], options: UploadOptions = {} ) => { const uploadPromises = files.map(async (file) => { try { // 每个文件独立上传 const result = await handleFileUpload(file, { ...options, onProgressUpdate: (progress) => { // 更新单文件进度 batchUploadResults.value.set(file.name, { progress, status: 'uploading' }); // 计算总体进度 const totalProgress = calculateTotalProgress(); options.onProgressUpdate?.(totalProgress); } }); return { success: true, file, result }; } catch (error) { return { success: false, file, error }; } }); // 等待所有文件上传完成 const results = await Promise.all(uploadPromises); return results; };功能特性:
- 每个文件使用
fileName + fileSize作为唯一标识 - 支持部分文件失败时的错误处理
- 提供统一的批量进度管理
3.5 文件去重与状态管理
使用Map结构实现文件级别的状态管理,防止重复上传:
// 文件级别状态管理 const fileUploadStates = ref<Map<string, boolean>>(new Map()); const isFileUploading = (fileKey: string) => { return fileUploadStates.value.get(fileKey) === true; }; const handleFileUpload = async (file: File) => { const fileKey = `${file.name}_${file.size}`; // 防止重复上传 if (isFileUploading(fileKey)) { throw new Error('文件已在上传中'); } setFileUploadState(fileKey, true); try { // 执行上传... } finally { // 清除状态 setFileUploadState(fileKey, false); } };4、上传任务管理
4.1 任务状态追踪
实现了完整的任务生命周期管理:
interface UploadTaskStatus { uploadId: string; fileName: string; progress: number; status: 'uploading' | 'paused' | 'completed' | 'error'; file: File; } // 任务状态存储 const uploadTasks = ref<Map<string, UploadTaskStatus>>(new Map()); // 添加任务 const addUploadTask = (uploadId: string, file: File) => { uploadTasks.value.set(uploadId, { uploadId, fileName: file.name, progress: 0, status: 'uploading', file }); }; // 更新进度 const updateTaskProgress = (uploadId: string, progress: number) => { const task = uploadTasks.value.get(uploadId); if (task) { task.progress = progress; uploadTasks.value.set(uploadId, { ...task }); } };4.2 任务清理机制
灵活的任务清理功能:
// 清除指定任务 const clearUploadTask = async (uploadId: string) => { const db = await initDB(); const transaction = db.transaction( ['upload_sessions', 'upload_chunks'], 'readwrite' ); // 删除上传会话 transaction.objectStore('upload_sessions').delete(uploadId); // 删除相关分片 const chunkStore = transaction.objectStore('upload_chunks'); const index = chunkStore.index('uploadId'); const request = index.openKeyCursor(IDBKeyRange.only(uploadId)); request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { chunkStore.delete(cursor.primaryKey); cursor.continue(); } }; }; // 清除所有任务 const clearAllUploadData = async () => { const db = await initDB(); const transaction = db.transaction( ['upload_sessions', 'upload_chunks'], 'readwrite' ); transaction.objectStore('upload_sessions').clear(); transaction.objectStore('upload_chunks').clear(); };5、使用示例
5.1 单文件上传
<template> <div class="upload-container"> <input type="file" @change="handleFileChange" accept="video/*" /> <el-progress :percentage="percent" :status="uploadStatus" /> <div class="actions"> <el-button @click="pauseCurrentUpload">暂停</el-button> <el-button @click="continueCurrentUpload">继续</el-button> </div> </div> </template> <script setup lang="ts"> import { useFileUpload } from '@/composables/useFileUpload'; const { percent, uploadStatus, handleFileUpload, pauseCurrentUpload, continueCurrentUpload } = useFileUpload(); const handleFileChange = async (event: Event) => { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; try { const result = await handleFileUpload(file, { concurrency: 5, // 并发数 onProgressUpdate: (progress) => { console.log(`上传进度: ${progress}%`); }, onChunkComplete: (index, total) => { console.log(`分片 ${index + 1}/${total} 完成`); } }); console.log('上传成功:', result); } catch (error) { console.error('上传失败:', error); } }; </script>5.2 批量上传
<template> <div class="batch-upload"> <input type="file" multiple @change="handleBatchChange" /> <div class="file-list"> <div v-for="item in batchProgress" :key="item.fileName" class="file-item" > <span>{{ item.fileName }}</span> <el-progress :percentage="item.progress" /> <span>{{ item.status }}</span> </div> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue'; import { useFileUpload } from '@/composables/useFileUpload'; const { handleBatchFileUpload, getBatchUploadProgress } = useFileUpload(); const batchProgress = ref([]); const handleBatchChange = async (event: Event) => { const files = Array.from( (event.target as HTMLInputElement).files || [] ); const { successResults, failedResults } = await handleBatchFileUpload( files, { concurrency: 3, onProgressUpdate: (progress) => { batchProgress.value = getBatchUploadProgress(); } } ); console.log(`成功: ${successResults.length}`); console.log(`失败: ${failedResults.length}`); }; </script>5.3 断点续传管理
<template> <div class="resume-uploads"> <h3>未完成的上传</h3> <div v-for="session in incompleteUploads" :key="session.uploadId" > <span>{{ session.fileName }}</span> <span>{{ session.chunksUploaded }}/{{ session.totalChunks }}</span> <el-button @click="resumeUpload(session)">继续</el-button> <el-button @click="clearTask(session.uploadId)">删除</el-button> </div> <el-button @click="clearAll">清除所有</el-button> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { useFileUpload } from '@/composables/useFileUpload'; const { getIncompleteUploads, clearUploadTask, clearAllUploadData } = useFileUpload(); const incompleteUploads = ref([]); onMounted(async () => { incompleteUploads.value = await getIncompleteUploads(); }); const resumeUpload = async (session) => { // 实现继续上传逻辑 }; const clearTask = async (uploadId: string) => { await clearUploadTask(uploadId); incompleteUploads.value = await getIncompleteUploads(); }; const clearAll = async () => { await clearAllUploadData(); incompleteUploads.value = []; }; </script>6、性能优化建议
6.1 分片大小选择
// 根据文件大小动态调整分片大小 const getOptimalChunkSize = (fileSize: number) => { if (fileSize < 50 * 1024 * 1024) { return 2 * 1024 * 1024; // 小于50MB,使用2MB } else if (fileSize < 500 * 1024 * 1024) { return 5 * 1024 * 1024; // 小于500MB,使用5MB } else { return 10 * 1024 * 1024; // 大于500MB,使用10MB } };6.2 并发数动态调整
// 根据网络状况调整并发数 const adjustConcurrency = (networkSpeed: number) => { if (networkSpeed > 10) { return 6; // 高速网络 } else if (networkSpeed > 5) { return 4; // 中速网络 } else { return 2; // 低速网络 } };6.3 内存优化
// 使用流式读取,避免一次性加载整个文件 const readChunkAsStream = async (file: File, start: number, end: number) => { const chunk = file.slice(start, end); return chunk; // 返回Blob,按需读取 };6.4 IndexedDB清理策略
// 定期清理过期上传任务(7天) const cleanExpiredSessions = async () => { const sessions = await getIncompleteUploads(); const now = Date.now(); const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; for (const session of sessions) { if (now - session.lastUpdated > SEVEN_DAYS) { await clearUploadTask(session.uploadId); } } };7、注意事项与最佳实践
7.1 错误处理
// 完善的错误处理机制 try { await handleFileUpload(file); } catch (error) { if (error.uploadId) { // 清理失败的上传任务 await clearUploadTask(error.uploadId); } // 根据错误类型提示用户 if (error.message.includes('网络')) { ElMessage.error('网络错误,请检查网络连接'); } else if (error.message.includes('空间')) { ElMessage.error('存储空间不足'); } else { ElMessage.error('上传失败,请重试'); } }7.2 安全性考虑
// 文件类型校验 const validateFile = (file: File) => { const allowedTypes = ['video/mp4', 'video/avi', 'video/mov']; if (!allowedTypes.includes(file.type)) { throw new Error('不支持的文件类型'); } // 文件大小限制(5GB) const MAX_SIZE = 5 * 1024 * 1024 * 1024; if (file.size > MAX_SIZE) { throw new Error('文件大小超过限制'); } };7.3 用户体验优化
// 显示友好的进度提示 const getProgressText = (progress: number, fileName: string) => { if (progress === 0) { return `准备上传 ${fileName}...`; } else if (progress < 100) { return `正在上传 ${fileName} (${progress}%)`; } else { return `${fileName} 上传完成`; } }; // 剩余时间估算 const estimateRemainingTime = ( uploadedBytes: number, totalBytes: number, startTime: number ) => { const elapsed = Date.now() - startTime; const speed = uploadedBytes / elapsed; const remaining = (totalBytes - uploadedBytes) / speed; return Math.ceil(remaining / 1000); // 返回秒数 };8、监控与日志
8.1 上传统计
// 收集上传统计数据 interface UploadStats { totalFiles: number; successCount: number; failureCount: number; totalBytes: number; averageSpeed: number; averageTime: number; } const collectStats = (results: any[]) => { return { totalFiles: results.length, successCount: results.filter(r => r.success).length, failureCount: results.filter(r => !r.success).length, totalBytes: results.reduce((sum, r) => sum + r.file.size, 0), // ... 其他统计 }; };8.2 日志记录
// 结构化日志 const logUploadEvent = (event: string, data: any) => { console.log(`[Upload ${event}]`, { timestamp: new Date().toISOString(), ...data }); }; // 使用示例 logUploadEvent('START', { fileName, fileSize, uploadId }); logUploadEvent('CHUNK_COMPLETE', { chunkIndex, totalChunks }); logUploadEvent('SUCCESS', { fileName, duration });9、总结
本文介绍的大文件分片上传方案具有以下优势:
- 高可靠性:基于IndexedDB的断点续传,确保上传不会因刷新而中断
- 高性能:并发控制+分片上传,充分利用网络带宽
- 易扩展:模块化设计,方便集成到现有项目
- 用户友好:实时进度反馈,支持暂停/继续操作
- 企业级:完善的错误处理、日志记录和监控机制
该方案已在生产环境验证,可处理GB级别的大文件上传,适用于视频上传、大文件传输等场景。
10、参考资源
- IndexedDB API 文档
- Vue3 Composition API
- Axios 官方文档
- Element Plus 组件库
11、后续优化方向
- WebWorker 优化:将MD5计算移至Worker线程,避免阻塞主线程
- 预签名URL:使用预签名URL直传OSS,减少服务器压力
- 智能重试:指数退避算法实现失败分片的智能重试
- 压缩上传:在上传前对文件进行压缩,节省带宽
- 秒传功能:完整文件MD5校验,实现真正的秒传