vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > IndexedDB 断点续传、分片上传

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

作者:菜喵007

本文基于Vue3、TypeScript和Setup语法糖,实现文件断点续传功能,支持网络中断或浏览器关闭后从上次上传位置继续上传,并在浏览器重新打开时通过用户确认自动续传,感兴趣的朋友跟随小编一起看看吧

IndexedDB 断点续传

本文基于 Vue3、TypeScript 和 Setup 语法糖,实现文件断点续传功能,支持网络中断或浏览器关闭后从上次上传位置继续上传,并在浏览器重新打开时通过用户确认自动续传。使用 IndexedDB 存储文件元数据和分片状态,确保上传过程可靠,支持暂停/恢复以及跨浏览器会话的自动续传。

1. 项目环境准备

1.1 技术栈

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

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 代码说明

2.5 应用场景

2.6 局限性

3. 注意事项与优化

3.1 错误处理

3.2 性能优化

3.3 浏览器兼容性

if (!window.indexedDB) {
  alert('浏览器不支持 IndexedDB');
}

3.4 改进建议

4. 总结

通过 IndexedDB 实现可靠的断点续传功能,支持浏览器关闭后经用户确认自动续传。Tailwind CSS 优化了界面,TypeScript 确保类型安全,完善的错误处理提升了可靠性。代码适用于云存储、视频上传等场景,开发者可根据需求调整分片大小或扩展多文件支持。

到此这篇关于IndexedDB 实现断点续传、分片上传 的文章就介绍到这了,更多相关IndexedDB 断点续传、分片上传 内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文