IndexedDB 实现断点续传、分片上传

171 阅读5分钟

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 实现思路

  1. 使用 IndexedDB 存储文件元数据和分片状态。
  2. 页面加载时,检查 IndexedDB 中的未完成任务,显示确认界面。
  3. 用户确认后,验证文件一致性并继续上传。
  4. 使用 Vue3 响应式 API 管理状态和进度。
  5. 支持暂停/继续功能,实时更新 UI。
  6. TypeScript 确保类型安全。
  7. 使用 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 存储元数据,显示确认界面(文件名、大小、最后修改时间)。
    • 用户需选择相同文件并点击“继续上传”,确保文件一致性。
  • 文件一致性校验
    • handleFileChangeconfirmResume 验证文件名、大小和最后修改时间,防止错误续传。
  • Tailwind CSS
    • 添加进度条、样式化按钮和响应式确认对话框,提升用户体验。
  • 错误处理
    • 数据库初始化、文件不匹配和上传失败均提供用户友好的提示。
  • 清理数据
    • 上传完成或取消后,clearDB 清空 chunksmetadata 存储。
  • 后端接口
    • 假设 /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 确保类型安全,完善的错误处理提升了可靠性。代码适用于云存储、视频上传等场景,开发者可根据需求调整分片大小或扩展多文件支持。