IndexedDB入门指南:从建库到增删改查及常见坑
各位前端同学今天来聊一个入门阶段经常被忽略、真正用起来又容易翻车的东西——浏览器内置的 IndexedDB。先说结论它是浏览器自带的一个真正意义上的数据库能存结构化数据支持索引、事务、游标查询容量也远大于 localStorage适合存用户草稿、表格数据、离线缓存这类需要持久化的内容。下面从一个能直接复制运行的入门示例出发把建库、建表、增删改查、模糊搜索都跑通同时把新手最容易踩的几个坑一起交代清楚。不管你的 JavaScript 基础是刚学完函数、事件和数据类型判断还是已经写过一阵业务代码这篇文章都可以当作一份实操笔记来用。1. 初识 IndexedDB浏览器的“正经数据库”1.1 为什么说 localStorage 不够用很多新手接触浏览器存储第一个认识的是 localStorage。它的 API 确实简单setItem、getItem两行代码就能搞定但用久了你会发现它有几个硬伤第一只能存字符串想存对象得自己JSON.stringify数据一复杂就会频繁做序列化和反序列化第二容量有限主流浏览器一般给到 5MB 左右存点配置和用户偏好还行存几十兆的数据会让页面直接拒绝写入第三它没有查询能力拿回来是整个字符串想按某个字段筛选根本没辙。IndexedDB 解决的就是这些问题。它是浏览器内置的非关系型数据库以“对象仓库”为存储单元可以存任意 JavaScript 对象包括 Blob、ArrayBuffer、File 这类二进制数据容量通常能到几百 MB甚至更多。它还自带事务保障支持索引、游标、范围查询所以从能力上讲它已经不只是“存储”更像一个跑在浏览器里的小型数据库引擎。但正因为能力强它的 API 设计也更“原生”没有 localStorage 那么甜。很多人第一次打开 MDN 文档看到indexedDB.open、onupgradeneeded、transaction、objectStore、request.onsuccess这些概念就头晕。这篇博文的目的就是把这些概念一个个拆开用一个最简单但完整的示例把它们串起来。1.2 什么时候该用 IndexedDB什么时候别勉强判断一个项目要不要上 IndexedDB我的标准很简单数据量有没有可能超过 5MB数据是否需要结构化查询是否需要长期离线访问如果三个问题里至少有两个是“是”那基本可以放心用 IndexedDB。反过来如果你只是存一个主题色、一个用户名、一个购物车数量localStorage 够了就别为了“看起来高级”去引一个库。IndexedDB 的 API 本身就带学习成本杀鸡用牛刀只会让代码更复杂。还有一种情况是临时会话数据刷新页面就失效用 sessionStorage 或内存变量就行完全没有持久化必要。我在实际项目里比较常见的用法是用户填表单时做自动保存每输入一个字段就写到 IndexedDB刷新后重新拉回来填入再比如把一个接口返回的列表数据缓存下来断网时先显示缓存等有网再同步。这些场景 localStorage 也能做但数据一多、结构一复杂IndexedDB 的优势就很明显了。2. 入门之前先把这 5 个概念嚼碎2.1 数据库、对象仓库、键仓库与货架的关系IndexedDB 里最简单的几个名词我建议用一个“仓库”的类比来记。整个 IndexedDB 数据库就像一个大仓库每个站点域名可以拥有多个这样的仓库。仓库里没有表的概念而是用“对象仓库”object store来存数据你可以把它理解成仓库里的一排排货架。比如你现在要存用户数据就建一个users的 object store你要存订单数据就再建一个orders的 object store。货架上的每一格放一条记录这条记录就是一个普通的 JavaScript 对象。比如{ id: 1, name: 张三, email: zhangsanexample.com }就能直接放进去。那怎么区分每条记录呢靠“键”key。你可以指定某个字段作为主键比如id这个字段就是“键路径”keyPath。也可以不指定字段让浏览器自动生成一个递增的数字作为主键这种叫自动主键。新手最容易忘的一点是同一个 object store 里主键不能重复否则写入会失败。这和关系型数据库的主键约束是一个道理只不过 IndexedDB 不会给你一个紫色大片的报错界面它只是让你的写入请求在 onerror 回调里返回一个错误。索引index也不难理解。它相当于给某个字段额外建了一套“快速查找目录”。比如你经常按email查用户那你可以在usersstore 上给email建一个索引之后就能直接用index(email).get(zhangsanexample.com)拿到记录不用遍历整个 store。这个功能是 localStorage 完全不具备的。2.2 事务与索引数据库最硬核的两个机制接下来是 IndexedDB 里让无数新人放弃的两个词事务transaction和索引index。事务这个概念我用一个转账的场景来解释。假设从 A 账户扣 100 块给 B 账户加 100 块如果第一步成功、第二步失败整个资金池就少了 100 块。事务保证的是要么两步全部成功要么全部回滚不存在中间状态。IndexedDB 的每次读写操作都必须在事务里进行你创建事务时可以指定只读readonly或读写readwrite然后在这个事务里对 object store 执行 add、put、delete、get 等操作。为什么前端也强制要求事务因为多个请求同时读写数据库时如果没有事务隔离数据很容易互相覆盖或者读到半截状态。它保证你在事务期间读到的数据是一致的写操作在事务提交后才会对后续请求可见。索引我已经提了一句但这里要展开一下因为它是 IndexedDB 查询能力的关键。创建索引时你可以设定唯一性unique。比如给 email 建唯一索引那就意味着整个 store 里不能出现两个相同的 email这相当于数据库层面的校验规则。查询的时候你可以用索引精确查找也可以用索引做范围查询比如“年龄大于 18 的用户”或者“注册时间在某个时间段内”。如果没有索引你就只能开游标cursor一条条遍历效率低很多。初次接触的人往往会觉得这两个概念抽象但只要你动手写一遍建索引、用索引查询的代码理解速度会快很多。下面动手环节里我会尽量把两者都覆盖到。2.3 全异步 API为什么回调总和你想的不一样IndexedDB 的 API 风格和传统 JavaScript 有点不一样它几乎全是异步操作靠事件回调返回结果。简单来说你调indexedDB.open()之后不能立刻拿返回值当数据库连接用必须等它的onsuccess事件触发。这套事件模型对用过XMLHttpRequest的老前端应该很熟悉和fetch这种 Promise 风格完全是两套思维。比如你需要写const request indexedDB.open(my-db, 1); request.onsuccess function () { const db request.result; // 到这里才能操作数据库 }; request.onerror function () { console.log(request.error); };如果不理解异步很容易写出“打开数据库后立刻读取数据结果拿到 undefined”的代码。我自己刚学的时候也踩过这种坑在onsuccess外面直接定义了一个变量想先赋值再取用结果那变量永远是空的。解决这个问题的标准思路有两个要么把操作写进onsuccess回调里要么把打开数据库的过程封装成 Promise让后续代码用async/await顺序执行。下面示例我会选择第二种因为对新手来说await顺序阅读的代码比连环回调和事件嵌套友好太多。3. 可以直接复制的入门示例从建库到增删改查3.1 打开数据库版本号的规则与坑第一个函数是打开数据库。这里有一个概念必须提前讲IndexedDB 的版本号不是随便填的它决定了何时触发“升级”逻辑。当浏览器检测到indexedDB.open时的版本号高于当前数据库版本就会触发onupgradeneeded事件。这个事件里专门用来创建新的 object store 或者索引。也就是说如果你第一次打开数据库时填版本 1那么建表逻辑放在onupgradeneeded里执行如果之后你想加一个新的 store必须把版本号改成 2否则它不会执行升级代码。一个很容易踩的坑是版本号只能往上加不能降。如果你曾经用版本 2 打开过某个库以后再用版本 1 打开浏览器会直接抛VersionError。所以现场的教训是版本号一旦定下来就按日期或功能迭代递增不要心血来潮往回退。function openDB(dbName, version) { return new Promise((resolve, reject) { const request indexedDB.open(dbName, version); request.onupgradeneeded function (event) { const db request.result; // 如果 users 仓库还不存在就创建它 if (!db.objectStoreNames.contains(users)) { const store db.createObjectStore(users, { keyPath: id }); // 给 email 字段建一个唯一索引 store.createIndex(email, email, { unique: true }); // 给 name 字段建一个普通索引 store.createIndex(name, name, { unique: false }); } }; request.onsuccess function () { resolve(request.result); }; request.onerror function () { reject(request.error); }; request.onblocked function () { reject(new Error(数据库被其他标签页占用请关闭后再试)); }; }); }这段代码里我加了一个判断db.objectStoreNames.contains(users)目的是避免重复创建。设想一下如果你在版本 2 里执行一次创建到版本 3 又执行同样代码而 store 已经存在浏览器会抛ConstraintError。养成先判断再创建的习惯能让升级过程稳定很多。3.2 封装增删改查Promise 化让代码回到熟悉的味道数据库连接拿到手之后所有操作都要先开一个事务。事务的开法很直观第一个参数是 store 名字的数组第二个参数是模式readonly 或 readwrite。拿到事务对象后再用tx.objectStore(users)拿到具体的仓库对象。这里我直接写成函数全部用 Promise 包装方便await顺序调用。先看新增和查询function addData(db, storeName, data) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readwrite); const store tx.objectStore(storeName); store.add(data); tx.oncomplete () resolve(true); tx.onerror () reject(tx.error); tx.onabort () reject(tx.error); }); } function getData(db, storeName, id) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readonly); const store tx.objectStore(storeName); const request store.get(id); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } function getAllData(db, storeName) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readonly); const store tx.objectStore(storeName); const request store.getAll(); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }注意新增时我用的是store.add()如果主键已经存在add 会报错。如果希望“有就更新、没有就新增”应该用store.put()。更新和删除函数的逻辑很像function updateData(db, storeName, data) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readwrite); const store tx.objectStore(storeName); store.put(data); tx.oncomplete () resolve(true); tx.onerror () reject(tx.error); tx.onabort () reject(tx.error); }); } function deleteData(db, storeName, id) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readwrite); const store tx.objectStore(storeName); store.delete(id); tx.oncomplete () resolve(true); tx.onerror () reject(tx.error); tx.onabort () reject(tx.error); }); }为什么更新和删除要看tx.oncomplete而不是store.put()这个请求的 onsuccess因为一个事务里可能包含多个操作比如你要在一个事务里更新三条数据那单个请求成功只能说明“写入排队成功”并不代表整个事务已经提交。tx.oncomplete才是真正意义上的“事务已提交、数据已生效”。刚开始学的时候我也只盯单个请求的 onsuccess后来发现数据经常还没落盘就执行了下一步这种边界问题在高并发下会变得很隐蔽。3.3 游标查询解决“模糊搜索”这类场景如果你的查询条件只是“按主键取一条”或者“取出全部”前面封装的get、getAll就够了。但现实场景里经常需要模糊搜索比如在用户列表里输入一个关键词匹配 name 或 email。IndexedDB 没有一个开箱即用的like操作这时候最通用的写法就是游标cursor。游标简单理解就是“一条一条地遍历记录”。我打开一个游标每拿到一条记录就做条件判断命中就收集到结果数组里然后调用cursor.continue()继续遍历下一条直到游标为空。function searchByCursor(db, storeName, keyword) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readonly); const store tx.objectStore(storeName); const request store.openCursor(); const results []; request.onsuccess function (event) { const cursor event.target.result; if (cursor) { const record cursor.value; if ( record.name.includes(keyword) || record.email.includes(keyword) ) { results.push(record); } cursor.continue(); } else { resolve(results); } }; request.onerror () reject(request.error); }); }这种写法的好处是通用任何字段都能拿来匹配缺点是数据量大时性能一般因为你把整张表都遍历了一遍。如果数据量极大更好的方案是利用索引做范围查询比如通过email索引精确查找。但作为入门示例游标能让你更直观理解 IndexedDB 的数据遍历机制也为以后做复杂筛选打底。3.4 一站式联动跑通整套流程上面的函数定义好之后整个流程合起来非常简单。我用一个立即执行的异步函数演示一遍(async function () { const db await openDB(my-first-db, 1); await addData(db, users, { id: 1, name: 张三, email: zhangsanexample.com, age: 21 }); await addData(db, users, { id: 2, name: 李四, email: lisiexample.com, age: 30 }); console.log(新增后全部数据:, await getAllData(db, users)); await updateData(db, users, { id: 2, name: 李四丰, email: lisiexample.com, age: 32 }); console.log(根据 id 查询:, await getData(db, users, 2)); console.log(模糊搜索“四丰”:, await searchByCursor(db, users, 四丰)); await deleteData(db, users, 1); console.log(删除后剩余数据:, await getAllData(db, users)); })();运行这个代码控制台会依次打印新增、更新、查询、搜索、删除的结果。只要浏览器正常数据会持久化到你关闭页面之后下次再打开同一地址依然能查到。这里有一个我在现场常提醒新手的点如果第二次运行时报了“数据库被占用”或者“版本错误”很可能是上一次运行还没结束或者别的地方又开了一个数据库连接。IndexedDB 允许多个标签页同时访问同一个库但版本升级时不允许其他连接存在这就是onblocked出现的原因。调试阶段尽量只保留一个页面在运行。4. 入门翻车实录常见报错与排查技巧4.1 版本号一变就崩VersionError 和 onblocked 的正确姿势我在一开始提到版本号是只增不减的这里再展开讲一个真实场景。有人做好了示例跑了一会儿想在同一个库里再加一个ordersstore于是把版本号从 1 改成 2顺手把onupgradeneeded里的代码改成同时创建两个 store。问题很快就会冒出来如果上一次运行还开着旧的数据库连接浏览器不会执行升级而是在onblocked事件里卡住不报错也没有反应。这时你会看到控制台没有日志代码也不往下执行。排查思路是确认是不是所有标签页都关掉了或者当前页面是否还有其它连接。确认request.onblocked里有没有提示信息。很多新手没写这个回调导致卡死也不知道发生了什么。如果旧代码没有主动关闭db.close()连接会一直保持版本升级会被卡住。正确的流程是在需要升级时先确保所有连接关闭或者至少在onblocked里给用户一个“请关闭其他页面”的提示。示例里我直接 reject 一个错误方便控制台看到问题。4.2 事务提前结束为什么 setTimeout 之后操作不生效这个坑很经典。有些人写代码时会先把store取出来放到变量里然后过一个setTimeout再去store.add()。执行到那一步浏览器控制台通常会报TransactionInactiveError或类似错误。原因是 IndexedDB 的事务生命周期不是由你手动管理的而是由 JavaScript 事件循环决定的。当你创建事务后如果同步代码已经执行完毕而事务上没有新的请求浏览器会认为这个事务“没事干了”自动提交并结束。等你在setTimeout回调里再想用这个事务对象时事务早就处于非活跃状态了。所以经验是一个事务里的所有操作尽量放在同一个同步代码块里完成不要拆到事件回调或者定时器里。如果你确实需要分两步写那就重新开一个新事务而不是拿着旧事务的引用。把这套规则讲清楚之后新人的代码质量会明显提升。4.3 写入成功却查不到先检查这 3 个地方还有一种让我经常帮忙排查的情况代码执行时明明不报错add的数据也看起来“成功”了但刷新页面后数据就是不在。这时候一般有三个原因。第一你写完了立刻查询但查的是同一个连接里同一批事务吗如果是要确认是tx.oncomplete之后才查询。第二你有没有在onupgradeneeded里创建 store如果你第一次open用的版本号已经大于 1而库是新建的那么onsuccess会在onupgradeneeded之后触发store 才真正创建完成需要确保后续操作都在这个时间点之后。第三你确认数据库名字、store 名字大小写都对吗IndexedDB 的命名是区分大小写的一个字母不一致就查不到。调试这类问题最直接的方式不是看代码而是打开浏览器的开发者工具看 Application 面板里的 IndexedDB 节点。下面讲一下调试面板怎么用。4.4 浏览器调试面板Application 里的 IndexedDB 实际上怎么用Chrome 和 Edge 的开发者工具里Application 面板是最直观的 IndexedDB 管理界面。你打开后能看到当前站点下的所有数据库点开一个库下面列着 object store点开 store右侧就会显示所有记录。你可以手动删除记录、编辑键值也可以清空整个 store。这里有个调试技巧如果代码运行后看不到数据多半是数据写进了另一个数据库或另一个 store。名字对不上是关键。我会建议在indexedDB.open之后把db.name和db.objectStoreNames打印出来肉眼确认一遍。也可以直接在 Console 面板里操作数据库但要注意调试面板的操作也会占用连接如果你开着 Application 面板做版本升级同样会触发 onblocked。4.5 新手问题速查表现象原因排查思路刷新后数据不见写操作未提交或 store 名不对看 tx.oncomplete 是否触发检查 DevTools报 VersionError版本号小于当前库版本版本号必须高于当前版本卡住不执行其他标签页持有旧连接关闭其它页面或处理 onblocked报 ConstraintError主键重复或唯一索引冲突改用 put或检查数据是否重复报 TransactionInactiveError异步回调里继续操作旧事务把操作放在同一同步块或重新开事务store.add 不报错但没数据没有监听 tx.onerror / onabort初始化时把 onerror 和 onabort 都打日志这张表整理的是我在日常答疑里碰到的高频组合。新手遇到问题先对照现象再打印日志基本能解决八成。5. 入门之后IndexedDB 还能做什么进阶方向与建议5.1 离线缓存与 PWA把网络数据搬到本地IndexedDB 的一大升级用法是离线优先的缓存方案。简单来说页面首次从接口拉到数据后把关键字段写进 IndexedDB。下次打开时先从本地库读立刻渲染页面然后在后台请求最新数据拿到后再更新库和界面。这个模式就是“缓存优先网络兜底”。这和 Service Worker 里的 Cache API 不一样要注意区分Cache API 专门缓存 HTTP 请求和响应适合存页面资源本身IndexedDB 适合存业务数据和复杂对象比如用户列表、表单草稿、聊天记录。两者配合使用才能把 PWA 的离线体验做完整。5.2 大文件分块存储断点续传的另一种实现前端做上传大文件时经常需要“分块上传”。浏览器端负责把文件切成若干 Blob 片段分段发到服务器。如果某个片段上传失败需要重传这时候信息存在哪内存肯定不行页面一刷新就丢。IndexedDB 可以胜任这个角色把每个分块的数据、上传状态、序号写进一个 store刷新后还能继续读取再配合服务端接口就能实现真正的断点续传。这个场景我用过一次效果很稳。由于 IndexedDB 本身支持存 Blob 和 ArrayBuffer几乎不用做额外转换直接把文件分片存进去就行。唯一要注意的是内存和容量管理存完一批要主动清理不然本地数据库会像水槽一样越积越满。5.3 学习建议别急着封装先把原生 API 用熟很多前端习惯用第三方库比如 Dexie.js 这类对 IndexedDB 的封装确实能让代码简洁不少。但我不建议新手第一个项目就上库原因很简单你还没搞清楚事务、版本、 cursor 的机制遇到报错时很难判断是库的问题还是自己操作的问题。先把原生 API 写熟哪怕代码多写一点也值得。等你把事务生命周期、索引设计、版本迁移这些机制吃透了再去看封装库的文档会发现很多设计是一一对应的学习成本会大大降低。到那时候你甚至可以自己写一个简化版封装把openDB、addData、getAllData这些函数收拢成一个工具类按需使用。这个过程中你还会顺手练到 JavaScript 的 Promise、事件、异步流程控制很多基础知识点都能串起来。最后再分享一个个人小习惯在实际项目里我不会每次需要操作数据库都重新await openDB()而是在页面启动时打开一次数据库连接保存到全局变量里之后所有函数复用同一个连接只有当版本升级时才重新打开并在升级完成后关闭旧连接。这样既能减少重复打开的开销又能避免多连接导致的 blocked 问题。至于数据量大的场景我会把“写入”和“查询”分开设计写入用批量事务查询开索引让数据库操作尽量靠近业务的实际频率来走。多调几次、多踩几个坑这套 API 会从一个“看起来很难”的东西变成前端工具箱里一个很顺手的存在。