IndexedDB?實(shí)現(xiàn)斷點(diǎn)續(xù)傳、分片上傳功能
IndexedDB 斷點(diǎn)續(xù)傳
本文基于 Vue3、TypeScript 和 Setup 語法糖,實(shí)現(xiàn)文件斷點(diǎn)續(xù)傳功能,支持網(wǎng)絡(luò)中斷或?yàn)g覽器關(guān)閉后從上次上傳位置繼續(xù)上傳,并在瀏覽器重新打開時(shí)通過用戶確認(rèn)自動續(xù)傳。使用 IndexedDB 存儲文件元數(shù)據(jù)和分片狀態(tài),確保上傳過程可靠,支持暫停/恢復(fù)以及跨瀏覽器會話的自動續(xù)傳。
1. 項(xiàng)目環(huán)境準(zhǔn)備
1.1 技術(shù)棧
- Vue3:使用 Composition API 和 Setup 語法糖。
- TypeScript:提供類型安全。
- IndexedDB:存儲文件元數(shù)據(jù)和分片狀態(tài)。
- Vite:作為構(gòu)建工具。
- Tailwind CSS:優(yōu)化界面樣式。
1.2 項(xiàng)目初始化
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 依賴
無額外運(yùn)行時(shí)依賴,使用瀏覽器原生 IndexedDB API。
2. 大批量文件斷點(diǎn)續(xù)傳(支持自動續(xù)傳)
2.1 場景描述
斷點(diǎn)續(xù)傳允許用戶在網(wǎng)絡(luò)中斷或?yàn)g覽器關(guān)閉后,從上次上傳位置繼續(xù)上傳。在瀏覽器重新打開時(shí),系統(tǒng)檢測未完成上傳任務(wù),通過用戶確認(rèn)后自動續(xù)傳。IndexedDB 存儲文件元數(shù)據(jù)(如文件名、大小、最后修改時(shí)間)和分片狀態(tài)(已上傳、待上傳)。
2.2 實(shí)現(xiàn)思路
- 使用 IndexedDB 存儲文件元數(shù)據(jù)和分片狀態(tài)。
- 頁面加載時(shí),檢查 IndexedDB 中的未完成任務(wù),顯示確認(rèn)界面。
- 用戶確認(rèn)后,驗(yàn)證文件一致性并繼續(xù)上傳。
- 使用 Vue3 響應(yīng)式 API 管理狀態(tài)和進(jìn)度。
- 支持暫停/繼續(xù)功能,實(shí)時(shí)更新 UI。
- TypeScript 確保類型安全。
- 使用 Tailwind CSS 優(yōu)化界面。
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">文件斷點(diǎn)續(xù)傳(支持自動續(xù)傳)</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">上傳進(jìn)度: {{ 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">
檢測到未完成任務(wù),正在自動續(xù)傳 {{ 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>上次修改時(shí)間:{{ 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"
>
繼續(xù)上傳
</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];
// 驗(yàn)證文件一致性
if (pendingFile.value && (file.name !== pendingFile.value.fileName || file.size !== pendingFile.value.fileSize || file.lastModified !== pendingFile.value.lastModified)) {
alert('所選文件與未完成任務(wù)不匹配,請取消未完成任務(wù)或選擇正確文件');
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('上傳已暫停,可重新點(diǎn)擊“開始上傳”繼續(xù)');
};
const confirmResume = async () => {
if (!fileInput.value!.files?.length) {
alert('請重新選擇文件以繼續(xù)上傳');
return;
}
const file = fileInput.value!.files[0];
if (
file.name !== pendingFile.value!.fileName ||
file.size !== pendingFile.value!.fileSize ||
file.lastModified !== pendingFile.value!.lastModified
) {
alert('所選文件與未完成任務(wù)不匹配');
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('已取消未完成任務(wù)');
};
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(`初始化數(shù)據(jù)庫失敗: ${error instanceof Error ? error.message : '未知錯誤'}`);
}
});
</script>2.3.2 工具函數(shù) (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(`上傳失敗,狀態(tài)碼: ${response.status}`);
}
await saveChunkStatus(db, chunkId, fileName, 'uploaded');
} catch (error) {
console.error(`分片 ${chunkId} 上傳失敗:`, error);
throw error;
}
};2.4 代碼說明
- 自動續(xù)傳:
- 頁面加載時(shí),
onMounted通過getPendingFile檢查未完成任務(wù)。 - 若存在未完成任務(wù),
pendingFile存儲元數(shù)據(jù),顯示確認(rèn)界面(文件名、大小、最后修改時(shí)間)。 - 用戶需選擇相同文件并點(diǎn)擊“繼續(xù)上傳”,確保文件一致性。
- 頁面加載時(shí),
- 文件一致性校驗(yàn):
handleFileChange和confirmResume驗(yàn)證文件名、大小和最后修改時(shí)間,防止錯誤續(xù)傳。
- Tailwind CSS:
- 添加進(jìn)度條、樣式化按鈕和響應(yīng)式確認(rèn)對話框,提升用戶體驗(yàn)。
- 錯誤處理:
- 數(shù)據(jù)庫初始化、文件不匹配和上傳失敗均提供用戶友好的提示。
- 清理數(shù)據(jù):
- 上傳完成或取消后,
clearDB清空chunks和metadata存儲。
- 上傳完成或取消后,
- 后端接口:
- 假設(shè)
/upload接口接收分片,實(shí)際需實(shí)現(xiàn)后端分片存儲和合并邏輯。
- 假設(shè)
2.5 應(yīng)用場景
- 大文件上傳(如視頻、壓縮包)在網(wǎng)絡(luò)不穩(wěn)定或?yàn)g覽器意外關(guān)閉的場景。
- 云存儲客戶端需要無縫恢復(fù)上傳。
- 用戶希望最小化手動干預(yù)的上傳流程。
2.6 局限性
- 用戶需重新選擇文件以續(xù)傳,因
File對象無法跨會話持久化??煽紤] FileSystem API(但支持度較低)。 - 僅支持單文件未完成任務(wù),多個文件需擴(kuò)展 UI 選擇邏輯。
3. 注意事項(xiàng)與優(yōu)化
3.1 錯誤處理
- 所有 IndexedDB 和網(wǎng)絡(luò)操作均包含 try-catch 塊,提供用戶提示。
- 文件不匹配時(shí)提示用戶取消任務(wù)或選擇正確文件。
3.2 性能優(yōu)化
CHUNK_SIZE(1MB)平衡內(nèi)存和網(wǎng)絡(luò)開銷,可根據(jù)需求調(diào)整。- 上傳完成或取消后清理 IndexedDB 數(shù)據(jù),釋放存儲空間。
3.3 瀏覽器兼容性
- 在
onMounted中檢查 IndexedDB 支持:
if (!window.indexedDB) {
alert('瀏覽器不支持 IndexedDB');
}3.4 改進(jìn)建議
- 使用
Dexie.js簡化 IndexedDB 操作。 - 封裝上傳邏輯為自定義 Hook(如
useFileUpload)。 - 添加文件哈希(如 MD5)到
FileMetadata,增強(qiáng)一致性校驗(yàn)。 - 支持多文件未完成任務(wù),增加文件選擇 UI。
4. 總結(jié)
通過 IndexedDB 實(shí)現(xiàn)可靠的斷點(diǎn)續(xù)傳功能,支持瀏覽器關(guān)閉后經(jīng)用戶確認(rèn)自動續(xù)傳。Tailwind CSS 優(yōu)化了界面,TypeScript 確保類型安全,完善的錯誤處理提升了可靠性。代碼適用于云存儲、視頻上傳等場景,開發(fā)者可根據(jù)需求調(diào)整分片大小或擴(kuò)展多文件支持。
到此這篇關(guān)于IndexedDB 實(shí)現(xiàn)斷點(diǎn)續(xù)傳、分片上傳 的文章就介紹到這了,更多相關(guān)IndexedDB 斷點(diǎn)續(xù)傳、分片上傳 內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
警告[vue-router]?Duplicate?named?routes?definition簡單解決方法
這篇文章主要關(guān)于介紹了警告[vue-router]?Duplicate?named?routes?definition的解決方法,這個錯誤提示是因?yàn)樵赩ue Router中定義了重復(fù)的路由名稱,需要的朋友可以參考下2023-12-12
vue框架下部署上線后刷新報(bào)404問題的解決方案(推薦)
這篇文章主要介紹了vue框架下部署上線后刷新報(bào)404問題解決方案,非常不錯,具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-04-04
vue工程師必會封裝的埋點(diǎn)指令思路知識總結(jié)
這篇文章主要給大家總結(jié)介紹了關(guān)于vue工程師必會封裝的埋點(diǎn)指令思路知識,文中通過實(shí)例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2021-12-12
ElementUI日期選擇器時(shí)間選擇范圍限制的實(shí)現(xiàn)
在日常開發(fā)中,我們會遇到一些情況,限制日期的范圍的選擇,本文就詳細(xì)的介紹了ElementUI日期選擇器時(shí)間選擇范圍限制的實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),感興趣的可以了解一下2022-04-04
vue中設(shè)置height:100%無效的問題及解決方法
這篇文章主要介紹了vue中設(shè)置height 100%無效的問題及解決方法,需要的朋友可以參考下2018-07-07
vue實(shí)現(xiàn)點(diǎn)擊按鈕input保持聚焦?fàn)顟B(tài)的示例代碼
這篇文章主要介紹了vue實(shí)現(xiàn)點(diǎn)擊按鈕input保持聚焦?fàn)顟B(tài),本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2025-06-06

