Vue3大文件上传终极解决方案
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Vue3 大文件上传组件 - 最终修复版</title><script src="https://unpkg.com/vue@3/dist/vue.global.js"></script><style>* {margin: 0;padding: 0;box-sizing: border-box;font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;}body {background-color: #f5f7fa;color: #333;line-height: 1.6;padding: 20px;}.container {max-width: 1200px;margin: 0 auto;}.upload-container {background: white;border-radius: 12px;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);padding: 30px;margin-bottom: 30px;}h1 {text-align: center;margin-bottom: 30px;color: #2c3e50;}.upload-area {border: 2px dashed #3498db;border-radius: 8px;padding: 40px 20px;text-align: center;cursor: pointer;transition: all 0.3s ease;margin-bottom: 20px;position: relative;}.upload-area:hover {background-color: #f8fafc;border-color: #2980b9;}.upload-area.dragover {background-color: #e3f2fd;border-color: #1e88e5;}.upload-icon {font-size: 48px;color: #3498db;margin-bottom: 15px;}.file-input {position: absolute;width: 100%;height: 100%;top: 0;left: 0;opacity: 0;cursor: pointer;}.file-list {margin-top: 20px;}.file-item {display: flex;align-items: center;padding: 15px;border: 1px solid #e1e8ed;border-radius: 8px;margin-bottom: 10px;background: #fafbfc;}.file-icon {font-size: 24px;color: #3498db;margin-right: 15px;}.file-info {flex: 1;}.file-name {font-weight: 600;margin-bottom: 5px;}.file-size {color: #7f8c8d;font-size: 0.9rem;}.file-status {margin-left: 15px;font-size: 0.9rem;padding: 4px 10px;border-radius: 20px;font-weight: 600;}.status-pending {background: #fff3cd;color: #856404;}.status-uploading {background: #d1ecf1;color: #0c5460;}.status-completed {background: #d4edda;color: #155724;}.status-error {background: #f8d7da;color: #721c24;}.progress-container {margin-top: 10px;}.progress-bar {height: 8px;background: #e9ecef;border-radius: 4px;overflow: hidden;}.progress-fill {height: 100%;background: #3498db;border-radius: 4px;transition: width 0.3s ease;}.progress-text {display: flex;justify-content: space-between;margin-top: 5px;font-size: 0.85rem;color: #6c757d;}.controls {display: flex;gap: 10px;margin-top: 10px;}button {padding: 8px 16px;border: none;border-radius: 6px;cursor: pointer;font-weight: 600;transition: all 0.3s ease;font-size: 0.9rem;}.btn-primary {background: #3498db;color: white;}.btn-success {background: #2ecc71;color: white;}.btn-danger {background: #e74c3c;color: white;}.btn-warning {background: #f39c12;color: white;}button:hover {opacity: 0.9;transform: translateY(-2px);}button:disabled {background: #bdc3c7;cursor: not-allowed;transform: none;}.chunk-info {margin-top: 10px;font-size: 0.8rem;color: #7f8c8d;}.stats {display: grid;grid-template-columns: repeat(3, 1fr);gap: 15px;margin-top: 20px;}.stat-card {background: #f8f9fa;padding: 15px;border-radius: 8px;text-align: center;border-left: 4px solid #3498db;}.stat-value {font-size: 1.5rem;font-weight: bold;margin-bottom: 5px;}.stat-label {font-size: 0.9rem;color: #7f8c8d;}.connection-status {display: flex;align-items: center;gap: 8px;margin-bottom: 15px;padding: 10px;border-radius: 6px;background: #f8f9fa;}.status-indicator {width: 10px;height: 10px;border-radius: 50%;background: #e74c3c;}.status-indicator.connected {background: #2ecc71;animation: pulse 2s infinite;}@keyframes pulse {0% { opacity: 1; }50% { opacity: 0.5; }100% { opacity: 1; }}.worker-status {display: flex;align-items: center;gap: 5px;margin-top: 5px;font-size: 0.8rem;color: #7f8c8d;}.worker-active {color: #2ecc71;}.worker-idle {color: #f39c12;}.logs-container {margin-top: 30px;}.logs {background: #2c3e50;color: #ecf0f1;padding: 15px;border-radius: 8px;font-family: 'Courier New', monospace;font-size: 0.85rem;max-height: 200px;overflow-y: auto;line-height: 1.4;}.log-entry {margin-bottom: 5px;}.log-time {color: #3498db;}.log-level-info {color: #2ecc71;}.log-level-warn {color: #f39c12;}.log-level-error {color: #e74c3c;}.config-section {margin-top: 20px;padding-top: 20px;border-top: 1px solid #e1e8ed;}.config-item {margin-bottom: 15px;}label {display: block;margin-bottom: 5px;font-weight: 600;font-size: 0.9rem;}input, select {width: 100%;padding: 10px;border: 1px solid #ddd;border-radius: 6px;font-size: 0.9rem;}.checkbox-group {display: flex;gap: 15px;}.checkbox-item {display: flex;align-items: center;gap: 5px;}</style>
</head>
<body><div id="app"><div class="container"><h1>Vue3 大文件上传组件 - 最终修复版</h1><div class="upload-container"><div class="connection-status"><div class="status-indicator" :class="{ connected: isConnected }"></div><span>{{ isConnected ? 'WebSocket 已连接' : 'WebSocket 未连接' }}</span></div><!-- 修复问题1: 阻止事件冒泡 --><div class="upload-area" @click="triggerFileInput"@drop="handleDrop"@dragover="handleDragOver"@dragleave="handleDragLeave":class="{ dragover: isDragOver }"><div class="upload-icon">📁</div><h3>拖放文件到此处或点击选择文件</h3><p>支持大文件上传,断点续传和切片上传</p><!-- 添加stop修饰符阻止事件冒泡 --><input type="file" class="file-input" @change="handleFileSelect" @click.stop multiple ref="fileInput"></div><div class="stats"><div class="stat-card"><div class="stat-value">{{ fileList.length }}</div><div class="stat-label">文件总数</div></div><div class="stat-card"><div class="stat-value">{{ completedFiles }}</div><div class="stat-label">已完成</div></div><div class="stat-card"><div class="stat-value">{{ failedFiles }}</div><div class="stat-label">失败</div></div></div><div class="file-list"><div class="file-item" v-for="file in fileList" :key="file.id"><div class="file-icon">📄</div><div class="file-info"><div class="file-name">{{ file.name }}</div><div class="file-size">{{ formatFileSize(file.size) }}</div><div class="progress-container" v-if="file.status !== 'pending'"><div class="progress-bar"><div class="progress-fill" :style="{ width: Math.min(file.progress, 100) + '%' }"></div></div><div class="progress-text"><span>{{ Math.min(file.progress, 100).toFixed(1) }}%</span><span>{{ formatFileSize(file.uploadedSize) }} / {{ formatFileSize(file.size) }}</span></div></div><div class="chunk-info" v-if="file.chunks">切片: {{ file.uploadedChunks }}/{{ file.totalChunks }}</div><div class="worker-status" v-if="file.workerStatus"><span>Worker状态:</span><span :class="file.workerStatus === 'processing' ? 'worker-active' : 'worker-idle'">{{ file.workerStatus === 'processing' ? '处理中' : '空闲' }}</span></div></div><div class="file-status" :class="'status-' + file.status">{{ getStatusText(file.status) }}</div><div class="controls" v-if="file.status !== 'completed'"><button class="btn-primary" @click="startUpload(file)":disabled="file.status === 'uploading' || file.status === 'processing' || file.status === 'completed'">{{ file.status === 'paused' ? '继续' : '开始' }}</button><button class="btn-warning" @click="pauseUpload(file)":disabled="file.status !== 'uploading'">暂停</button><button class="btn-danger" @click="removeFile(file)">删除</button></div><div class="controls" v-else><button class="btn-success" disabled>已完成</button><button class="btn-danger" @click="removeFile(file)">删除</button></div></div></div><div class="config-section"><h3>上传配置</h3><div class="config-item"><label for="chunk-size">切片大小 (KB)</label><input type="number" id="chunk-size" v-model.number="config.chunkSize" min="100" max="10240"></div><div class="config-item"><label for="max-concurrent">最大并发数</label><input type="number" id="max-concurrent" v-model.number="config.maxConcurrent" min="1" max="10"></div><div class="config-item"><label for="retry-count">最大重试次数</label><input type="number" id="retry-count" v-model.number="config.maxRetry" min="0" max="10"></div><div class="checkbox-group"><div class="checkbox-item"><input type="checkbox" id="use-worker" v-model="config.useWebWorker"><label for="use-worker">使用Web Worker</label></div><div class="checkbox-item"><input type="checkbox" id="enable-indexeddb" v-model="config.enableIndexedDB"><label for="enable-indexeddb">启用IndexedDB存储</label></div></div></div></div><div class="logs-container"><h3>上传日志</h3><div class="logs"><div class="log-entry" v-for="log in logs" :key="log.id"><span class="log-time">[{{ formatTime(log.timestamp) }}]</span><span :class="'log-level-' + log.level">[{{ log.level.toUpperCase() }}]</span><span>{{ log.message }}</span></div></div></div></div></div><script>const { createApp, ref, reactive, computed, onMounted, onUnmounted } = Vue;// Web Worker代码 (内联)const workerCode = `self.onmessage = function(e) {const { file, chunkSize, fileId } = e.data;const totalChunks = Math.ceil(file.size / chunkSize);const chunks = [];// 模拟计算文件哈希 (在实际应用中应使用更复杂的哈希算法)let hash = '';for (let i = 0; i < Math.min(16, file.size); i++) {hash += file.size % 256;}// 切片处理for (let i = 0; i < totalChunks; i++) {const start = i * chunkSize;const end = Math.min(start + chunkSize, file.size);chunks.push({index: i,start,end,hash: hash + '-' + i,file: fileId,uploaded: false,uploading: false,retryCount: 0});}self.postMessage({fileId,totalChunks,chunks,hash});};`;createApp({setup() {// 响应式数据const fileInput = ref(null);const isDragOver = ref(false);const isConnected = ref(false);// 配置const config = reactive({chunkSize: 500, // KBmaxConcurrent: 3,maxRetry: 3,useWebWorker: true,enableIndexedDB: true,uploadUrl: 'https://api.example.com/upload'});// 文件列表const fileList = ref([]);const logs = ref([]);// WebSocket连接let ws = null;// 计算属性const completedFiles = computed(() => {return fileList.value.filter(file => file.status === 'completed').length;});const failedFiles = computed(() => {return fileList.value.filter(file => file.status === 'error').length;});// 方法const triggerFileInput = () => {fileInput.value.click();};const handleFileSelect = (event) => {const files = Array.from(event.target.files);files.forEach(file => addFile(file));// 修复问题1: 重置input值,避免重复触发event.target.value = '';};const handleDrop = (event) => {event.preventDefault();isDragOver.value = false;const files = Array.from(event.dataTransfer.files);files.forEach(file => addFile(file));};const handleDragOver = (event) => {event.preventDefault();isDragOver.value = true;};const handleDragLeave = (event) => {event.preventDefault();isDragOver.value = false;};const addFile = (file) => {const fileId = generateId();const fileItem = {id: fileId,name: file.name,size: file.size,file: file,status: 'pending',progress: 0,uploadedSize: 0,uploadedChunks: 0,totalChunks: 0,chunks: [],workerStatus: 'idle',retryCount: 0,activeUploads: 0, // 跟踪当前正在上传的切片数pendingChunks: [], // 待上传的切片队列uploadTimers: new Map(), // 使用Map存储上传计时器,key为chunk.indexisCompleted: false // 明确标记文件是否已完成};fileList.value.push(fileItem);log(`文件 "${file.name}" 已添加`, 'info');// 检查IndexedDB中是否有未完成的记录if (config.enableIndexedDB) {checkStoredFile(fileItem);}};const removeFile = (file) => {const index = fileList.value.findIndex(f => f.id === file.id);if (index !== -1) {// 清除所有相关的上传计时器if (file.uploadTimers && file.uploadTimers.size > 0) {file.uploadTimers.forEach((timerId, chunkIndex) => {clearTimeout(timerId);});file.uploadTimers.clear();}fileList.value.splice(index, 1);log(`文件 "${file.name}" 已移除`, 'info');// 从IndexedDB中删除记录if (config.enableIndexedDB) {removeFileFromIndexedDB(file.id);}}};const startUpload = async (file) => {// 修复问题4: 防止已完成的文件重新上传if (file.status === 'uploading' || file.status === 'processing' || file.status === 'completed' || file.isCompleted) {return;}file.status = 'processing';file.workerStatus = 'processing';log(`开始处理文件 "${file.name}"`, 'info');try {// 使用Web Worker进行切片处理if (config.useWebWorker) {await processFileWithWorker(file);} else {await processFile(file);}// 初始化待上传队列 - 只包含未上传的切片file.pendingChunks = file.chunks.filter(chunk => !chunk.uploaded);// 开始上传切片startChunkUpload(file);} catch (error) {file.status = 'error';file.workerStatus = 'idle';log(`文件处理失败: ${error.message}`, 'error');}};const processFileWithWorker = (file) => {return new Promise((resolve, reject) => {const blob = new Blob([workerCode], { type: 'application/javascript' });const workerUrl = URL.createObjectURL(blob);const worker = new Worker(workerUrl);worker.postMessage({file: file.file,chunkSize: config.chunkSize * 1024,fileId: file.id});worker.onmessage = (e) => {const { fileId, totalChunks, chunks, hash } = e.data;if (fileId === file.id) {file.totalChunks = totalChunks;file.chunks = chunks;file.hash = hash;file.workerStatus = 'idle';log(`文件 "${file.name}" 切片完成,共 ${totalChunks} 个切片`, 'info');// 存储到IndexedDBif (config.enableIndexedDB) {storeFileToIndexedDB(file);}worker.terminate();URL.revokeObjectURL(workerUrl);resolve();}};worker.onerror = (error) => {log(`Worker处理错误: ${error.message}`, 'error');worker.terminate();URL.revokeObjectURL(workerUrl);reject(error);};});};const processFile = (file) => {return new Promise((resolve) => {// 模拟处理时间setTimeout(() => {const totalChunks = Math.ceil(file.size / (config.chunkSize * 1024));const chunks = [];let hash = '';// 简单哈希计算for (let i = 0; i < Math.min(16, file.size); i++) {hash += file.size % 256;}// 模拟切片for (let i = 0; i < totalChunks; i++) {const start = i * config.chunkSize * 1024;const end = Math.min(start + config.chunkSize * 1024, file.size);chunks.push({index: i,start,end,hash: hash + '-' + i,file: file.id,uploaded: false,uploading: false,retryCount: 0});}file.totalChunks = totalChunks;file.chunks = chunks;file.hash = hash;file.workerStatus = 'idle';log(`文件 "${file.name}" 切片完成,共 ${totalChunks} 个切片`, 'info');// 存储到IndexedDBif (config.enableIndexedDB) {storeFileToIndexedDB(file);}resolve();}, 1000);});};const startChunkUpload = (file) => {// 检查文件是否已完成if (file.isCompleted || file.status === 'completed') {return;}file.status = 'uploading';// 获取未上传且未在传输中的切片const availableChunks = file.pendingChunks.filter(chunk => !chunk.uploaded && !chunk.uploading);if (availableChunks.length === 0) {// 检查是否全部完成if (file.uploadedChunks === file.totalChunks) {completeFileUpload(file);}return;}// 限制并发数,考虑当前正在上传的切片const availableSlots = config.maxConcurrent - file.activeUploads;const chunksToUpload = availableChunks.slice(0, Math.max(0, availableSlots));chunksToUpload.forEach(chunk => {chunk.uploading = true;file.activeUploads++;uploadChunk(file, chunk);});};const uploadChunk = async (file, chunk) => {// 关键修复: 在上传前再次检查文件状态和切片状态if (file.isCompleted || file.status === 'completed' || chunk.uploaded) {chunk.uploading = false;file.activeUploads--;return;}try {// 模拟上传const uploadTime = Math.random() * 2000 + 500; // 500-2500msconst timerId = setTimeout(() => {// 关键修复: 在回调中再次检查状态if (file.isCompleted || file.status === 'completed' || chunk.uploaded) {chunk.uploading = false;file.activeUploads--;file.uploadTimers.delete(chunk.index);return;}// 模拟90%成功率if (Math.random() > 0.1) {// 上传成功chunk.uploaded = true;chunk.uploading = false;file.activeUploads--;file.uploadedChunks++;file.uploadedSize = Math.min(file.uploadedSize + (chunk.end - chunk.start), file.size);file.progress = (file.uploadedSize / file.size) * 100;// 从待上传队列中移除const chunkIndex = file.pendingChunks.findIndex(c => c.index === chunk.index);if (chunkIndex !== -1) {file.pendingChunks.splice(chunkIndex, 1);}// 从计时器Map中移除file.uploadTimers.delete(chunk.index);log(`文件 "${file.name}" 切片 ${chunk.index + 1}/${file.totalChunks} 上传成功`, 'info');// 更新IndexedDBif (config.enableIndexedDB) {storeFileToIndexedDB(file);}// 检查是否全部完成if (file.uploadedChunks === file.totalChunks && !file.isCompleted) {completeFileUpload(file);} else {// 继续上传下一个切片startChunkUpload(file);}} else {// 上传失败chunk.uploading = false;file.activeUploads--;chunk.retryCount++;// 从计时器Map中移除file.uploadTimers.delete(chunk.index);if (chunk.retryCount <= config.maxRetry) {log(`文件 "${file.name}" 切片 ${chunk.index + 1} 上传失败,第 ${chunk.retryCount} 次重试`, 'warn');setTimeout(() => {// 重新加入上传队列 - 只有在未完成的情况下if (!file.isCompleted && !file.pendingChunks.some(c => c.index === chunk.index) && !chunk.uploaded) {file.pendingChunks.push(chunk);}startChunkUpload(file);}, 1000);} else {file.status = 'error';log(`文件 "${file.name}" 切片 ${chunk.index + 1} 上传失败,已达最大重试次数`, 'error');}}}, uploadTime);// 保存计时器ID到Map中,key为chunk.indexfile.uploadTimers.set(chunk.index, timerId);} catch (error) {// 上传失败chunk.uploading = false;file.activeUploads--;chunk.retryCount++;if (chunk.retryCount <= config.maxRetry) {log(`文件 "${file.name}" 切片 ${chunk.index + 1} 上传失败,第 ${chunk.retryCount} 次重试`, 'warn');setTimeout(() => {// 重新加入上传队列if (!file.isCompleted && !file.pendingChunks.some(c => c.index === chunk.index) && !chunk.uploaded) {file.pendingChunks.push(chunk);}startChunkUpload(file);}, 1000);} else {file.status = 'error';log(`文件 "${file.name}" 切片 ${chunk.index + 1} 上传失败,已达最大重试次数`, 'error');}}};const completeFileUpload = (file) => {// 关键修复: 标记文件为已完成,并清除所有计时器file.isCompleted = true;file.status = 'completed';file.progress = 100;file.activeUploads = 0;// 清除所有上传计时器if (file.uploadTimers && file.uploadTimers.size > 0) {file.uploadTimers.forEach((timerId, chunkIndex) => {clearTimeout(timerId);});file.uploadTimers.clear();}// 确保所有切片标记为已上传file.chunks.forEach(chunk => {chunk.uploaded = true;chunk.uploading = false;});file.uploadedChunks = file.totalChunks;file.uploadedSize = file.size;log(`文件 "${file.name}" 上传完成`, 'info');// 从IndexedDB中删除记录if (config.enableIndexedDB) {removeFileFromIndexedDB(file.id);}// 发送WebSocket通知if (ws && ws.readyState === WebSocket.OPEN) {ws.send(JSON.stringify({type: 'file_completed',fileId: file.id,fileName: file.name}));}};const pauseUpload = (file) => {// 修复问题3: 真正暂停上传file.status = 'paused';// 清除所有上传计时器if (file.uploadTimers && file.uploadTimers.size > 0) {file.uploadTimers.forEach((timerId, chunkIndex) => {clearTimeout(timerId);});file.uploadTimers.clear();}// 停止所有正在上传的切片file.chunks.forEach(chunk => {chunk.uploading = false;});file.activeUploads = 0;log(`文件 "${file.name}" 上传已暂停`, 'info');};const resumeUpload = (file) => {if (file.isCompleted) {return;}file.status = 'uploading';startChunkUpload(file);log(`文件 "${file.name}" 上传已恢复`, 'info');};// IndexedDB相关方法const openIndexedDB = () => {return new Promise((resolve, reject) => {const request = indexedDB.open('FileUploadDB', 1);request.onerror = () => reject(request.error);request.onsuccess = () => resolve(request.result);request.onupgradeneeded = (event) => {const db = event.target.result;if (!db.objectStoreNames.contains('files')) {const store = db.createObjectStore('files', { keyPath: 'id' });store.createIndex('name', 'name', { unique: false });}};});};const storeFileToIndexedDB = async (file) => {if (!config.enableIndexedDB) return;try {const db = await openIndexedDB();const transaction = db.transaction(['files'], 'readwrite');const store = transaction.objectStore('files');// 只存储必要的信息,不存储文件本身const fileData = {id: file.id,name: file.name,size: file.size,hash: file.hash,totalChunks: file.totalChunks,uploadedChunks: file.uploadedChunks,uploadedSize: file.uploadedSize,progress: file.progress,status: file.status,isCompleted: file.isCompleted,chunks: file.chunks.map(chunk => ({index: chunk.index,uploaded: chunk.uploaded,retryCount: chunk.retryCount}))};store.put(fileData);} catch (error) {log(`存储到IndexedDB失败: ${error.message}`, 'error');}};const checkStoredFile = async (file) => {if (!config.enableIndexedDB) return;try {const db = await openIndexedDB();const transaction = db.transaction(['files'], 'readonly');const store = transaction.objectStore('files');const request = store.get(file.id);request.onsuccess = () => {const storedFile = request.result;if (storedFile) {// 恢复文件状态file.totalChunks = storedFile.totalChunks;file.uploadedChunks = storedFile.uploadedChunks;file.uploadedSize = storedFile.uploadedSize;file.progress = storedFile.progress;file.status = storedFile.status;file.hash = storedFile.hash;file.isCompleted = storedFile.isCompleted || false;// 恢复切片状态file.chunks = storedFile.chunks.map(chunkData => ({index: chunkData.index,uploaded: chunkData.uploaded,retryCount: chunkData.retryCount,uploading: false}));// 初始化待上传队列file.pendingChunks = file.chunks.filter(chunk => !chunk.uploaded);log(`文件 "${file.name}" 状态已从IndexedDB恢复`, 'info');}};} catch (error) {log(`从IndexedDB恢复文件失败: ${error.message}`, 'error');}};const removeFileFromIndexedDB = async (fileId) => {if (!config.enableIndexedDB) return;try {const db = await openIndexedDB();const transaction = db.transaction(['files'], 'readwrite');const store = transaction.objectStore('files');store.delete(fileId);} catch (error) {log(`从IndexedDB删除文件失败: ${error.message}`, 'error');}};// WebSocket连接const connectWebSocket = () => {try {// 模拟WebSocket连接ws = {readyState: WebSocket.OPEN,send: (data) => {console.log('WebSocket send:', data);},close: () => {}};isConnected.value = true;log('WebSocket 连接已建立', 'info');} catch (error) {log(`WebSocket 连接失败: ${error.message}`, 'error');// 重连机制setTimeout(connectWebSocket, 5000);}};// 工具函数const generateId = () => {return Math.random().toString(36).substring(2) + Date.now().toString(36);};const formatFileSize = (bytes) => {if (bytes === 0) return '0 B';const k = 1024;const sizes = ['B', 'KB', 'MB', 'GB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];};const formatTime = (timestamp) => {const date = new Date(timestamp);return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`;};const getStatusText = (status) => {const statusMap = {pending: '等待上传',processing: '处理中',uploading: '上传中',paused: '已暂停',completed: '已完成',error: '上传失败'};return statusMap[status] || status;};const log = (message, level = 'info') => {logs.value.push({id: generateId(),timestamp: Date.now(),level,message});// 限制日志数量if (logs.value.length > 100) {logs.value.shift();}};// 生命周期onMounted(() => {connectWebSocket();log('大文件上传组件已初始化', 'info');});onUnmounted(() => {if (ws) {ws.close();}});return {fileInput,isDragOver,isConnected,config,fileList,logs,completedFiles,failedFiles,triggerFileInput,handleFileSelect,handleDrop,handleDragOver,handleDragLeave,removeFile,startUpload,pauseUpload,formatFileSize,formatTime,getStatusText};}}).mount('#app');</script>
</body>
</html>
专业术语
断点续传
断开重连重传
切片上传
方案
- 前端切片chunk 1024M(1048576K), 500K, const size=1048576/500
- 将切片传递给后端,切的片要取名:hash,index
- 后端组合切片
加料
前端切片:主进程做卡顿,web-worker多线程切片,处理完后交给主进程发送
切完后,将blob,存储到IndexedDB,下次用户进来之后,嗅探一下是否存在未完成上传的切片,有就尝试继续上传
websocket,实时通知,和请求序列的控制wss
大文件上传器整体设计
- 组件设计
- props、事件、状态
- 拖拽上传、多文件选择
- 通用化不同文件的上传,上传统一协议
问题
- 文件选择弹窗重复出现:这是因为在点击上传区域时,我们触发了一次文件输入框的点击,而文件输入框本身也位于上传区域内,所以当文件输入框被点击时,它也会触发上传区域的点击事件,导致再次触发文件输入框的点击,从而出现两次弹窗。
- 上传完成后,又继续走了一遍上传:这是因为在上传完成的逻辑中,我们虽然将文件状态标记为已完成,但是之前已经发出的上传请求(setTimeout模拟的)可能还在进行中,这些请求完成后会继续触发上传成功的逻辑,导致切片重复计数。
解决方案:
问题1:我们可以通过阻止事件冒泡来解决。当点击文件输入框时,我们不应该让事件冒泡到上传区域,从而避免重复触发。
问题2:我们需要在上传完成时,清除所有可能还在进行中的上传计时器,并且确保在上传完成之后,不再处理任何上传成功的回调。
切片数量溢出:已上传切片数超过总切片数。这是因为我们在上传成功时没有正确地从待上传队列中移除切片,或者并发控制逻辑有误,导致同一个切片被多次上传。
暂停按钮点击后没有暂停,马上又恢复上传:这是因为我们在暂停时只是将文件状态改为暂停,但没有真正停止正在上传的切片。我们暂停时应该取消正在上传的请求
上传成功后仍然可以点击开始按钮:我们需要在上传完成后禁用开始按钮,或者在上传成功后不再执行上传逻辑。