做前端久了你迟早会遇到 localStorage 不够用的一天。容量上限 5MB只能存字符串数据一多查询全靠 for 循环硬滤还没法保证写入不冲突。我第一次被 IndexedDB 逼着上手是因为一个离线台账功能几千条结构化数据、要按姓名和年龄筛选还要跨页面共用。用 localStorage 存完再自己写过滤性能和代码量都让人崩溃。后来静下心来把 IndexedDB 过了一遍发现这东西概念多、API 绕但底层思路跟数据库一个路数摸清之后并不难。这篇文章就用一个最简单的通讯录示例把打开数据库、建表、增删改查、游标遍历、事务和常见坑位一次讲明白适合已经会 JavaScript 基础语法、但还没接触过浏览器存储 API 的入门读者。1. 为什么前端需要 IndexedDB先搞清楚它解决什么问题1.1 localStorage 的局限与 IndexedDB 的定位很多初学者一提到浏览器本地存储脑子里只有 localStorage 和 sessionStorage这很正常因为这两个 API 够简单存个主题色、用户偏好、购物车数量完全够用。但它们是典型的“小而美”一旦数据量上来问题就全暴露了。首先是容量。localStorage 规范层面约 5MB各家浏览器略有出入但对一个要存上千条甚至上万条记录的离线应用来说5MB 很容易被撑爆。其次是数据格式localStorage 只能存字符串存对象得先 JSON.stringify取出来再 JSON.parse如果嵌套层级深、数据量大序列化和反序列化本身就是一笔不小的开销。第三是查询能力localStorage 没有索引、没有条件查询、没有范围过滤你要按某个字段过滤只能把整份数据读出来自己遍历数据一多性能肉眼可见地下降。第四是写入策略localStorage 的写入是同步阻塞的虽然平时感觉不到但在大数据量场景下主线程会被明显卡住。IndexedDB 就是来补这些窟窿的。它是一个真正跑在浏览器里的数据库底层基于结构化克隆算法存储对象容量通常远大于 localStorage单键值对能存 Blob、File 这类二进制还有事务保证写入一致性有索引支撑高效查询API 全部异步不阻塞主线程。说得直白点localStorage 像一个只能放杂物的纸盒子IndexedDB 是一个带书架、带标签系统、还带事务日志的档案柜。1.2 从数据库的视角理解 IndexedDB我第一次看 IndexedDB 的文档时最大的障碍是一堆陌生名词objectStore、transaction、index、IDBKeyRange、cursor。后来我换了个角度看瞬间就通了——它跟传统数据库是一一对应的。数据库database对应一个库一个站点可以创建多个用名称和版本号区分。对象仓库object store对应数据库里的“表”里面存的是对象而不是行记录。主键key对应表的主键可以是对象里的某个字段keyPath也可以是由浏览器自动生成的递增序列。索引index对应表的索引建在某个字段上方便按这个字段快速查询。事务transaction对应数据库事务读写操作必须在事务里执行事务提交前失败可以整体回滚。游标cursor对应 SQL 里的游标用来遍历结果集。理解了这层映射IndexedDB 的文档就不再是天书了。它无非是把 SQL 换成了 request 加 event 的模式把 SQL 里的 WHERE 换成了 IDBKeyRange把 JOIN 换成了你自己的代码逻辑。没有 SQL 语法是它跟后端数据库最大的区别却也是它不需要服务器、不依赖网络就能跑起来的原因。1.3 什么时候该用什么时候不该用IndexedDB 不是万能的我见过一些项目盲目用它最后给自己添了一堆麻烦。根据我的实际经验适合用 IndexedDB 的场景有这几类离线数据Web 应用需要离线可用比如离线台账、离线工单、PWA 的本地数据层。大数据量结构化存储几千条以上、字段不固定、需要按条件查询的数据。本地草稿与断点续传编辑器草稿、上传任务列表、输入表单的增量保存。缓存增强除了 HTTP 缓存之外需要把接口响应结构化缓存到本地并做版本管理。不适合用 IndexedDB 的场景也不少。比如只是存几个偏好设置、登录 token用 localStorage 就够了比如数据必须实时跟服务端同步本地存储只是缓存那应该优先考虑 HTTP 缓存和服务端状态管理再比如需要复杂关联查询、多表 join、聚合统计IndexedDB 并不擅长硬用只会把代码写得又臭又长。简单说IndexedDB 是给“需要在本地做一点数据管理”的场景用的不是给所有存储需求用的。2. 动手前的四个核心概念不弄懂这几个词代码肯定写不顺2.1 数据库版本号升级的唯一入口IndexedDB 有个跟传统数据库不太一样的机制版本号。它不是可有可无的元信息而是控制整个数据库结构升级的开关。你调用indexedDB.open(contactBook, 1)里的那个1就是版本号。当你要建表、建索引、修改字段结构时必须把版本号往上加比如从 1 升到 2。每次版本号变化浏览器都会触发onupgradeneeded事件这个事件是唯一允许创建和删除对象仓库、索引的地方。如果版本号不变即使你修改了建表代码浏览器也不会重建结构这就是很多人“改了建表代码但数据库没变化”的原因。版本号还有两个容易踩的坑。第一版本号只能升不能降如果先打开了版本 2再拿旧代码去open一个版本 1会抛 VersionError。第二当你打开一个更高版本时如果其它标签页还开着旧版本新版本会卡在onblocked事件上直到旧页面关闭或主动调用db.close()。这是多标签页应用最常遇到的坑后面我会专门讲。2.2 对象仓库与主键的三种玩法对象仓库的创建方式是在onupgradeneeded里调用db.createObjectStore(storeName, options)。options 里最关键的是主键配置有三种玩法第一种指定keyPath也就是取对象里的某个字段当主键。比如联系人对象里有id字段你可以写{ keyPath: id }每次添加的数据必须自带id而且不能重复。第二种开autoIncrement让浏览器自动生成一个从 1 开始递增的主键。比如{ keyPath: id, autoIncrement: true }浏览器会帮你填id你不传也能存。第三种既不用keyPath也不用autoIncrement这时主键由外部指定比如store.add(data, key001)显式传一个主键。这种方式常见于分表存储或需要自定义主键的场景但日常开发中用得少一些。主键选得好不好直接影响后续查询。我之前做过一个联系人导入功能一开始用姓名当主键结果同一姓名的人一导入就报错后来改成自增 id 加姓名索引才解决。记住一条原则主键要选业务上天然唯一、且不常变的字段拿姓名、邮箱这类可能重复或者可能修改的值当主键迟早出问题。2.3 索引名字都叫 Indexed 了IndexedDB 这个名字里的 Indexed说的就是索引。索引建在对象仓库的某个字段上作用是让你能按这个字段快速查询。没有索引的时候你只能按主键查有了索引你就能按任意字段查。建索引的代码很简单在创建对象仓库之后调store.createIndex(indexName, keyPath, options)。比如给联系人按姓名建索引可以写store.createIndex(name_index, name, { unique: false })。第三个参数里unique控制这个字段是否唯一如果设成true那么插入两条相同姓名的数据就会失败这个设计很实用比如手机号、身份证号这种天然唯一的字段就应该建唯一索引。索引虽然是自动维护的但要注意它不会帮你在“查询逻辑”层面做文章。比如你按姓名建了索引想查“所有姓张的人”你仍然需要结合 IDBKeyRange 去构造范围查询索引只是让这个范围查询变得高效。另外索引字段本身如果在数据更新后被删掉或改成undefined这条记录在索引里会消失查询时可能莫名其妙少数据这点我在第 4 部分会细讲。2.4 事务Transaction的自动提交陷阱IndexedDB 的读写操作必须在事务里执行事务保证一组操作要么全部成功、要么全部回滚。创建事务的方式是db.transaction(storeNames, mode)mode 有两种readonly和readwrite。查询用 readonly增删改用 readwrite。事务最大的陷阱是“自动提交”——当事件循环里没有待处理的任务时事务会自动结束。很多人第一次写 IndexedDB 会写出这样的代码const tx db.transaction(people, readwrite); const store tx.objectStore(people); store.add({ name: 张三, age: 18 }); setTimeout(() { store.add({ name: 李四, age: 20 }); }, 1000);这段代码里第二次store.add大概率会抛出TransactionInactiveError因为事务在 setTimeout 回调真正执行前就已经在事件循环空转时自动提交了。理解这个机制是绕开 IndexedDB 一半坑的关键。后面我有两种解决办法一种是所有操作放在同一轮事件循环里连续执行另一种是封装成 Promise严格让请求在前一个完成后紧跟下一个。3. 一个可直接运行的通讯录示例从建库到增删改查3.1 第一步打开数据库与建表下面这个通讯录示例我会把它拆成几个步骤每一步给出一段可以直接复制运行的代码。先看打开数据库function openDatabase() { return new Promise((resolve, reject) { const request indexedDB.open(contactBook, 1); request.onupgradeneeded function (event) { const db request.result; if (!db.objectStoreNames.contains(people)) { const store db.createObjectStore(people, { keyPath: id, autoIncrement: true }); store.createIndex(name_index, name, { unique: false }); store.createIndex(age_index, age, { unique: false }); } }; request.onsuccess function () { const db request.result; resolve(db); }; request.onerror function () { reject(request.error); }; }); }这里有三件事需要你特别留意。第一onupgradeneeded只在数据库不存在或者版本号比现有数据库高时触发第一次运行会触发之后旧不会了所以建表代码必须放在这个事件里。第二request.result和event.target.result指向同一个数据库连接对象但要注意在onsuccess里event.target和request是等价的两种写法都行别混到事务里就行。第三db.objectStoreNames.contains(people)是幂等保护防止同一段升级代码被重复执行时重复建表养成这个习惯能避免很多诡异问题。打开数据库之后记得用完后调用db.close()。数据库连接是有限资源不关闭会导致版本升级时onblocked一直卡住也会让后续的数据库操作在部分浏览器里变得异常慢。3.2 第二步写入数据add 与 put 的区别建完表接下来就是往里写联系人。先看 addfunction addContact(db, contact) { return new Promise((resolve, reject) { const tx db.transaction(people, readwrite); const store tx.objectStore(people); const request store.add(contact); request.onsuccess function () { resolve(request.result); }; request.onerror function () { reject(request.error); }; }); }调用方式类似addContact(db, { name: 张三, age: 28, phone: 13800000000 })成功后request.result里返回的是这条数据的主键值。这里有个关键点add在遇到主键重复时会报错而put则会把同主键的数据整个覆盖掉。简单理解add是“新增”put是“新增或更新”。如果需要做“存在就更新、不存在就插入”的同步逻辑直接用put更省心。另外如果你的对象仓库指定了autoIncrementadd 时不传 id 也能成功浏览器会自动生成。但我建议你在业务逻辑里还是主动维护一个业务编号自动主键适合内部引用不适合直接暴露给用户或者做跨端同步因为不同浏览器环境下的自增序列没有全局唯一性。3.3 第三步读取数据按主键、按索引、按范围按主键读取最简单function getContactById(db, id) { const tx db.transaction(people, readonly); const store tx.objectStore(people); const request store.get(id); request.onsuccess function () { console.log(查询结果, request.result); }; }按姓名索引读取写法略有不同function getContactByName(db, name) { const tx db.transaction(people, readonly); const store tx.objectStore(people); const index store.index(name_index); const request index.get(name); request.onsuccess function () { console.log(按姓名查询结果, request.result); }; }如果名字唯一index.get(name)就能拿到单条数据如果同名的有多条就要用游标openCursor配合范围去取。按某个字段做范围过滤需要用IDBKeyRange。比如我想查年龄在 18 到 30 之间的人const range IDBKeyRange.bound(18, 30); const index store.index(age_index); const request index.openCursor(range);IDBKeyRange有四个静态方法值得记住only(value)查唯一值lowerBound(value)查大于等于某个值的范围upperBound(value)查小于等于某个值的范围bound(lower, upper)查 between 范围。这四个方法几乎覆盖了所有常见查询需求在入门阶段先掌握only和bound两个就够了。3.4 第四步用游标遍历所有数据游标是 IndexedDB 遍历数据的主方式逻辑很像链表——每次onsuccess拿到一条记录调cursor.continue()就跳到下一条直到cursor为null说明遍历结束。function listAllContacts(db) { const tx db.transaction(people, readonly); const store tx.objectStore(people); const request store.openCursor(); request.onsuccess function (event) { const cursor event.target.result; if (cursor) { console.log(联系人, cursor.value.name, cursor.value.age); cursor.continue(); } else { console.log(遍历结束); } }; }游标不仅能读还能在遍历过程中更新和删除。在cursor.onsuccess里拿到cursor后可以调cursor.update(newData)更新当前记录也可以调cursor.delete()删除当前记录这比“查出 id 再重新发起一次删除请求”要高效。我在做批量修改比如给所有人年龄加一岁的时候就是靠游标加 update 一行一行处理后提交的代码清爽、性能也可控。3.5 完整代码串联一个最小可运行的示例把上面的函数拼起来就是一个完整的通讯录。我习惯在本地用一个 HTML 加一个 script 标签直接跑不用任何构建工具!DOCTYPE html html langzh-CN body script let db; const openDatabase () new Promise((resolve, reject) { const request indexedDB.open(contactBook, 1); request.onupgradeneeded () { const database request.result; const store database.createObjectStore(people, { keyPath: id, autoIncrement: true }); store.createIndex(name_index, name, { unique: false }); store.createIndex(age_index, age, { unique: false }); }; request.onsuccess () { db request.result; resolve(db); }; request.onerror () reject(request.error); }); const addContact (contact) new Promise((resolve, reject) { const tx db.transaction(people, readwrite); const store tx.objectStore(people); const request store.add(contact); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); const listAll () { const tx db.transaction(people, readonly); const store tx.objectStore(people); const request store.openCursor(); request.onsuccess (event) { const cursor event.target.result; if (cursor) { console.log(cursor.value.name, cursor.value.age); cursor.continue(); } }; }; (async () { await openDatabase(); await addContact({ name: 张三, age: 28 }); await addContact({ name: 李四, age: 20 }); listAll(); })(); /script /body /html打开开发者工具的 Application 面板找到 IndexedDB你能直观看到数据库、对象仓库、索引和数据记录。看到数据落进去的那一刻IndexedDB 的神秘感基本就破了一半。这也是我给所有入门朋友的建议先跑通这个最小示例再去啃文档远比从文档读到代码要快。4. 常见问题与排查技巧实录4.1 版本号引发的 onblocked多标签页卡死现场有一次我给线上项目升级数据表加了两个索引信心满满地发布结果用户反馈页面一直加载不出来。排查了半天发现问题是数据库从版本 1 升到 2 时用户另一个标签页还开着旧版本页面旧版本持有着旧数据库连接新版本就一直停留在onblocked状态新页面白屏。正确的处理方式有两个层面。第一在代码里监听onblocked和onversionchange事件给用户一个明确的提示避免白屏无响应。第二收到onversionchange事件时主动关闭旧连接的数据库db.onversionchange function () { db.close(); window.location.reload(); };这是多标签页应用的标准做法当检测到版本变更请求主动释放旧连接让新版本可以继续升级。如果你没做任何处理至少在开发阶段要意识到这个机制别把onblocked当成代码 bug 去瞎调。4.2 事务提前结束写入静默丢失的元凶事务自动提交这个问题我在第 2 部分已经提过这里给一个更隐蔽的案例。假设你想循环插入一万条数据写了这样的代码function insertMany(db, items) { const tx db.transaction(people, readwrite); const store tx.objectStore(people); items.forEach(item store.add(item)); }这段代码通常是能成功的因为store.add的请求在同一个同步循环里全部发出去事务不会提前结束。但如果你把它改成异步版本比如forEach内部等待某个异步操作事务就可能在等待期间自动提交后续请求全部失败而且因为你没有监听错误数据就“静默丢失”了。一个稳妥的做法是监听事务的onerror和onabort至少把失败暴露出来tx.onerror function (event) { console.error(事务出错, event.target.error); }; tx.onabort function () { console.error(事务被回滚); };另一个做法是把写入任务拆成批次一批一个事务比如每 100 条一个事务这样既避免单个事务过大导致性能问题也能在某个批次失败时不影响其它批次。写批处理之前先想清楚事务边界这是 IndexedDB 开发里最重要的心智模型。4.3 回调地狱与执行顺序混乱一切异步都要排队IndexedDB 的 API 全是异步的但它设计得很老派——事件回调没有 Promise。这意味着你一旦写复杂逻辑很容易出现“回调里套回调”然后执行顺序完全失控。我见过一个很典型的 bug先写一条记录马上按名字去查结果查出来是空的。原因是写入的onsuccess虽然触发了但事务还没完全提交紧接着的只读事务去查的时候数据还没真正入库。不同事务之间没有自动的先后等待你必须自己保证顺序。所以入门阶段就要树立一个习惯把 IndexedDB 的所有操作都封装成 Promise然后用async/await串起来。这样写代码不仅顺序可控错误处理也集中。后面第 5 部分我会给一个完整封装这里先记住一句话IndexedDB 的请求不是函数返回值而是事件结果你需要把“事件”翻译成“可等待的值”。4.4 索引字段变化导致查询不到数据有一次用户反馈某条联系人记录了手机号但按手机号索引查询就是查不到。逐条排查发现那条数据是在旧版本代码里写入的当时并没有手机号这个字段索引在写入时把缺失字段的记录跳过了。升级代码后虽然建了索引但旧数据没有被重新索引。这个问题的根源是索引只覆盖“建索引之后写入或更新的数据”不会自动回填历史数据。解决办法也简单升级建索引之后需要用游标把所有旧数据读出来重新put一遍让索引重新生成。类似的情况也出现在唯一索引上如果旧数据里有两个人的手机号相同那么建立唯一索引时会直接报错这种数据清洗工作只能在升级脚本里手动做没有捷径。4.5 私有模式与存储配额跨浏览器的兼容细节IndexedDB 在主流浏览器里都支持但隐私模式是例外。Safari 的私有模式曾经对本地存储限制很严格IndexedDB 写入会直接抛 QuotaExceededErrorFirefox 的隐私窗口早期也有类似限制。如果你的应用面向隐私模式用户务必监听onerror并把错误信息展示出来而不是让用户以为功能坏了。另外浏览器给 IndexedDB 的配额并不是无限大。Chrome 通常会根据磁盘剩余空间动态分配但如果同时使用了 Cache Storage、IndexedDB 和 localStorage总量超限时就会报配额错误。处理策略是定期清理无用的旧数据比如给数据加时间戳用游标批量删除超过保留期限的记录。真到配额问题爆发的那天再回头补清理逻辑成本会非常高。5. 把代码包装成 Promise 工具让 IndexedDB 好用十倍5.1 为什么几乎每个项目都要封装一层如果你看过 GitHub 上一些老项目会发现很早就有社区库帮你封装 IndexedDB比如 idb、Dexie.js 这些。它们解决的核心问题只有一个把“事件回调”翻译成“Promise”让你能像写普通 JavaScript 异步函数一样去操作数据库。我不建议一上来就引库因为 IndexedDB 的主要难点不在代码量而在理解模型。但当你写完几个增删改查之后再回头写任何新功能如果每次都要手动 new Promise、再监听 onsuccess代码会非常啰嗦。封装一层轻量的 Promise 工具是收益极高的投资。5.2 一个极简封装思路核心是三个工具函数。第一个把单个请求包装成 Promisefunction requestToPromise(request) { return new Promise((resolve, reject) { request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }第二个把打开数据库包装成 Promise并在升级时执行建表逻辑function openDB(name, version, upgrade) { return new Promise((resolve, reject) { const request indexedDB.open(name, version); request.onupgradeneeded () upgrade(request.result); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }第三个组合事务的完成时机。这里有个细节请求的 onsuccess 不代表事务已提交如果你要在写入后立刻发起下一个事务最好等待事务的 oncompletefunction transactionDone(tx) { return new Promise((resolve, reject) { tx.oncomplete () resolve(); tx.onerror () reject(tx.error); tx.onabort () reject(tx.error); }); }有了这三个工具增删改查就变成了const db await openDB(contactBook, 1, (database) { const store database.createObjectStore(people, { keyPath: id, autoIncrement: true }); store.createIndex(name_index, name, { unique: false }); }); async function addPerson(data) { const tx db.transaction(people, readwrite); const store tx.objectStore(people); const id await requestToPromise(store.add(data)); await transactionDone(tx); return id; } async function getPerson(id) { const tx db.transaction(people, readonly); const store tx.objectStore(people); return requestToPromise(store.get(id)); }比起裸 API这套封装最大的价值是你不再需要记每个操作是 request 还是 transaction所有函数统一async/await错误处理也能通过 try-catch 靠拢到同一条代码路径上。实际项目里我还会在此基础上加一个“自动重试”和“限流”的逻辑但入门阶段做到这一步已经足够。5.3 进阶扩展备份导出和跨页面同步封装好之后IndexedDB 真正好用的功能才能舒展开。我最常用的两个扩展一个是数据备份一个是跨标签页同步。数据备份很简单用游标遍历所有数据组装成 JSON然后下载到本地function exportDatabase(db, storeName) { return new Promise((resolve) { const tx db.transaction(storeName, readonly); const store tx.objectStore(storeName); const request store.openCursor(); const result []; request.onsuccess (event) { const cursor event.target.result; if (cursor) { result.push(cursor.value); cursor.continue(); } else { resolve(result); } }; }); }这个函数能帮用户把本地数据导出成 JSON 文件需要恢复时再写一个导入函数逐条put回去。对于纯本地应用来说这是最朴素的灾备方案。跨标签页同步则可以监听storage事件或者直接监听 IndexedDB 相关的自定义窗口事件当数据更新时通知其它标签页刷新视图。IndexedDB 本身没有原生的跨标签页通知机制但你可以配合BroadcastChannel或者 localStorage 的storage事件来广播“数据已更新”让所有标签页重新查询并渲染。这样就能把 IndexedDB 用成一个简易的“本地共享数据中心”多页签之间数据保持一致。我的个人体会是IndexedDB 真正难的不是 API 本身而是你把后端数据库那套思维搬不搬得过来。只要理解对象仓库、索引、事务这三个关键词再写一遍增删改查和游标遍历它就会从一个“看不懂的怪东西”变成“一个熟悉的数据库”。如果你第一次接触别急着引库先把我这段最小示例在自己浏览器里跑通亲手打断点看看每次事件触发的顺序再回去读文档一定事半功倍。