Vue?利用?IndexedDB?實(shí)現(xiàn)前端大文件斷點(diǎn)續(xù)傳功能
一、背景與痛點(diǎn)
前端下載大文件時(shí),我們通常的做法是一行 fetch 拿到 response,轉(zhuǎn)成 Blob,再丟給一個(gè)隱藏的 <a> 標(biāo)簽觸發(fā)下載。這套邏輯在幾十 KB 的圖片、幾百 KB 的 PDF 上完全沒(méi)問(wèn)題??梢坏┪募艿?100MB、1GB,問(wèn)題就來(lái)了:
- 瀏覽器內(nèi)存扛不住。 fetch 把整個(gè) response body 讀到內(nèi)存數(shù)組里,100MB 的文件就是 100MB+ 的堆內(nèi)存占用,稍微大一點(diǎn)就直接 OOM。
- 刷新歸零。 進(jìn)度條跑到 80%,用戶手滑刷新了頁(yè)面——從頭再來(lái),已經(jīng)下載的數(shù)據(jù)全丟了。
- 無(wú)法暫?;謴?fù)。 瀏覽器原生沒(méi)有"暫停下載"的概念,fetch 一旦發(fā)起,要么等到結(jié)束,要么 abort 掉全部作廢。
這背后本質(zhì)上是兩個(gè)問(wèn)題:流式數(shù)據(jù)怎么持久化,以及已持久化的數(shù)據(jù)怎么在下次請(qǐng)求時(shí)被服務(wù)器"認(rèn)賬"。第一個(gè)問(wèn)題用 IndexedDB 解決,第二個(gè)問(wèn)題靠 HTTP Range 請(qǐng)求。
本文基于一個(gè)完整的 Vue 2 + Element UI 實(shí)戰(zhàn)項(xiàng)目,把這兩塊串起來(lái),講清楚如何實(shí)現(xiàn)一個(gè)帶持久化存儲(chǔ)、支持?jǐn)帱c(diǎn)續(xù)傳、進(jìn)度精確到小數(shù)點(diǎn)后兩位的大文件下載方案。
二、核心原理:Range 請(qǐng)求與 206 響應(yīng)
HTTP 協(xié)議從 1.1 開(kāi)始就定義了一個(gè)叫 Range 的請(qǐng)求頭,語(yǔ)義很簡(jiǎn)單:告訴服務(wù)器"我只要這個(gè)文件的一部分"。
Range: bytes=5000000-
上面這行表示:從第 5,000,000 個(gè)字節(jié)開(kāi)始,把后面的內(nèi)容都給我。服務(wù)器如果支持分段傳輸,會(huì)返回 206 Partial Content,同時(shí)在 Content-Range 響應(yīng)頭里告訴客戶端三件事:本次數(shù)據(jù)的起止位置,以及文件的完整大小。
Content-Range: bytes 5000000-104857599/104857600
格式是 bytes 起始-結(jié)束/總大小。這個(gè) header 是整個(gè)斷點(diǎn)續(xù)傳的核心——客戶端靠它知道文件有多大,以及自己還差多少。
如果服務(wù)器不支持 Range(比如某些 CDN 或靜態(tài)文件服務(wù)器沒(méi)開(kāi)這個(gè)能力),它會(huì)忽略 Range 頭,照常返回 200 和完整文件。所以我們的代碼需要兼容兩種情況:206 就走續(xù)傳邏輯,200 就清空舊數(shù)據(jù)從頭來(lái)。
三、存儲(chǔ)方案選型:為什么不用 LocalStorage
有人會(huì)想:瀏覽器不是有 LocalStorage 嗎,5MB 夠不夠?答案是一票否決的——LocalStorage 只能存字符串,不能存二進(jìn)制數(shù)據(jù)。
對(duì)比維度 | LocalStorage | IndexedDB |
容量上限 | 5-10MB | 瀏覽器可用磁盤(pán)的 50%-80% |
數(shù)據(jù)類型 | 僅字符串 | String、Blob、ArrayBuffer、File |
存儲(chǔ)模式 | 同步(阻塞主線程) | 異步(不阻塞 UI) |
查詢能力 | 僅 key-value | 支持索引、游標(biāo)遍歷 |
對(duì)于大文件下載場(chǎng)景,核心要求有三個(gè):
- 必須存二進(jìn)制。 fetch 流式讀取出來(lái)的是
Uint8Array二進(jìn)制塊,LocalStorage 根本塞不進(jìn)去。 - 容量必須大。 一個(gè) 100MB 的測(cè)試文件,存進(jìn)去就是 100MB。LocalStorage 的 5MB 上限連塞牙縫都不夠。
- 必須異步。 你不想每次寫(xiě)盤(pán)都卡 UI 渲染。
IndexedDB 是瀏覽器內(nèi)置的事務(wù)型對(duì)象數(shù)據(jù)庫(kù),支持結(jié)構(gòu)化克隆算法,ArrayBuffer 可以直接作為值存入,這些特性讓它天然適合做文件分片的持久化。
四、實(shí)戰(zhàn)代碼拆解
4.1 項(xiàng)目架構(gòu)簡(jiǎn)述
src/ ├── db/downloadDB.js # IndexedDB 操作封裝 ├── store/index.js # Vuex 狀態(tài)管理 ├── App.vue # 根實(shí)例:核心下載邏輯 └── components/DownloadChild.vue # 下載按鈕子組件
- DownloadChild 通過(guò) Vuex
dispatch('requestDownload', { url, filename })發(fā)起下載指令 - App.vue 通過(guò) computed 屬性監(jiān)聽(tīng) Vuex 中
downloadTask.timestamp的變化,觸發(fā)實(shí)際下載 - downloadDB.js 封裝所有 IndexedDB CRUD 操作,與業(yè)務(wù)邏輯解耦
4.2 數(shù)據(jù)庫(kù)設(shè)計(jì)
首先看數(shù)據(jù)庫(kù)結(jié)構(gòu)——一個(gè) object store,一個(gè)索引,足以支撐整個(gè)斷點(diǎn)續(xù)傳:
const DB_NAME = 'DownloadDB'
const STORE_NAME = 'chunks'
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1)
request.onupgradeneeded = (e) => {
const db = e.target.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: 'id',
autoIncrement: true
})
store.createIndex('filename', 'filename', { unique: false })
}
}
request.onsuccess = (e) => resolve(e.target.result)
request.onerror = (e) => reject(e.target.error)
})
}每條記錄包含五個(gè)字段:id(自增主鍵)、filename(用于索引查詢)、chunkIndex(分片序號(hào),用于最終排序合并)、data(ArrayBuffer,實(shí)際二進(jìn)制數(shù)據(jù))、size(當(dāng)前分片的字節(jié)數(shù),用于進(jìn)度累加)。
選擇 autoIncrement 主鍵而非 [filename, chunkIndex] 復(fù)合主鍵,是因?yàn)?IndexedDB 的復(fù)合主鍵在游標(biāo)遍歷時(shí)性能不如單字段索引,而我們的 clearChunks 操作依賴游標(biāo)刪除,這是寫(xiě)入頻率較高的路徑。
4.3 核心下載流程:Range 請(qǐng)求 + 流式持久化
整個(gè) startDownload 方法是斷點(diǎn)續(xù)傳的核心,我按執(zhí)行順序拆成五個(gè)階段來(lái)講解。
階段一:查詢已下載字節(jié)
let loadedSize = await getDownloadedSize(filename)
這里遍歷 IndexedDB 中該文件的所有 chunk,累加 size 字段求和。即使之前頁(yè)面刷新過(guò)、甚至瀏覽器重啟過(guò),只要 IndexedDB 中的數(shù)據(jù)還在,就能準(zhǔn)確拿到已經(jīng)下載了多少字節(jié)。
階段二:構(gòu)造 Range 請(qǐng)求
const headers = {}
if (loadedSize > 0) {
headers['Range'] = `bytes=${loadedSize}-`
}
const response = await fetch(url, {
signal: this.abortController.signal,
headers
})如果 loadedSize 為 0(首次下載或之前已清理),就不帶 Range 頭,服務(wù)器返回完整文件。如果大于 0,告訴服務(wù)器"從第 N 個(gè)字節(jié)開(kāi)始給我"。
這里同時(shí)傳入 AbortController 的 signal,方便用戶取消下載時(shí)中斷 fetch。
階段三:處理 206 vs 200
let totalSize
if (response.status === 206) {
const contentRange = response.headers.get('Content-Range')
totalSize = parseInt(contentRange.split('/')[1])
} else if (response.status === 200) {
if (loadedSize > 0) {
await clearChunks(filename)
loadedSize = 0
}
totalSize = +response.headers.get('Content-Length')
} else if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}這是整個(gè)方法里最容易踩坑的分支邏輯:
- 206:服務(wù)器認(rèn)了 Range,返回部分內(nèi)容。我們從
Content-Range: bytes 5000000-104857599/104857600中取斜杠后面的104857600作為文件總大小。注意不是Content-Length——206 響應(yīng)中的 Content-Length 只表示本次返回的 body 大小,不是完整文件大小。 - 200:服務(wù)器不支持 Range。如果此時(shí) IndexedDB 里還有舊數(shù)據(jù),必須清空,因?yàn)橹按娴?chunk 和現(xiàn)在返回的完整數(shù)據(jù)會(huì)產(chǎn)生重疊和錯(cuò)亂。
- 其他非 2xx:直接拋異常,交給 catch 處理。
階段四:流式讀取并逐塊寫(xiě)入 IndexedDB
const reader = response.body.getReader()
let chunkIndex = await getChunkCount(filename)
while (true) {
const { done, value } = await reader.read()
if (done) break
await saveChunk(filename, chunkIndex++, value)
loadedSize += value.length
this.downloadPercent = parseFloat(
((loadedSize / totalSize) * 100).toFixed(2)
)
}response.body.getReader() 拿到的是 ReadableStream 的 reader,每次 read() 返回一個(gè) { done, value } 對(duì)象,value 是一個(gè) Uint8Array,大約是幾 KB 到幾十 KB 不等的二進(jìn)制塊。
關(guān)鍵細(xì)節(jié)在 saveChunk 里:
export async function saveChunk(filename, chunkIndex, data) {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const buffer = new Uint8Array(data).buffer // 拷貝一份
store.add({ filename, chunkIndex, data: buffer, size: buffer.byteLength })
tx.oncomplete = () => { db.close(); resolve() }
tx.onerror = (e) => { db.close(); reject(e.target.error) }
})
}new Uint8Array(data).buffer 這一步一定要做。reader.read() 返回的 Uint8Array 可能共享一個(gè)更大的底層 ArrayBuffer,直接存入 IndexedDB 會(huì)導(dǎo)致數(shù)據(jù)不完整或錯(cuò)亂。做一個(gè)拷貝,雖然多花了一點(diǎn)內(nèi)存,但保證了數(shù)據(jù)的正確性。
階段五:合并分片,觸發(fā)下載,清理
async mergeAndSave(filename) {
const chunks = await getAllChunks(filename)
const blob = new Blob(chunks, { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
await clearChunks(filename)
}從 IndexedDB 按 chunkIndex 升序取出所有分片,每個(gè)分片還原成 Uint8Array,直接傳給 Blob 構(gòu)造函數(shù)。Blob 可以接受 Uint8Array[] 數(shù)組,會(huì)自動(dòng)拼接。
下載完成后務(wù)必調(diào)用 clearChunks——既釋放用戶磁盤(pán)空間,也避免下次下載時(shí) IndexedDB 里殘留舊數(shù)據(jù)。清理邏輯使用游標(biāo)遍歷刪除:
export async function clearChunks(filename) {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const index = store.index('filename')
const cursorReq = index.openCursor(IDBKeyRange.only(filename))
cursorReq.onsuccess = (e) => {
const cursor = e.target.result
if (cursor) {
cursor.delete()
cursor.continue()
}
}
tx.oncomplete = () => { db.close(); resolve() }
tx.onerror = (e) => { db.close(); reject(e.target.error) }
})
}這里用游標(biāo)而不是 index.getAll() + 逐條 store.delete() 的原因有兩個(gè):一是游標(biāo)一次只持有當(dāng)前記錄,內(nèi)存友好;二是游標(biāo)刪除在一次事務(wù)內(nèi)完成,要么全部成功要么全部回滾。
4.4 取消即保存
注意代碼中 startDownload 的 catch 分支:
catch (err) {
if (err.name === 'AbortError') {
this.progressStatus = 'exception'
return // 不清理 IndexedDB
}
// ...
}AbortError 代表用戶主動(dòng)取消——此時(shí) IndexedDB 中的數(shù)據(jù)不會(huì)被清理。用戶下次點(diǎn)擊下載按鈕,getDownloadedSize 會(huì)讀到之前累積的字節(jié)數(shù),自動(dòng)從斷點(diǎn)繼續(xù)。
4.5 進(jìn)度精度:為什么是 toFixed(2)
進(jìn)度計(jì)算公式本身很簡(jiǎn)單:
this.downloadPercent = parseFloat(((loadedSize / totalSize) * 100).toFixed(2))
toFixed(2) 把百分比控制到兩位小數(shù),這樣 100MB 文件的最小分辨率約為 1MB × 0.01% ≈ 10KB。每次 reader.read() 返回的 chunk 大約是幾十 KB,進(jìn)度條每隔幾個(gè) chunk 就會(huì)有一次肉眼可見(jiàn)的變化,不會(huì)出現(xiàn)"卡在 99% 不動(dòng)"的體感。
配合 Element UI 的 el-progress 組件,用 format 屬性自定義顯示文本:
<el-progress :percentage="downloadPercent" :stroke-width="20" :text-inside="true" :format="percentFormat" />
percentFormat(pct) {
return pct.toFixed(2) + '%'
}這樣進(jìn)度條內(nèi)部會(huì)顯示 33.56% 這樣的精確數(shù)值,而不是默認(rèn)的 34%。
五、總結(jié)
這套方案本質(zhì)上做了一件事:把瀏覽器的持久化存儲(chǔ)能力和 HTTP 協(xié)議的分段傳輸能力結(jié)合起來(lái),讓大文件下載從"一次性內(nèi)存操作"變成"可暫停、可恢復(fù)、狀態(tài)可持久化的流式管道"。
實(shí)戰(zhàn)中值得記住的幾個(gè)點(diǎn):
- fetch + ReadableStream 是流式處理二進(jìn)制數(shù)據(jù)的基礎(chǔ)設(shè)施,
response.body.getReader()是拿到這塊能力的入口。 - IndexedDB 是前端唯一能存大容量二進(jìn)制數(shù)據(jù)的方案,但它基于事務(wù),有并發(fā)限制,封裝時(shí)要注意 Promise 化并確保每次操作后關(guān)閉連接。
- 斷點(diǎn)續(xù)傳的關(guān)鍵在于對(duì) 206 和 200 的分支處理,尤其是"服務(wù)器不支持 Range 時(shí)要清空舊數(shù)據(jù)"這個(gè)邏輯,遺漏了就會(huì)導(dǎo)致文件損壞。
- 進(jìn)度條只是 UI 層面的反饋,真正的可靠性在于 IndexedDB 中的數(shù)據(jù)完整性——每一塊 chunk 存入前做一次獨(dú)立的
ArrayBuffer拷貝,代價(jià)很小,收益是絕對(duì)的確定性。
到此這篇關(guān)于Vue 利用 IndexedDB 實(shí)現(xiàn)前端大文件斷點(diǎn)續(xù)傳的文章就介紹到這了,更多相關(guān)vue前端大文件斷點(diǎn)續(xù)傳內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- Vue3實(shí)現(xiàn)大文件上傳:切片上傳 / 斷點(diǎn)續(xù)傳 / 秒傳 / 暫?;謴?fù) / 全局并發(fā)控制
- django vue3實(shí)現(xiàn)大文件分段續(xù)傳(斷點(diǎn)續(xù)傳)
- Vue項(xiàng)目中大文件切片上傳實(shí)現(xiàn)秒傳與斷點(diǎn)續(xù)傳的詳細(xì)實(shí)現(xiàn)過(guò)程
- vue實(shí)現(xiàn)大文件切片斷點(diǎn)續(xù)傳
- Vue.js+express利用切片實(shí)現(xiàn)大文件斷點(diǎn)續(xù)傳
- vue?大文件分片上傳(斷點(diǎn)續(xù)傳、并發(fā)上傳、秒傳)
- vue實(shí)現(xiàn)大文件分片上傳與斷點(diǎn)續(xù)傳到七牛云
- Vue+Node實(shí)現(xiàn)大文件上傳和斷點(diǎn)續(xù)傳
- Vue 大文件上傳和斷點(diǎn)續(xù)傳的實(shí)現(xiàn)
相關(guān)文章
uniapp微信小程序webview和h5數(shù)據(jù)通信代碼示例
這篇文章主要介紹了uniapp微信小程序webview和h5數(shù)據(jù)通信的相關(guān)資料,文章還列出了項(xiàng)目的結(jié)構(gòu),包括微信小程序和h5端的主要文件和組件,文中通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下2025-01-01
vue+mockjs模擬數(shù)據(jù)實(shí)現(xiàn)前后端分離開(kāi)發(fā)的實(shí)例代碼
本篇文章主要介紹了vue+mockjs模擬數(shù)據(jù)實(shí)現(xiàn)前后端分離開(kāi)發(fā)的實(shí)例代碼,具有一定的參考價(jià)值,有興趣的可以了解一下2017-08-08
Vue3+Element Plus進(jìn)行圖片加載優(yōu)化全攻略
在Web開(kāi)發(fā)中,未優(yōu)化的圖片會(huì)導(dǎo)致很多問(wèn)題,本文將為大家介紹一下Vue3如何通過(guò)Element Plus進(jìn)行圖片加載優(yōu)化,希望對(duì)大家有所幫助2025-03-03
Vue+Element一步步實(shí)現(xiàn)動(dòng)態(tài)添加Input_輸入框案例
這篇文章主要介紹了Vue+Element一步步實(shí)現(xiàn)動(dòng)態(tài)添加Input_輸入框案例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-09-09
vue項(xiàng)目打包優(yōu)化的方法實(shí)戰(zhàn)記錄
最近入職了新公司,接手了一個(gè)新拆分出來(lái)的Vue項(xiàng)目,針對(duì)該項(xiàng)目做了個(gè)打包優(yōu)化,把經(jīng)驗(yàn)分享出來(lái),下面這篇文章主要給大家介紹了關(guān)于vue項(xiàng)目打包優(yōu)化的相關(guān)資料,文中通過(guò)實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-08-08
Vue render渲染時(shí)間戳轉(zhuǎn)時(shí)間,時(shí)間轉(zhuǎn)時(shí)間戳及渲染進(jìn)度條效果
這篇文章主要介紹了Vue render渲染時(shí)間戳轉(zhuǎn)時(shí)間,時(shí)間轉(zhuǎn)時(shí)間戳及渲染進(jìn)度條效果,通過(guò)實(shí)例代碼相結(jié)合的形式給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2018-07-07
詳解如何在Vue3+TS的項(xiàng)目中使用NProgress進(jìn)度條
本文主要介紹了詳解如何在Vue3+TS的項(xiàng)目中使用NProgress進(jìn)度條,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2022-06-06

