
、背景与痛点前端下载大文件时我们通常的做法是一行fetch拿到 response转成 Blob再丢给一个隐藏的a标签触发下载。这套逻辑在几十 KB 的图片、几百 KB 的 PDF 上完全没问题。可一旦文件跑到 100MB、1GB问题就来了浏览器内存扛不住。fetch 把整个 response body 读到内存数组里100MB 的文件就是 100MB 的堆内存占用稍微大一点就直接 OOM。刷新归零。进度条跑到 80%用户手滑刷新了页面——从头再来已经下载的数据全丢了。无法暂停恢复。浏览器原生没有暂停下载的概念fetch 一旦发起要么等到结束要么 abort 掉全部作废。这背后本质上是两个问题流式数据怎么持久化以及已持久化的数据怎么在下次请求时被服务器认账。第一个问题用 IndexedDB 解决第二个问题靠 HTTP Range 请求。本文基于一个完整的 Vue 2 Element UI 实战项目把这两块串起来讲清楚如何实现一个带持久化存储、支持断点续传、进度精确到小数点后两位的大文件下载方案。二、核心原理Range 请求与 206 响应HTTP 协议从 1.1 开始就定义了一个叫Range的请求头语义很简单告诉服务器我只要这个文件的一部分。Range: bytes5000000-上面这行表示从第 5,000,000 个字节开始把后面的内容都给我。服务器如果支持分段传输会返回206 Partial Content同时在Content-Range响应头里告诉客户端三件事本次数据的起止位置以及文件的完整大小。Content-Range: bytes 5000000-104857599/104857600格式是bytes 起始-结束/总大小。这个 header 是整个断点续传的核心——客户端靠它知道文件有多大以及自己还差多少。如果服务器不支持 Range比如某些 CDN 或静态文件服务器没开这个能力它会忽略 Range 头照常返回200和完整文件。所以我们的代码需要兼容两种情况206 就走续传逻辑200 就清空旧数据从头来。三、存储方案选型为什么不用 LocalStorage有人会想浏览器不是有 LocalStorage 吗5MB 够不够答案是一票否决的——LocalStorage 只能存字符串不能存二进制数据。对比维度LocalStorageIndexedDB容量上限5-10MB浏览器可用磁盘的 50%-80%数据类型仅字符串String、Blob、ArrayBuffer、File存储模式同步阻塞主线程异步不阻塞 UI查询能力仅 key-value支持索引、游标遍历对于大文件下载场景核心要求有三个必须存二进制。fetch 流式读取出来的是Uint8Array二进制块LocalStorage 根本塞不进去。容量必须大。一个 100MB 的测试文件存进去就是 100MB。LocalStorage 的 5MB 上限连塞牙缝都不够。必须异步。你不想每次写盘都卡 UI 渲染。IndexedDB 是浏览器内置的事务型对象数据库支持结构化克隆算法ArrayBuffer可以直接作为值存入这些特性让它天然适合做文件分片的持久化。四、实战代码拆解4.1 项目架构简述src/ ├── db/downloadDB.js # IndexedDB 操作封装 ├── store/index.js # Vuex 状态管理 ├── App.vue # 根实例核心下载逻辑 └── components/DownloadChild.vue # 下载按钮子组件DownloadChild通过 Vuexdispatch(requestDownload, { url, filename })发起下载指令App.vue通过 computed 属性监听 Vuex 中downloadTask.timestamp的变化触发实际下载downloadDB.js封装所有 IndexedDB CRUD 操作与业务逻辑解耦4.2 数据库设计首先看数据库结构——一个 object store一个索引足以支撑整个断点续传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) }) }每条记录包含五个字段id自增主键、filename用于索引查询、chunkIndex分片序号用于最终排序合并、dataArrayBuffer实际二进制数据、size当前分片的字节数用于进度累加。选择autoIncrement主键而非[filename, chunkIndex]复合主键是因为 IndexedDB 的复合主键在游标遍历时性能不如单字段索引而我们的clearChunks操作依赖游标删除这是写入频率较高的路径。4.3 核心下载流程Range 请求 流式持久化整个startDownload方法是断点续传的核心我按执行顺序拆成五个阶段来讲解。阶段一查询已下载字节let loadedSize await getDownloadedSize(filename)这里遍历 IndexedDB 中该文件的所有 chunk累加size字段求和。即使之前页面刷新过、甚至浏览器重启过只要 IndexedDB 中的数据还在就能准确拿到已经下载了多少字节。阶段二构造 Range 请求const headers {} if (loadedSize 0) { headers[Range] bytes${loadedSize}- } const response await fetch(url, { signal: this.abortController.signal, headers })如果loadedSize为 0首次下载或之前已清理就不带 Range 头服务器返回完整文件。如果大于 0告诉服务器从第 N 个字节开始给我。这里同时传入AbortController的 signal方便用户取消下载时中断 fetch。阶段三处理 206 vs 200let 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}) }这是整个方法里最容易踩坑的分支逻辑206服务器认了 Range返回部分内容。我们从Content-Range: bytes 5000000-104857599/104857600中取斜杠后面的104857600作为文件总大小。注意不是Content-Length——206 响应中的 Content-Length 只表示本次返回的 body 大小不是完整文件大小。200服务器不支持 Range。如果此时 IndexedDB 里还有旧数据必须清空因为之前存的 chunk 和现在返回的完整数据会产生重叠和错乱。其他非 2xx直接抛异常交给 catch 处理。阶段四流式读取并逐块写入 IndexedDBconst 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()返回一个{ done, value }对象value是一个Uint8Array大约是几 KB 到几十 KB 不等的二进制块。关键细节在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可能共享一个更大的底层ArrayBuffer直接存入 IndexedDB 会导致数据不完整或错乱。做一个拷贝虽然多花了一点内存但保证了数据的正确性。阶段五合并分片触发下载清理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升序取出所有分片每个分片还原成Uint8Array直接传给Blob构造函数。Blob可以接受Uint8Array[]数组会自动拼接。下载完成后务必调用clearChunks——既释放用户磁盘空间也避免下次下载时 IndexedDB 里残留旧数据。清理逻辑使用游标遍历删除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) } }) }这里用游标而不是index.getAll() 逐条store.delete()的原因有两个一是游标一次只持有当前记录内存友好二是游标删除在一次事务内完成要么全部成功要么全部回滚。4.4 取消即保存注意代码中startDownload的 catch 分支catch (err) { if (err.name AbortError) { this.progressStatus exception return // 不清理 IndexedDB } // ... }AbortError代表用户主动取消——此时 IndexedDB 中的数据不会被清理。用户下次点击下载按钮getDownloadedSize会读到之前累积的字节数自动从断点继续。4.5 进度精度为什么是 toFixed(2)进度计算公式本身很简单this.downloadPercent parseFloat(((loadedSize / totalSize) * 100).toFixed(2))toFixed(2)把百分比控制到两位小数这样 100MB 文件的最小分辨率约为 1MB × 0.01% ≈ 10KB。每次reader.read()返回的 chunk 大约是几十 KB进度条每隔几个 chunk 就会有一次肉眼可见的变化不会出现卡在 99% 不动的体感。配合 Element UI 的el-progress组件用format属性自定义显示文本el-progress :percentagedownloadPercent :stroke-width20 :text-insidetrue :formatpercentFormat /percentFormat(pct) { return pct.toFixed(2) % }这样进度条内部会显示33.56%这样的精确数值而不是默认的34%。五、总结这套方案本质上做了一件事把浏览器的持久化存储能力和 HTTP 协议的分段传输能力结合起来让大文件下载从一次性内存操作变成可暂停、可恢复、状态可持久化的流式管道。实战中值得记住的几个点fetch ReadableStream是流式处理二进制数据的基础设施response.body.getReader()是拿到这块能力的入口。IndexedDB 是前端唯一能存大容量二进制数据的方案但它基于事务有并发限制封装时要注意 Promise 化并确保每次操作后关闭连接。断点续传的关键在于对 206 和 200 的分支处理尤其是服务器不支持 Range 时要清空旧数据这个逻辑遗漏了就会导致文件损坏。进度条只是 UI 层面的反馈真正的可靠性在于 IndexedDB 中的数据完整性——每一块 chunk 存入前做一次独立的ArrayBuffer拷贝代价很小