资讯详情 IndexedDB从入门到实战:打破localStorage容量上限的完整存储方案
📅 2026/10/17 21:06:49
在做数据量比较大的前端项目时我第一次被 localStorage 的 5MB 上限卡得死死的——几万条业务数据塞进去还没等序列化完成浏览器就开始报 QuotaExceededError。后来尝试用内存缓存兜底又扛不住页面刷新每次重新进入都要把接口重新拉一遍体验非常糟糕。真正把这个死结解开的是浏览器自带的 IndexedDB。它不算什么新东西IE 时代就已经存在只是因为原生 API 写起来啰嗦、事件回调绕来绕去劝退了一大批人。这篇文章不打算做文档翻译而是用一条完整的实战主线把 IndexedDB 的核心概念、增删改查、索引查询、游标遍历、高频坑位全部串起来最后再给出一套可以直接复用的 Promise 封装思路。内容适合所有做前端开发的读者特别是已经熟练使用 localStorage、但还没有深入用过 IndexedDB 的人。如果你正准备做离线应用、数据量较大的管理后台或者想把一部分接口数据缓存到本地减少请求这篇文章应该能帮你少走不少弯路。我会用一个书籍库存管理的小例子贯穿始终每个代码片段都是我实际跑通后精简下来的版本你可以在自己的项目里直接对照修改。1. 为什么是 IndexedDB前端存储选型的底层逻辑1.1 localStorage 装不下的场景才是它的主场先说一个朴素的道理localStorage 生来就不是为存储大量结构化数据设计的。它底层就是一块简单的键值存储空间值只能是字符串。存对象要先 JSON.stringify读出来再 JSON.parse这个过程中数据量小的时候无所谓一旦数据量上涨同步阻塞主线程的代价会非常明显。它的容量虽然在各个浏览器里不完全一致但普遍落在 5MB 到 10MB 这个区间超过就直接抛 QuotaExceededError。我在项目里第一次遇到这个报错时第一反应是压缩和裁剪字段后来发现只要业务数据持续增长压缩只是把爆炸时间往后推了几天。IndexedDB 把这些短板全部补上了。它是浏览器内置的一种 NoSQL 数据库采用对象仓库Object Store的方式存储数据底层走结构化克隆算法意味着你可以直接把对象、数组甚至 Blob、File 这类二进制数据存进去不需要自己做序列化。读写操作全部异步执行不会阻塞 UI 线程。容量方面现代浏览器通常允许单个站点使用磁盘空间的很大比例实际能到达的量级是几百 MB 甚至更高。更关键的是它原生支持索引和事务这已经不只是“存数据”而是上升到了“查数据”的层面。特性localStoragesessionStorageIndexedDB存储类型仅字符串仅字符串结构化对象读写方式同步阻塞同步阻塞异步回调典型容量5MB~10MB5MB~10MB数百MB以上索引不支持不支持原生支持事务不支持不支持支持查询能力全量遍历再过滤全量遍历再过滤游标、索引、范围查询表格看下来IndexedDB 在功能维度上几乎是碾压级的存在但代价就是 API 复杂度高。所以选型时不是“哪个强就用哪个”而是要看场景。我自己的判断标准很简单只要数据量可能超过几 MB、或者需要以结构化方式保存并做条件查询就直接上 IndexedDB如果只是存一个用户偏好、主题色、记住的 tab 状态localStorage 顺手就用没必要把简单问题复杂化。1.2 使用场景判断标准与选型清单从实际项目看IndexedDB 出场率最高的场景集中在这么几类第一类是离线优先应用比如笔记类、阅读类工具必须保证没有网络时核心数据仍然可用第二类是接口数据本地缓存把列表和详情接口的响应缓存一份在本地下次进入先渲染缓存再在后台更新第三类是表单草稿与复杂状态保存管理后台的用户经常录到一半刷新页面或者误关标签把中间状态落到 IndexedDB比 sessionStorage 稳妥得多第四类是上传任务队列浏览器断网或页面关闭后记录已上传和未上传的文件分片恢复会话时接着传。不太适合的场景也有。比如存储体积很小的全局配置、需要跨标签页实时同步的轻量消息这类应该优先考虑 BroadcastChannel 或者 localStorage 的 storage 事件以及需要服务端直接读取的数据。IndexedDB 的数据完全放在用户浏览器里服务端拿不到你只能在浏览器端消费。我把选型逻辑总结成了一句口诀小配置用 localStorage大对象、强查询、离线场景用 IndexedDB。这句话看起来简单但能帮你避开两个极端——要么什么都往 IndexedDB 里塞结果代码量爆炸维护困难要么数据已经明显超出容量上限还硬撑着用 localStorage最后只能靠删数据续命。动手之前不妨对照这个清单过一遍数据量是否可能超过 5MB是 - IndexedDB是否需要按字段做范围查询或排序是 - IndexedDB是否要存二进制、大对象或复杂结构是 - IndexedDB是否要求离线可用是 - IndexedDB只是简单的键值配置localStorage 足够2. 动手前必须吃透的四个核心概念2.1 数据库、仓库、索引和事务到底怎么配合IndexedDB 的四个核心概念是 Database、Object Store、Index、Transaction。我学它们的时候总记不住层级关系直到套了一个现实中的比喻才通透Database 是整个仓库Object Store 是仓库里的货架每条记录是货架上的货箱Index 是贴在货箱上的分类标签Transaction 则是搬运货箱时签的一纸任务单。一个数据库里可以同时存在多个 Object Store不同的数据领域建议分开存放。比如在书籍库存的例子中books 放书籍authors 放作者信息你可以为它们各建一个独立仓库。每个 Object Store 里存储的是由键和值组成的记录值可以是一个复杂的 JavaScript 对象键则是你指定的属性或者自动生成的数字。Index 建立在某个属性上相当于给这个属性做了一份额外目录查询时不用翻整个货架直接查目录就能定位。Transaction 包裹所有读写操作保证一组操作要么全部成功要么全部回滚。这个特性在需要同时更新多个仓库的场景里尤其重要——比如下单时既要扣库存又要生成订单任何一次写失败都不能留下半成品数据。虽然我们平时用单仓库存数据感受不到它的存在但只要涉及跨仓库的一致性事务就是唯一的保障。2.2 键路径设计决定查询效率的第一道关口键路径keyPath是创建 Object Store 时第一个要想清楚的决策。它决定了用什么字段作为记录的唯一标识。常见有两种模式一种是把对象里已有的一个属性设为键路径比如书籍的 isbn 号码另一种是不指定键路径、开启 autoIncrement让浏览器自动生成 1、2、3 这样的自增数字当键。前者适合业务上本来就有唯一标识的对象后者适合纯粹靠插入顺序管理的流水数据。这里有一个实操中很容易踩的细节autoIncrement 生成的键只保证在同一个 Object Store 内自增而且删除记录后已用过的数字不会回填。这是设计上为了避免复用旧键而产生脏数据。如果你把键路径设置成数据里并不存在的属性比如 createObjectStore(books, { keyPath: id }) 但写入的对象里根本没有 id 字段add 操作会直接抛 DataError。不少新手在这里卡半天第一反应是怀疑浏览器缓存其实是键路径和数据对不上。键还可以用数组组合比如 keyPath: [category, name]表示用两个字段的组合作为唯一键。这种组合键在某些按分组查询的场景很好用但副作用是不能单独把其中一个字段当主键来精确查询只能借助索引辅助。设计时要记住一句话主键路径负责唯一性索引负责查询性能两者不要混为一谈。2.3 事务模式选错你的数据可能写一半就没了IndexedDB 的事务有三种模式readonly、readwrite 和 versionchange。前两种好理解分别对应只读和可写创建时在 db.transaction(books, readwrite) 里指定。第三种 versionchange 比较特殊只在数据库版本升级时自动创建用来修改数据结构比如增删 Object Store 和索引。普通事务里永远不能修改结构这是浏览器层面的硬性约束。事务的自动提交机制是新手最容易翻车的地方。事务并不会等你显式调用 commit() 才结束——当事件循环里没有更多待处理的任务时事务会自动提交。所以你在一个事务的多个请求之间不能插入 setTimeout、Promise 等待等异步操作否则当前事务可能已经提交后面的请求就会抛 TransactionInactiveError。我早期就写过这样的代码在一个事务里先 get再 await 某个远程接口拿到结果后才 put第二次操作直接报错。正确的做法是尽快把一个事务里的操作执行完或者干脆拆成多个独立事务。另外要记住版本升级事务一旦失败整个数据库版本会回滚到升级前已经执行的 createObjectStore 相关操作也会整体撤销不会留下半套新结构。所以升级逻辑要写得保守一点每次先检查 database.objectStoreNames.contains() 再创建避免重复执行时报 ConstraintError。3. 完整实战从打开数据库到复杂查询这一段用书籍库存管理作为贯穿案例把从零开始接入 IndexedDB 的流程完整走一遍。我们的目标是本地维护一个 books 仓库每条记录包含 id、title、author、price、category 字段支持按价格区间查询、按分类筛选、遍历全部记录。3.1 打开数据库与升级逻辑的完整走读打开数据库是整个流程的第一步几乎所有连接级的问题都发生在这里。下面是我常用的打开函数let db null; function openDatabase() { return new Promise((resolve, reject) { const request indexedDB.open(bookStore, 1); request.onupgradeneeded function (event) { const database event.target.result; if (!database.objectStoreNames.contains(books)) { const store database.createObjectStore(books, { keyPath: id, autoIncrement: true }); store.createIndex(title, title, { unique: false }); store.createIndex(price, price, { unique: false }); store.createIndex(category, category, { unique: false }); } }; request.onsuccess function (event) { db event.target.result; resolve(db); }; request.onerror function (event) { reject(event.target.error); }; request.onblocked function () { console.warn(bookStore 被其他标签页占用无法完成升级); }; }); }indexedDB.open(bookStore, 1) 里的第二个参数是数据库版本号必须是一个大于 0 的整数。第一次打开时不管写 1 还是 2浏览器都会创建一个新库并触发 onupgradeneeded之后再次打开只有当版本号大于当前版本时才会再次触发。版本号相等的 open 请求会直接走 onsuccess 拿到现有连接。这个机制的意义在于你可以在 onupgradeneeded 里安全地修改结构浏览器会保证同一时刻只有一个版本升级事务在跑。onblocked 很多人不重视但它会在一种非常实际的场景下出现你在这个标签页已经打开了 bookStore 的旧版本连接然后在另一个标签页发起了一个更高版本的升级请求旧连接不关闭浏览器就无法完成升级升级请求会一直挂起直到旧标签页关闭或者旧连接主动调用 db.close()。配合 onblocked 弹一个“请关闭其他标签页”的提示是成熟项目里必须做的处理。3.2 增删改查的标准姿势与事务边界有了 db 连接之后CRUD 是使用频率最高的操作。我习惯把每个操作都包成 Promise下面是四个基础函数function addBook(book) { return new Promise((resolve, reject) { const tx db.transaction(books, readwrite); const store tx.objectStore(books); const request store.add(book); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } function getBook(id) { return new Promise((resolve, reject) { const tx db.transaction(books, readonly); const store tx.objectStore(books); const request store.get(id); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } function updateBook(book) { return new Promise((resolve, reject) { const tx db.transaction(books, readwrite); const store tx.objectStore(books); const request store.put(book); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } function deleteBook(id) { return new Promise((resolve, reject) { const tx db.transaction(books, readwrite); const store tx.objectStore(books); const request store.delete(id); request.onsuccess () resolve(); request.onerror () reject(request.error); }); }这里要注意 add 和 put 的区别add 在键已存在时会直接报 ConstraintError适合新增场景put 则相当于“存在就更新、不存在就插入”特别适合把服务端拉到的整包数据同步进本地。我个人的习惯是用户手工新增用 add服务端数据同步用 put这样能天然规避重复插入的问题。除了单个操作批量场景还有几个顺手的方法getAll() 可以一次取回仓库里的所有记录适合数据量不大的场合getAllKeys() 只取所有键适合做存在性判断count() 返回记录总数。它们的用法和 get 一样是发起一个请求在 onsuccess 里拿结果。关于事务边界再强调一个关键点transaction 对象在创建时指定了涉及的仓库和模式但你通过 objectStore(books) 拿到的 store 引用只能在当前事务存活期间使用不能缓存长期复用。事务一旦自动提交再用这个引用发起请求就会报 InvalidStateError。我踩过的坑之一就是把 store 存成了模块级变量结果第二次使用直接崩溃。正确做法是每个操作都新建 transaction 和 store虽然重复代码多一点但这是 IndexedDB 的并发模型决定的。3.3 游标 索引 IDBKeyRange高性能查询三件套单条记录的增删改查只是基础IndexedDB 真正值钱的能力在于条件查询。按价格区间筛选是最典型的例子function getBooksByPrice(min, max) { return new Promise((resolve, reject) { const tx db.transaction(books, readonly); const store tx.objectStore(books); const index store.index(price); const range IDBKeyRange.bound(min, max); const request index.getAll(range); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }IDBKeyRange 是用来描述查询范围的对象所有带 openCursor 或 get 的请求都可以传一个进去。它支持的构造方式很直观IDBKeyRange.only(前端); // 精确匹配 IDBKeyRange.lowerBound(30); // 大于等于 30 IDBKeyRange.lowerBound(30, true); // 大于 30排除边界 IDBKeyRange.upperBound(80); // 小于等于 80 IDBKeyRange.bound(30, 80); // 30 到 80 闭区间 IDBKeyRange.bound(30, 80, true, true); // 30 到 80 开区间理解 IDBKeyRange 之后你会发现 IndexedDB 的查询能力被严重低估了——完全可以用索引加范围做区间筛选、前缀匹配而不用像 localStorage 那样把全量数据拉回来再 filter。游标Cursor则是在需要逐条处理数据时的核心工具。openCursor 返回的不是一次性结果集而是一个可以向前移动的指针配合 cursor.continue() 一条条往下走function walkBooks(callback) { return new Promise((resolve, reject) { const tx db.transaction(books, readonly); const store tx.objectStore(books); const request store.openCursor(); request.onsuccess function (event) { const cursor event.target.result; if (cursor) { callback(cursor.value); cursor.continue(); } else { resolve(); } }; request.onerror () reject(request.error); }); }这种设计在数据量较大时优势明显内存占用稳定不会一次性把几十万条记录全部装进内存。在游标循环里你还可以调用 cursor.update(newValue) 或 cursor.delete() 就地修改和删除记录免去“先查出来再拼一个写事务去改”的来回折腾。索引方面还有一个容易被忽略的参数multiEntry。它只对数组类型的索引字段有效。如果你把字段设计成数组比如书籍的 tags: [前端, 笔记]建索引时加上 { multiEntry: true }浏览器会针对数组里的每个元素分别建立索引条目按标签查书会非常方便。但要清楚加了 multiEntry 的索引不能再对同一字段设 unique 约束这是浏览器的硬性限制。4. 高频踩坑实录与排错指南4.1 升级失败、数据丢失、事务异常排查先给出一张速查表都是我实际遇到过、也经常在一线项目里见到别人踩的问题现象常见原因处理方式onupgradeneeded 不触发版本号没有变大把版本号改成大于当前值的整数打开数据库请求一直挂起旧标签页持有旧连接监听 onblocked引导用户关闭旧页面add 报 ConstraintError主键已经存在改用 put或者先查询再决定报 TransactionInactiveError事务里等待了其他异步任务不要在事务内 await 外部操作拆事务数据疑似丢失浏览器清理站点数据/升级删了仓库不要把唯一副本只存在客户端store 引用复用报 InvalidStateError事务结束后继续使用旧引用每个操作都新建事务和 store排错的第一步永远是打开开发者工具看数据。浏览器 DevTools 的 Application 面板里IndexedDB 一栏会列出当前站点所有数据库、仓库和记录可以手动查看数据、删除数据库、刷新视图。我排查“数据丢了”之类的反馈时习惯先在面板里确认底层数据到底在不在再判断是业务代码的问题还是存储本身的问题。很多所谓的数据丢失其实只是查询条件写错数据一直好好地躺在库里。这里单独展开两个最容易迷糊的点。第一点是版本升级的破坏性如果你在 onupgradeneeded 里删除了某个 Object Store这个仓库里的所有数据会立即消失而且这个操作没有撤销按钮。所以线上升级逻辑一定要先备份数据或者至少在代码层面保证删除动作是明确经过评估的。第二点是升级事务的原子性版本升级事务一旦失败数据库整体回滚到升级前状态不会出现“新建了一部分仓库”的中间状态这一点既是保护也是麻烦因为排查问题时你会发现升级失败像没发生过一样。4.2 浏览器差异与兼容性避坑现代浏览器对 IndexedDB 的支持已经相当统一主流桌面端和移动端都能用但有两个历史遗留问题值得知道早期版本的 iOS Safari 在隐私模式无痕浏览下IndexedDB 操作可能直接抛错或者静默失败这在景区导览、共享设备这类公共浏览场景里会踩到。另一个问题是不同浏览器对存储上限和清理策略不统一有的按磁盘空间百分比分配有的配额策略比较保守。所以在面向大量非技术用户的页面里写入前最好用存储估算接口探一下底if (navigator.storage navigator.storage.estimate) { const estimate await navigator.storage.estimate(); console.log(已使用:, estimate.usage, 配额:, estimate.quota); }检测到配额不足、或者 IndexedDB 完全不可用时一个稳妥的兜底是退回 localStorage 或 sessionStorage。虽然容量小、同步阻塞但至少保证核心流程不中断。另外要知道浏览器在磁盘空间紧张时可能自动清除站点数据调用 navigator.storage.persist() 可以向浏览器申请保留本地数据但这个申请是否被批准完全由浏览器决定不能当成可靠的持久化契约。真正重要的数据还是要同步回服务端本地只当副本。5. 封装成实用模块与配额管理5.1 用 Promise 封装 IDB 操作的通用方案原生 IndexedDB 的事件回调写起来确实啰嗦但只要用一层薄薄的 Promise 包住使用体验会立刻上一个台阶。下面是一个针对单个 Object Store 的通用封装function createStoreClient(storeName) { const withStore (mode, action) { const tx db.transaction(storeName, mode); const store tx.objectStore(storeName); return new Promise((resolve, reject) { const request action(store); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }; return { add: (value) withStore(readwrite, (store) store.add(value)), put: (value) withStore(readwrite, (store) store.put(value)), get: (key) withStore(readonly, (store) store.get(key)), delete: (key) withStore(readwrite, (store) store.delete(key)), getAll: () withStore(readonly, (store) store.getAll()) }; }这个封装能成立的前提是事务在同步代码里创建请求也同步发起Promise 在 onsuccess 里 resolve。调用端可以这样用const booksStore createStoreClient(books); await booksStore.add({ title: IndexedDB 实战笔记, price: 59, category: 前端 }); const book await booksStore.get(1);原生回调被收敛成了 async/await 风格业务代码的可读性会好很多。我的原则是只做薄封装不要过度抽象成复杂的查询 DSL。见过不少项目死于“封装太厚”——页面上报问题时数据明明就在库里排查代码却要翻三层抽象才能看到底层的 request 对象。薄封装保留原生能力出了问题能直接在浏览器面板和调用栈里定位维护成本最低。5.2 存储配额、过期清理与隐私模式IndexedDB 没有内置的过期机制数据只要不被手动删除就永久存在所以清理逻辑必须自己设计。一个简单可靠的做法是给每条记录附带 timestamp 字段然后定期用游标扫描删除超过 N 天的旧数据。为了避免每次启动都全量扫描可以在一个专用的配置仓库里存上次清理时间超过一定间隔才执行一次清理任务。另外一点要反复强调IndexedDB 的数据完全在用户本地它最合适的角色是“缓存”和“离线副本”而不是服务端数据库的替代品。我见过有团队把用户产生的操作记录只存在本地结果用户换设备或者浏览器清理过数据业务直接陷入瘫痪。正确姿势是本地做中间态的持久化同时通过同步机制把数据推给服务端让服务端始终是权威数据源。最后补充一个使用习惯上的建议不要长期持有数据库连接。db 对象在页面生命周期内可以重复使用但如果你确定不再需要应当调用 db.close()。同时监听 db.onversionchange 事件当浏览器准备升级或删除数据库时主动释放连接这能避免很多诡异的挂起和阻塞问题。我个人每次在新项目里接 IndexedDB都会先花十几分钟把要存储的字段、查询方式和关键索引列成一张表再动手写代码。这个前置动作往往比后面所有调试省的时间都多。如果你正在纠结要不要引入它建议先拿一个真实的业务页面做小验证把接口数据缓存到本地刷新页面直接读取跑通了再铺开。前端存储这块阵地IndexedDB 值得你认真用起来。