资讯详情

资讯详情

IndexedDB基本使用指南:核心概念与离线存储实战解析

1. 项目概述为什么是IndexedDB1.1 从“存储不够用”说起IndexedDB要解决什么问题做前端开发这几年存储方案从cookie到localStorage再到IndexedDB我基本都踩过一遍。如果你只是存个token、存个用户偏好localStorage确实够用毕竟它API简单5MB的容量对轻量数据也绰绰有余。但一旦你开始做Web应用的数据本地化比如离线缓存数据、同步增量数据、存储文件或结构化JSON对象localStorage就会立刻暴露出两个硬伤一是容量确实有限数据一多就容易顶到天花板二是它只能存字符串每次存对象都得手动JSON.stringify取出来还得parse多套一层不说处理复杂数据时代码会变得很啰嗦。所以我说IndexedDB基本使用是每个做稍微复杂点的前端项目迟早要补的一课它本质上是浏览器内置的非关系型数据库事务型的支持索引、游标、范围查询这些正经数据库才有的能力。它可以存几乎所有结构化数据包括File、Blob、ArrayBuffer这种二进制。大小以百MB甚至GB计的存储空间对有离线化需求的重度应用来说是真能扛事的方案。1.2 这篇内容适合谁看如果你“indexedDB基本使用”还停留在“听说过但不太清楚怎么上手”的状态或者之前看过一些文章但对着代码还是有点懵那就很适合读下去。这篇不是单纯把API列一遍就算完而是从“为什么这样设计”和“实操踩坑”两个角度去拆。我会把核心概念用生活化的类比讲清楚把每段代码为什么这么写、去掉会出什么问题也说透最后用一个完整的离线备忘录案例把所有API串起来。内容里会包含完整代码和表格化的参数说明所以哪怕你之前没碰过IndexedDB跟着敲一遍也能跑通。有后端数据库经验的人读到事务和索引部分会很有熟悉感但你会发现IndexedDB的API虽然在浏览器里坑和特性却一点不少。2. 核心概念拆解五个名词背后的设计逻辑2.1 数据库、对象仓库、键——和MySQL的类比IndexedDB里有几个名词很容易劝退初学者其实类比一下MySQL你会发现逻辑就顺了。数据库DB对应MySQL里的database一个域名下可以建多个database。对象仓库Object Store对应数据库里的table。但它更接近一张没有固定表结构的表你可以往同一个Store里塞结构不同的对象只要它们的key不冲突即可。这一点像极了MongoDB的collection。键Key对应主键每条记录的标识符。默认会生成自增的数值键也可以指定任意目标类型比如字符串、日期、甚至数组。键的生成策略在创建Object Store时定好后面不能随便改。不过这里有个坑IndexedDB没有SQL数据筛选全靠索引和游标。所以当你设计Object Store的时候先得想清楚哪些字段会被“查”再决定要么作为key要么建立索引。一旦建好并写入了数据索引结构很难改真需要改也只能升级数据库版本。2.2 事务Transaction的现实价值事务这个词听起来很后端但在IndexedDB里却非常直观。你每次读或写都必须通过一个事务对象去操作。事务有四种模式readonly、readwrite、versionchange、readwriteflush。常用的就前面两种。我在实际使用indexedDB基本操作中的体会是事务的存在不仅是为了保证数据一致性更是把“原子性”这个特性直接暴露给了前端。比如批量写入多条记录中间一旦有一条失败前面的写入会一并回滚——这在离线场景里是很重要的保证。做个不碍事的类比事务就像一份“购物清单”要么所有东西都买齐买单成功要么中途发现某样东西缺货整单取消不会出现少了一样还硬要付款的情况。2.3 游标Cursor和索引Index的配合索引是查询性能的命脉。没有索引时你想按用户名查找一条数据就得全表遍历数据量上去了体验就很差。有了索引后就可以直接基于索引字段做精确匹配或范围遍历。而游标则是遍历的入口它本质上是一个指向当前记录的结构每调用一次continue()就跳到下一条循环往复直到结束。配合索引你就能实现“找age大于25的所有用户并按age排序”这样的需求。我第一次用游标时犯过一个错忘记在回调里调用continue()导致只读到了第一条记录就停住。后来知道了游标不是自动迭代的它更像手动挡你得自己挂挡加速。3. 基础操作详解一文跑通增删改查3.1 打开数据库创建、升级、版本号那些事IndexedDB的入口就是indexedDB.open(name, version)。name就是数据库名version是版本号类型是整数。第一次打开时onupgradeneeded会被触发这里是你创建Object Store和索引的唯一起点。之后每次你调高version打开同一个库也会触发onupgradeneeded用来执行结构变更的代码。let db null; function openDB(dbName, version) { return new Promise((resolve, reject) { const request indexedDB.open(dbName, version); request.onupgradeneeded (event) { db event.target.result; if (!db.objectStoreNames.contains(todos)) { const store db.createObjectStore(todos, { keyPath: id, autoIncrement: true }); store.createIndex(title_index, title, { unique: false }); store.createIndex(status_index, status, { unique: false }); } console.log(数据库结构升级完成); }; request.onsuccess (event) { db event.target.result; resolve(db); }; request.onerror (event) { reject(event.target.error); }; }); }几点细节值得说明onupgradeneeded里用event.target.result拿数据库实例而在onsuccess里用request.result拿。同一个request对象不同的事件里拿法基本一样这并不难记。但有一点数据库打开成功后监听块里要尽快把实例存到外部变量否则函数结束引用丢了后面就操作不了。用db.objectStoreNames.contains(todos)做判断可以避免重复建表。因为onupgradeneeded不只是第一次打开会触发每次升版本都会触发所以这个判断很关键否则升版本时会报“对象仓库已存在”的错误。3.2 新增和读取数据Promise封装与异步陷阱新增一条记录很简单把要存的对象放到事务里然后调store.add()或store.put()。add遇到相同key会报错put会更新或插入所以大部分场景我更喜欢用put。function addTodo(title, status pending) { return new Promise((resolve, reject) { const tx db.transaction(todos, readwrite); const store tx.objectStore(todos); const data { title, status, createdAt: Date.now() }; const req store.put(data); req.onsuccess () resolve(req.result); req.onerror () reject(req.error); tx.oncomplete () console.log(事务已完成); tx.onerror () reject(tx.error); }); }注意我之所以在req.onsuccess和tx.oncomplete两个地方都加了监听是因为单条写入的“成功”并不意味着事务一定成功。事务的成功要看整个事务流程是否走完你可以在事务结束时更安全地确认结果。在批量写入时这个区别就会显现出来。读取的时候分为“按主键读”和“按索引查”。按主键读function getTodoById(id) { return new Promise((resolve, reject) { const tx db.transaction(todos, readonly); const store tx.objectStore(todos); const req store.get(id); req.onsuccess () resolve(req.result); req.onerror () reject(req.error); }); }按索引查function getTodosByStatus(status) { return new Promise((resolve, reject) { const results []; const tx db.transaction(todos, readonly); const store tx.objectStore(todos); const index store.index(status_index); const req index.getAll(status); req.onsuccess () resolve(req.result); req.onerror () reject(req.error); }); }getAll是后来才有的API用起来非常方便。但要注意它在浏览器兼容性上不如游标稳定在部分老旧环境里可能没有这个方法。要稳还是得用游标去遍历。3.3 更新和删除数据游标遍历配合索引定位删除其实最直接store.delete(key)接主键就行。但如果你想删除满足某个条件的一批数据比如“所有已完成任务”那就得先查出来再逐个删。这里的查只能基于索引除非你删的是全表。function deleteByStatus(status) { return new Promise((resolve, reject) { const tx db.transaction(todos, readwrite); const store tx.objectStore(todos); const index store.index(status_index); const req index.openCursor(status); req.onsuccess (event) { const cursor event.target.result; if (cursor) { store.delete(cursor.primaryKey); cursor.continue(); } else { resolve(true); } }; req.onerror () reject(req.error); }); }这里大家注意下游标返回的对象上面有个primaryKey属性这才是记录真实的主键。如果你用cursor.value.id在keyPath不是id的store里就会出问题。所以每次用游标我都习惯用primaryKey来做删除依据。更新操作也没那么玄乎获取到旧数据改掉字段再放回store即可put会覆盖同key的旧记录。4. 完整案例用IndexedDB做一个离线待办备忘录4.1 需求分析和数据模型设计很多教程到API层面就停了但这恰恰是很多初学者卡住的地方API看得懂一碰到“具体做一个什么小功能”就不知道怎么组合。所以这一节我打算用案例把前面一切串起来。我们做一个纯前端的带状态待办备忘录。需求如下能新增待办项能标记完成/未完成能按状态筛选能将数据持久化在浏览器里刷新不丢。这里设计Object Store时字段类型和索引分配是这样的{ id: 自增主键, title: 字符串, status: pending | done, priority: high | medium | low, createdAt: 时间戳 }其中status和priority都需要建索引因为我们会按它们筛选。标题索引可建可不建如果后续要做模糊搜索可以考虑。4.2 逐步实现与代码解读第一步定义数据库结构和工具函数function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(offline-todos, 1); request.onupgradeneeded (event) { const db event.target.result; if (!db.objectStoreNames.contains(todos)) { const store db.createObjectStore(todos, { keyPath: id, autoIncrement: true }); store.createIndex(status_index, status, { unique: false }); store.createIndex(priority_index, priority, { unique: false }); store.createIndex(createdAt_index, createdAt, { unique: false }); } }; request.onsuccess (event) resolve(event.target.result); request.onerror (event) reject(event.target.error); }); }这个openDB()在页面初始化时调用一次把db实例保存到全局。注意不要在每次操作时重复open数据库连接是有成本的频繁开关不仅慢还可能造成版本冲突。第二步封装CRUD工具层这里我把上一节的基础操作抽象成比较通用的工具函数业务代码里只需要传参数调用const DB_NAME offline-todos; const STORE_NAME todos; let dbInstance null; async function getDB() { if (dbInstance) return dbInstance; return new Promise((resolve, reject) { const req indexedDB.open(DB_NAME, 1); req.onupgradeneeded (event) { const db event.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store db.createObjectStore(STORE_NAME, { keyPath: id, autoIncrement: true }); store.createIndex(status_index, status, { unique: false }); store.createIndex(priority_index, priority, { unique: false }); } }; req.onsuccess (event) { dbInstance event.target.result; resolve(dbInstance); }; req.onerror (event) reject(event.target.error); }); } function addItem(data) { return getDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_NAME, readwrite); const store tx.objectStore(STORE_NAME); const req store.put(data); req.onsuccess () resolve(req.result); req.onerror () reject(req.error); }); }); } function getAllItems() { return getDB().then((db) { return new Promise((resolve, reject) { const results []; const tx db.transaction(STORE_NAME, readonly); const store tx.objectStore(STORE_NAME); const req store.openCursor(); req.onsuccess (event) { const cursor event.target.result; if (cursor) { results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; req.onerror () reject(req.error); }); }); } function getItemsByStatus(status) { return getDB().then((db) { return new Promise((resolve, reject) { const results []; const tx db.transaction(STORE_NAME, readonly); const store tx.objectStore(STORE_NAME); const index store.index(status_index); const req index.openCursor(status); req.onsuccess (event) { const cursor event.target.result; if (cursor) { results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; req.onerror () reject(req.error); }); }); } function updateItemStatus(id, newStatus) { return getDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_NAME, readwrite); const store tx.objectStore(STORE_NAME); const req store.get(id); req.onsuccess (event) { const item event.target.result; if (!item) { reject(new Error(记录不存在)); return; } item.status newStatus; const updateReq store.put(item); updateReq.onsuccess () resolve(true); updateReq.onerror () reject(updateReq.error); }; req.onerror () reject(req.error); }); }); } function deleteItem(id) { return getDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_NAME, readwrite); const store tx.objectStore(STORE_NAME); const req store.delete(id); req.onsuccess () resolve(true); req.onerror () reject(req.error); }); }); }第三步界面交互逻辑UI部分我刻意简化重点看IndexedDB的调用方式async function handleAddTodo(title, priority) { const newItem { title, status: pending, priority, createdAt: Date.now() }; const id await addItem(newItem); console.log(已新增自增主键为, id); await refreshList(); } async function handleToggleStatus(id, currentStatus) { const nextStatus currentStatus pending ? done : pending; await updateItemStatus(id, nextStatus); await refreshList(); } async function handleDelete(id) { await deleteItem(id); await refreshList(); }你怎么写这个页面不重要重要的是这一层逻辑非常薄数据库操作全被封装在工具函数里。想换存储方案比如换成后端接口只需要改工具层不用去动界面渲染那一块。4.3 基于游标的批量操作和搜索增强待办任务多了以后你一定会遇到想批量操作的情况比如一次性把所有“done”状态的任务全部清除。批量操作时事务的作用就很明显了状态一旦开始中途失败会整体回滚省去不少麻烦。另外游标配合索引做范围查询也很典型。例如查所有优先级为high的任务function getHighPriorityItems() { return getDB().then((db) { return new Promise((resolve, reject) { const results []; const tx db.transaction(STORE_NAME, readonly); const store tx.objectStore(STORE_NAME); const index store.index(priority_index); const req index.openCursor(high); req.onsuccess (event) { const cursor event.target.result; if (cursor) { results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; req.onerror () reject(req.error); }); }); }如果你想查“priority为high且status为pending”这种复合条件IndexedDB的索引没法直接多字段组合。你可以分别建两个索引然后取交集也可以直接在游标回调里做条件判断。数据量不大时后者最省事也最不容易绕晕。req.onsuccess (event) { const cursor event.target.result; if (cursor) { const item cursor.value; if (item.priority high item.status pending) { results.push(item); } cursor.continue(); } else { resolve(results); } };这里我想多说一句不要一上来就追求“高级用法”。IndexedDB的游标回调里做条件过滤其实非常灵活比死磕各种索引组合更容易让人理解。等数据量确实大到过滤成为瓶颈再去研究复合索引或游标配合多个索引的方案也不迟。5. 实战中的常见问题与排查技巧5.1 很容易踩的版本号与onupgradeneeded的坑不少新手第一次运行代码时看到报错“VersionError: The requested version (2) is less than the existing version (1)”第一反应就是代码写错了。实际原因往往是你之前试过打开版本2数据库已经记录下来但你后来又把代码里的版本号改回1了。浏览器端的IndexedDB版本号只会向上记录不会降级所以数据库版本无论如何只能增不能减。解决方案是要么用新版本继续开发要么在控制台里找到该域名下的IndexedDB数据手动删除整个数据库后再重新初始化。清理调试数据最快的操作是打开DevTools的Application面板在Storage下的IndexedDB里右键删除。5.2 事务提前失效异步操作里的大忌事务和普通的回调不一样它是有生命周期的。你发起一个事务在事件循环里跑完当前一轮后如果事务里还有未完成的请求这个事务会标记为inactive后续再请求就会抛“TransactionInactiveError”。典型的错误写法是const tx db.transaction(STORE_NAME, readwrite); const store tx.objectStore(STORE_NAME); fetch(/api/data).then((res) { store.put(res.data); // 到这里事务大概率已经 inactive 了 });解决办法是不要将异步网络请求夹在事务中间你可以先请求网络数据等结果回来了再发起新的事务做写入。还有一个容易忽视的细节是不要在事务外的await之后使用之前事务创建的store实例。事务是“一次性的活”干完就散场了。5.3 游标遍历中修改数据导致缺漏的问题如果你在游标遍历过程中去修改当前记录然后再调用cursor.continue()在新版浏览器上一般没问题。但如果你修改记录后没有继续调用continue遍历就直接中止了。这不算bug而是使用习惯问题。但真正隐蔽的坑在于在一次遍历中批量更新数据时如果你更新的字段正好是游标当前正在遍历的索引键字段游标的顺序可能会被干扰。比如你正在按status索引遍历却把当前记录的status从“pending”改成“done”那这个记录可能在本轮遍历中被再次命中甚至导致循环无法结束。解决思路是先收集要更新的主键列表遍历结束或进入新的readwrite事务后再基于主键做批量更新。5.4 内存泄漏忘记关闭连接的问题IndexedDB的连接是浏览器底层维护的如果一个页面里你反复indexedDB.open()但又不做全局复用浏览器会为每次请求创建独立的连接对象。这些连接占用内存和文件句柄极端情况下会让标签页变卡。我的习惯是无论代码多简单都把db实例挂在一个全局引用上后面所有操作都复用这个引用。如果确实要做多标签页之间的同步就需要监听db.onversionchange事件主动关闭旧连接并提示用户刷新页面否则新标签页升级数据库版本的时候老标签页会一直占用连接导致升级一直等待。db.onversionchange () { db.close(); alert(页面数据版本已更新请刷新页面); };5.5 兼容性评估与降级方案IndexedDB在现代浏览器里已经全覆盖老旧的浏览器基本也支持。不过如果你需要兼容极其老旧的WebView内核就得提前判断一下if (!(indexedDB in window)) { alert(当前浏览器不支持IndexedDB请升级浏览器); }更保守的方案是用localStorage做降级存储但API要设计成能平滑切换不要在你的业务代码里到处直接引用indexedDB。这也是我为什么一直强调工具层封装的原因一旦存储层要替换改动只集中在很小的区域。6. 长期维护视角下的设计与性能建议6.1 对象仓库结构设计一开始就要想好后续演进IndexedDB最特别的地方在于对象仓库的结构只能在onupgradeneeded里改。一旦数据库版本定了、数据开始写入了后面再加索引只能通过升级版本来实现。因此在项目早期就应当尽量把可能用于筛选的字段都建上索引。索引不是越多越好每个索引都会占额外的存储空间写入时也会有额外的索引维护开销。我的经验是只为真实查询场景建索引不要为了“以后可能用到”而提前建一堆用不上的。新增索引可比删除索引好办多了索引一旦建好删除的代价也比较大还是得走版本升级的流程所以设计阶段多花几分钟理清查询需求非常值得。6.2 大数据量下的写入优化如果你一次性要写入几千条甚至上万条记录不要逐条发起独立的事务。这样每条记录一个事务光事务建立和销毁的开销就很可观。更好的做法是把数据分块比如每500条放进同一个readwrite事务里批量写这样可以成倍缩短写入时间。async function batchAddItems(items) { const db await getDB(); const tx db.transaction(STORE_NAME, readwrite); const store tx.objectStore(STORE_NAME); return new Promise((resolve, reject) { items.forEach((item) store.put(item)); tx.oncomplete () resolve(true); tx.onerror () reject(tx.error); }); }块的大小也不需要太讲究我一般放在500到1000条之间帧率不会卡顿太久写入稳定性也相对好控制。如果你要写入的是超大文件可以用readwriteflush模式这个模式会在写入完成后把数据强制刷到磁盘对数据安全性要求高的场景很有用。6.3 搜索功能与游标性能的平衡对Inde​​xedDB里的文本做模糊搜索目前没有原生的LIKE操作。常规做法是先用游标遍历全部记录再用JavaScript做字符串匹配includes、startsWith等。记录数达到几千条时这通常不会有什么感知延迟。但如果你有几万条以上就得考虑在写入时生成额外的搜索字段并建立索引或者把文本切词后存成数组字段再配合游标筛选。我做过一个案例某开发者需要在上万条项目记录里做关键词搜索一开始直接全表遍历加includes拉了列表要差不多900毫秒。后来他改成写入时存一份小写的标题字段并在索引上做前缀匹配把时间压到了200毫秒以内。所以基本原则是结构设计时预留一个用于搜索的规范化字段既能保持搜索功能完整又不至于写得太复杂。6.4 数据备份与导出IndexedDB的数据存在当前浏览器的客户端本地不代表永久安全。用户清缓存、换设备、隐私模式下数据都可能消失。对重度本地化应用来说理想做法是提供一个导出功能把整个Object Store的数据序列化成JSON或CSV文件让用户下载同时支持导入把之前导出的数据再写回本地。要做到这一点我的思路是封装一个导出函数async function exportStore() { const all await getAllItems(); const blob new Blob([JSON.stringify(all, null, 2)], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download todos-backup.json; a.click(); URL.revokeObjectURL(url); }导入的时候就反向操作读取文件内容解析JSON后批量写入数据库。这个能力在真实项目中非常实用也经常是被忽略的一环。6.5 配合Service Worker实现离线应用IndexedDB最常见也是最“正统”的使用场景其实是在Service Worker里做离线缓存。Service Worker本身有Cache API可以做请求级别的缓存但要存储应用层业务数据比如用户生成的待办、草稿、离线提交的表单IndexedDB就是更合适的选择。在Service Worker中你可以把用户离线状态下新增的记录先写入IndexedDB等到网络恢复时再批量上传到服务端。在didnt尝试过之前我总觉得Service Worker和IndexedDB的组合会很复杂真正实现下来后会发现挂载在全局的Promise封装思路在Worker里同样成立只是你不能访问window对象得用self.indexedDB。这个方向如果你感兴趣之后的文章我可以展开讲讲具体的同步机制。这里想强调的核心是IndexedDB不只是一个“页面里存点数据的小工具”它其实是浏览器赋予前端应用真正的本地数据库能力对于离线优先、弱网补偿、大文件分块这类需求扎扎实实研究好它是很值得的。我个人的经验是IndexedDB重要的是在动手前把“存什么、怎么查、何时升级”这三件事想清楚。很多看起来复杂的问题像重复打开连接、事务失效、游标不迭代本质上是没有理解它的生命周期模型。跑通一个增删改查只是起步把它当作一个真正有事务约束的数据库来对待后续的设计和扩展才会顺畅得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →