IndexedDB 实现断点续传、分片上传功能
作者:菜喵007
本文基于Vue3、TypeScript和Setup语法糖,实现文件断点续传功能,支持网络中断或浏览器关闭后从上次上传位置继续上传,并在浏览器重新打开时通过用户确认自动续传,感兴趣的朋友跟随小编一起看看吧
IndexedDB 断点续传
本文基于 Vue3、TypeScript 和 Setup 语法糖,实现文件断点续传功能,支持网络中断或浏览器关闭后从上次上传位置继续上传,并在浏览器重新打开时通过用户确认自动续传。使用 IndexedDB 存储文件元数据和分片状态,确保上传过程可靠,支持暂停/恢复以及跨浏览器会话的自动续传。
1. 项目环境准备
1.1 技术栈
- Vue3:使用 Composition API 和 Setup 语法糖。
- TypeScript:提供类型安全。
- IndexedDB:存储文件元数据和分片状态。
- Vite:作为构建工具。
- Tailwind CSS:优化界面样式。
1.2 项目初始化
npm create vite@latest indexeddb-upload -- --template vue-ts cd indexeddb-upload npm install npm install -D tailwindcss@latest postcss@latest autoprefixer@latest npx tailwindcss init -p npm run dev
1.3 配置 Tailwind CSS
在 src/style.css
中添加:
@tailwind base; @tailwind components; @tailwind utilities;
更新 vite.config.ts
:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], css: { postcss: { plugins: [require('tailwindcss'), require('autoprefixer')], }, }, })
1.4 依赖
无额外运行时依赖,使用浏览器原生 IndexedDB API。
2. 大批量文件断点续传(支持自动续传)
2.1 场景描述
断点续传允许用户在网络中断或浏览器关闭后,从上次上传位置继续上传。在浏览器重新打开时,系统检测未完成上传任务,通过用户确认后自动续传。IndexedDB 存储文件元数据(如文件名、大小、最后修改时间)和分片状态(已上传、待上传)。
2.2 实现思路
- 使用 IndexedDB 存储文件元数据和分片状态。
- 页面加载时,检查 IndexedDB 中的未完成任务,显示确认界面。
- 用户确认后,验证文件一致性并继续上传。
- 使用 Vue3 响应式 API 管理状态和进度。
- 支持暂停/继续功能,实时更新 UI。
- TypeScript 确保类型安全。
- 使用 Tailwind CSS 优化界面。
2.3 完整示例代码
2.3.1 主组件 (src/App.vue
)
<template> <div class="p-6 max-w-2xl mx-auto"> <h1 class="text-3xl font-bold mb-6">文件断点续传(支持自动续传)</h1> <input type="file" ref="fileInput" @change="handleFileChange" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" /> <div class="mt-6 flex space-x-4"> <button class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400" @click="startUpload" :disabled="isUploading" > 开始上传 </button> <button class="bg-red-600 text-white px-6 py-2 rounded-md hover:bg-red-700 disabled:bg-gray-400" @click="pauseUpload" :disabled="!isUploading" > 暂停上传 </button> </div> <div class="mt-6"> <p class="text-lg">上传进度: {{ progress }}%</p> <div class="w-full bg-gray-200 rounded-full h-4 mt-2"> <div class="bg-blue-600 h-4 rounded-full" :style="{ width: `${progress}%` }" ></div> </div> </div> <p v-if="autoUploading" class="text-green-600 mt-4"> 检测到未完成任务,正在自动续传 {{ fileName }}... </p> <div v-if="pendingFile" class="mt-4 p-4 bg-yellow-100 border border-yellow-400 rounded-md" > <p>检测到未完成的文件:{{ pendingFile.fileName }} ({{ formatSize(pendingFile.fileSize) }})</p> <p>上次修改时间:{{ new Date(pendingFile.lastModified).toLocaleString() }}</p> <div class="mt-4 flex space-x-4"> <button class="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700" @click="confirmResume" > 继续上传 </button> <button class="bg-gray-600 text-white px-4 py-2 rounded-md hover:bg-gray-700" @click="cancelResume" > 取消 </button> </div> </div> </div> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { initDB, saveChunkStatus, getChunkStatus, uploadChunk, getPendingFile, clearDB } from './utils/upload'; const CHUNK_SIZE = 1024 * 1024; // 1MB const fileInput = ref<HTMLInputElement | null>(null); const selectedFile = ref<File | null>(null); const isUploading = ref(false); const isPaused = ref(false); const autoUploading = ref(false); const uploadedChunks = ref(new Set<number>()); const totalChunks = ref(0); const fileName = ref(''); const db = ref<IDBDatabase | null>(null); const pendingFile = ref<FileMetadata | null>(null); const progress = computed(() => totalChunks.value ? ((uploadedChunks.value.size / totalChunks.value) * 100).toFixed(2) : '0' ); const formatSize = (bytes: number): string => { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(2)} ${units[unitIndex]}`; }; const handleFileChange = async (event: Event) => { const input = event.target as HTMLInputElement; if (input.files?.length) { const file = input.files[0]; // 验证文件一致性 if (pendingFile.value && (file.name !== pendingFile.value.fileName || file.size !== pendingFile.value.fileSize || file.lastModified !== pendingFile.value.lastModified)) { alert('所选文件与未完成任务不匹配,请取消未完成任务或选择正确文件'); input.value = ''; return; } selectedFile.value = file; fileName.value = file.name; totalChunks.value = Math.ceil(file.size / CHUNK_SIZE); uploadedChunks.value.clear(); pendingFile.value = null; await saveFileMetadata(); } }; const saveFileMetadata = async () => { if (!db.value || !selectedFile.value) return; const transaction = db.value.transaction(['metadata'], 'readwrite'); const store = transaction.objectStore('metadata'); const metadata: FileMetadata = { fileName: selectedFile.value.name, fileSize: selectedFile.value.size, totalChunks: totalChunks.value, lastModified: selectedFile.value.lastModified, }; await new Promise((resolve, reject) => { const request = store.put(metadata); request.onsuccess = () => resolve(undefined); request.onerror = () => reject(request.error); }); }; const startUpload = async (auto = false) => { if (!selectedFile.value && !auto) { alert('请选择文件'); return; } isUploading.value = true; isPaused.value = false; if (auto) autoUploading.value = true; if (!db.value) { db.value = await initDB('FileUploadDB', 1, (db) => { db.createObjectStore('chunks', { keyPath: 'chunkId' }); db.createObjectStore('metadata', { keyPath: 'fileName' }); }); } try { for (let i = 0; i < totalChunks.value; i++) { if (isPaused.value) break; const chunkStatus = await getChunkStatus(db.value, i); if (chunkStatus?.status === 'uploaded') { uploadedChunks.value.add(i); continue; } const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, selectedFile.value!.size); const chunk = selectedFile.value!.slice(start, end); await uploadChunk(db.value, chunk, i, fileName.value, totalChunks.value); uploadedChunks.value.add(i); } if (!isPaused.value) { await clearDB(db.value); alert('上传完成'); resetState(); } } catch (error) { alert(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`); isUploading.value = false; autoUploading.value = false; } }; const pauseUpload = () => { isPaused.value = true; isUploading.value = false; autoUploading.value = false; alert('上传已暂停,可重新点击“开始上传”继续'); }; const confirmResume = async () => { if (!fileInput.value!.files?.length) { alert('请重新选择文件以继续上传'); return; } const file = fileInput.value!.files[0]; if ( file.name !== pendingFile.value!.fileName || file.size !== pendingFile.value!.fileSize || file.lastModified !== pendingFile.value!.lastModified ) { alert('所选文件与未完成任务不匹配'); return; } selectedFile.value = file; fileName.value = file.name; totalChunks.value = pendingFile.value!.totalChunks; pendingFile.value = null; await startUpload(true); }; const cancelResume = async () => { await clearDB(db.value!); pendingFile.value = null; resetState(); alert('已取消未完成任务'); }; const resetState = () => { isUploading.value = false; autoUploading.value = false; selectedFile.value = null; fileName.value = ''; totalChunks.value = 0; uploadedChunks.value.clear(); if (fileInput.value) fileInput.value.value = ''; }; onMounted(async () => { if (!window.indexedDB) { alert('浏览器不支持 IndexedDB'); return; } try { db.value = await initDB('FileUploadDB', 1, (db) => { db.createObjectStore('chunks', { keyPath: 'chunkId' }); db.createObjectStore('metadata', { keyPath: 'fileName' }); }); const pending = await getPendingFile(db.value); if (pending) { pendingFile.value = pending; } } catch (error) { alert(`初始化数据库失败: ${error instanceof Error ? error.message : '未知错误'}`); } }); </script>
2.3.2 工具函数 (src/utils/upload.ts
)
export interface ChunkStatus { chunkId: number; fileName: string; status: 'pending' | 'uploaded'; } export interface FileMetadata { fileName: string; fileSize: number; totalChunks: number; lastModified: number; } export const initDB = ( dbName: string, version: number, onUpgrade: (db: IDBDatabase) => void ): Promise<IDBDatabase> => { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, version); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; onUpgrade(db); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }; export const saveChunkStatus = ( db: IDBDatabase, chunkId: number, fileName: string, status: 'pending' | 'uploaded' ): Promise<void> => { return new Promise((resolve, reject) => { const transaction = db.transaction(['chunks'], 'readwrite'); const store = transaction.objectStore('chunks'); const request = store.put({ chunkId, fileName, status }); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); }; export const getChunkStatus = (db: IDBDatabase, chunkId: number): Promise<ChunkStatus | undefined> => { return new Promise((resolve, reject) => { const transaction = db.transaction(['chunks'], 'readonly'); const store = transaction.objectStore('chunks'); const request = store.get(chunkId); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }; export const getPendingFile = (db: IDBDatabase): Promise<FileMetadata | undefined> => { return new Promise((resolve, reject) => { const transaction = db.transaction(['metadata'], 'readonly'); const store = transaction.objectStore('metadata'); const request = store.getAll(); request.onsuccess = () => { const files = request.result as FileMetadata[]; resolve(files.length > 0 ? files[0] : undefined); }; request.onerror = () => reject(request.error); }); }; export const clearDB = async (db: IDBDatabase): Promise<void> => { const stores = ['chunks', 'metadata']; for (const storeName of stores) { const transaction = db.transaction([storeName], 'readwrite'); const store = transaction.objectStore(storeName); await new Promise((resolve, reject) => { const request = store.clear(); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } }; export const uploadChunk = async ( db: IDBDatabase, chunk: Blob, chunkId: number, fileName: string, totalChunks: number ): Promise<void> => { const formData = new FormData(); formData.append('chunk', chunk); formData.append('chunkId', chunkId.toString()); formData.append('fileName', fileName); try { const response = await fetch('/upload', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error(`上传失败,状态码: ${response.status}`); } await saveChunkStatus(db, chunkId, fileName, 'uploaded'); } catch (error) { console.error(`分片 ${chunkId} 上传失败:`, error); throw error; } };
2.4 代码说明
- 自动续传:
- 页面加载时,
onMounted
通过getPendingFile
检查未完成任务。 - 若存在未完成任务,
pendingFile
存储元数据,显示确认界面(文件名、大小、最后修改时间)。 - 用户需选择相同文件并点击“继续上传”,确保文件一致性。
- 页面加载时,
- 文件一致性校验:
handleFileChange
和confirmResume
验证文件名、大小和最后修改时间,防止错误续传。
- Tailwind CSS:
- 添加进度条、样式化按钮和响应式确认对话框,提升用户体验。
- 错误处理:
- 数据库初始化、文件不匹配和上传失败均提供用户友好的提示。
- 清理数据:
- 上传完成或取消后,
clearDB
清空chunks
和metadata
存储。
- 上传完成或取消后,
- 后端接口:
- 假设
/upload
接口接收分片,实际需实现后端分片存储和合并逻辑。
- 假设
2.5 应用场景
- 大文件上传(如视频、压缩包)在网络不稳定或浏览器意外关闭的场景。
- 云存储客户端需要无缝恢复上传。
- 用户希望最小化手动干预的上传流程。
2.6 局限性
- 用户需重新选择文件以续传,因
File
对象无法跨会话持久化。可考虑 FileSystem API(但支持度较低)。 - 仅支持单文件未完成任务,多个文件需扩展 UI 选择逻辑。
3. 注意事项与优化
3.1 错误处理
- 所有 IndexedDB 和网络操作均包含 try-catch 块,提供用户提示。
- 文件不匹配时提示用户取消任务或选择正确文件。
3.2 性能优化
CHUNK_SIZE
(1MB)平衡内存和网络开销,可根据需求调整。- 上传完成或取消后清理 IndexedDB 数据,释放存储空间。
3.3 浏览器兼容性
- 在
onMounted
中检查 IndexedDB 支持:
if (!window.indexedDB) { alert('浏览器不支持 IndexedDB'); }
3.4 改进建议
- 使用
Dexie.js
简化 IndexedDB 操作。 - 封装上传逻辑为自定义 Hook(如
useFileUpload
)。 - 添加文件哈希(如 MD5)到
FileMetadata
,增强一致性校验。 - 支持多文件未完成任务,增加文件选择 UI。
4. 总结
通过 IndexedDB 实现可靠的断点续传功能,支持浏览器关闭后经用户确认自动续传。Tailwind CSS 优化了界面,TypeScript 确保类型安全,完善的错误处理提升了可靠性。代码适用于云存储、视频上传等场景,开发者可根据需求调整分片大小或扩展多文件支持。
到此这篇关于IndexedDB 实现断点续传、分片上传 的文章就介绍到这了,更多相关IndexedDB 断点续传、分片上传 内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!