Vue 实战:利用 IndexedDB 实现前端大文件断点续传
、背景与痛点前端下载大文件时我们通常的做法是一行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拷贝代价很小

相关新闻

云平台 OCR(云端 API OCR)完整讲解

云平台 OCR(云端 API OCR)完整讲解

云平台 OCR(云端 API OCR)完整讲解 一、什么是云平台 OCR 各大云厂商(百度智能云、阿里云、腾讯云、华为云、谷歌云等)托管在云端服务器的 OCR 识别服务,开发者不用本地部署任何模型、推理库,仅通过 HTTP/HTTPS 网络接口上传图片,云端完成全部文字检测 + 识别,返回结…

2026/7/5 19:47:52 阅读更多 →
如何用Scan Tailor实现文档数字化的终极指南:让老旧扫描文档重获新生

如何用Scan Tailor实现文档数字化的终极指南:让老旧扫描文档重获新生

如何用Scan Tailor实现文档数字化的终极指南:让老旧扫描文档重获新生 【免费下载链接】scantailor 项目地址: https://gitcode.com/gh_mirrors/sc/scantailor 在数字化浪潮席卷全球的今天,你是否还在为堆积如山的老旧扫描文档而烦恼?…

2026/7/5 19:45:47 阅读更多 →
BLAST安全最佳实践:10个关键步骤保护你的AI浏览服务 [特殊字符]️

BLAST安全最佳实践:10个关键步骤保护你的AI浏览服务 [特殊字符]️

BLAST安全最佳实践:10个关键步骤保护你的AI浏览服务 🛡️ 【免费下载链接】blast Open-source VMs-as-a-service 项目地址: https://gitcode.com/gh_mirrors/blast14/blast 在当今AI技术快速发展的时代,BLAST作为开源的高性能Web浏览A…

2026/7/5 19:43:46 阅读更多 →

最新新闻

大数据原生集群 (Hadoop2.X为核心) 本地测试环境搭建二

大数据原生集群 (Hadoop2.X为核心) 本地测试环境搭建二

上一篇补充小提示 根据上一篇安装好虚拟机和系统之后,在安装软件之前我有两个对于虚拟机的注意点想送给大家,大家可以不看,但是后期在虚拟机的使用上或许对你有帮助 一、在安装配置集群的时候,涉及到不同机器之间有关IP地址的设…

2026/7/5 21:30:36 阅读更多 →
英雄联盟智能助手Seraphine:5分钟快速上手的游戏增强工具

英雄联盟智能助手Seraphine:5分钟快速上手的游戏增强工具

英雄联盟智能助手Seraphine:5分钟快速上手的游戏增强工具 【免费下载链接】Seraphine 英雄联盟战绩查询工具 项目地址: https://gitcode.com/gh_mirrors/se/Seraphine 你是否厌倦了在英雄联盟中手动查询对手战绩、错过对局接受,或是在BP阶段手忙脚…

2026/7/5 21:26:35 阅读更多 →
求自然对数e的近似值

求自然对数e的近似值

【问题描述】求自然对数e的近似值,当任意项的值小于10-4时结束计算,近似公式为:【输入形式】无 【输出形式】可参考:print("e的近似值值为:{:.6f}".format(e))【样例输入】 【样例输出】 【样例说明】 【评分…

2026/7/5 21:26:35 阅读更多 →
Redis 主从复制,哨兵,集群——(2)哨兵篇

Redis 主从复制,哨兵,集群——(2)哨兵篇

目录 一. Redis 哨兵是什么? 二. Redis 哨兵有什么用? 三. Redis 哨兵数量配备要求 四. 哨兵配置文件详解 五. quorum 投票数详解 5.1 quorum 的含义 5.2 网络抖动导致主观下线 5.3 quorum 票数达到设定值客观下线 六. 最好让所有 redis 服务器…

2026/7/5 21:24:35 阅读更多 →
如何从huggingface快速下载

如何从huggingface快速下载

插播广告一条😂🐶:我制作的一个免费语音识别网站,欢迎体验! 方法一:使用Access Tokens # 安装准备 pip install huggingface-hub # 先登录,它会提示你输入你的 Hugging Face 访问令牌 (Access …

2026/7/5 21:24:35 阅读更多 →
从混乱到优雅:SQL Formatter如何让你的数据库查询代码焕然一新

从混乱到优雅:SQL Formatter如何让你的数据库查询代码焕然一新

从混乱到优雅:SQL Formatter如何让你的数据库查询代码焕然一新 【免费下载链接】sql-formatter A whitespace formatter for different query languages 项目地址: https://gitcode.com/gh_mirrors/sql/sql-formatter 你是否曾面对过同事提交的SQL代码&#…

2026/7/5 21:22:34 阅读更多 →

日新闻

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容 【免费下载链接】BiliTools A cross-platform bilibili toolbox. 跨平台哔哩哔哩工具箱,支持下载视频、番剧等等各类资源 项目地址: https://gitcode.com/GitHub_Trending/bilit/BiliTools …

2026/7/5 0:03:34 阅读更多 →
威胁模型全解析:从新手入门到实战应用,助你构建安全产品!

威胁模型全解析:从新手入门到实战应用,助你构建安全产品!

威胁模型的陌生现状在忙碌疲惫的一天里,参与了关于混合后量子密码学的讨论,应付端点攻击找茬的人,还参与留言板讨论后,发现“威胁模型”对多数人仍是陌生概念,且多被当作时髦用语。有趣的相关画作有一幅由 Embyr 创作的…

2026/7/5 0:03:34 阅读更多 →
渗透测试入门指南:从零基础到实战环境搭建

渗透测试入门指南:从零基础到实战环境搭建

1. 从“看热闹”到“入门”:我理解的渗透测试到底是什么?每次看到新闻里说某个大公司的数据被“黑”了,或者某个网站被攻击导致服务瘫痪,你是不是和我一样,心里会冒出两个念头:一是“这黑客真厉害”&#x…

2026/7/5 0:07:38 阅读更多 →

周新闻

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容 【免费下载链接】BiliTools A cross-platform bilibili toolbox. 跨平台哔哩哔哩工具箱,支持下载视频、番剧等等各类资源 项目地址: https://gitcode.com/GitHub_Trending/bilit/BiliTools …

2026/7/5 0:03:34 阅读更多 →
威胁模型全解析:从新手入门到实战应用,助你构建安全产品!

威胁模型全解析:从新手入门到实战应用,助你构建安全产品!

威胁模型的陌生现状在忙碌疲惫的一天里,参与了关于混合后量子密码学的讨论,应付端点攻击找茬的人,还参与留言板讨论后,发现“威胁模型”对多数人仍是陌生概念,且多被当作时髦用语。有趣的相关画作有一幅由 Embyr 创作的…

2026/7/5 0:03:34 阅读更多 →
渗透测试入门指南:从零基础到实战环境搭建

渗透测试入门指南:从零基础到实战环境搭建

1. 从“看热闹”到“入门”:我理解的渗透测试到底是什么?每次看到新闻里说某个大公司的数据被“黑”了,或者某个网站被攻击导致服务瘫痪,你是不是和我一样,心里会冒出两个念头:一是“这黑客真厉害”&#x…

2026/7/5 0:07:38 阅读更多 →

月新闻