1. 为什么你的项目需要IndexedDB:从localStorage的局限说起
在聊IndexedDB之前,先说说我为什么在几个项目里坚决地把数据存储方案从localStorage换成了它。很多人在做前端数据持久化的时候,第一个想到的就是localStorage,毕竟API简单,一个setItem一个getItem就完事了。但真正把数据量跑起来之后你会发现,localStorage的瓶颈非常明显:单键值对存储、字符串类型、大约5MB的容量上限、同步读写阻塞主线程。一旦你的业务需要存结构化数据、文件、二进制内容,或者单条数据就超过几MB,localStorage就完全不够用了。举个实际例子,我之前做一个离线优先的报表工具,用户需要把一周的销售数据缓存到本地,数据量在几十MB级别,用localStorage跑起来浏览器直接卡顿,后来整体迁移到IndexedDB之后,流畅度才恢复正常。
IndexedDB是浏览器内置的非关系型数据库,它解决的核心问题是:在浏览器本地存储大量结构化数据,并且以异步方式读写,不阻塞UI渲染。它支持索引、事务、游标查询、版本管理,能力上已经非常接近一个嵌入式的NoSQL数据库。对于需要离线能力、本地缓存、数据同步中间层、甚至可以推给用户的复杂交互应用来说,IndexedDB是现阶段最靠谱的浏览器存储方案。
这篇内容适合谁?适合已经在写前端业务代码、用过localStorage但没深入玩过IndexedDB的开发者;也适合正在做PWA离线应用、富交互编辑器、音视频缓存、数据可视化大屏这类需要本地数据支撑的项目的朋友。我尽量把API的用法、底层机制、踩坑点一次讲透,让你看完就能直接上手。
2. 先把概念搞懂:数据库、对象仓库、索引和事务
2.1 数据库和对象仓库:类比SQL世界的库和表
IndexedDB的基本单位是数据库(IDBDatabase),一个域名下可以创建多个数据库,每个数据库有名字和版本号。数据库内部没有“表”的概念,取而代之的是“对象仓库”(Object Store)。每个对象仓库存储的是一组“对象”,类似于一张表里存了多行记录,但每一行不需要有相同的字段结构,这是典型的NoSQL风格。
这里有一个关键区别:SQL表是行列结构,而IndexedDB的对象仓库直接存JavaScript对象。你可以把任意一个对象直接放进仓库里,不需要先定义字段。对象里有个必须的key,你可以指定某个字段作为key(比如id),也可以让浏览器自动生成自增key。key的作用是唯一标识仓库里的每条记录,后续的增删改查都依赖它。
2.2 索引(Index):为什么索引是查询的命脉
如果只有一个key,那你查找数据的方式就很受限。比如电商订单数据存在仓库里,key是订单ID,但你想按用户ID查所有订单怎么办?这时候就必须给“用户ID”这个字段建索引。索引本质上是一份独立的映射表,它把某个字段的值映射回对应的记录。
索引有几种类型,最常用的是普通索引(unique为false),允许索引值重复。还有唯一索引(unique为true),不允许重复值。混合索引(multiEntry)用于数组字段,比如一个标签数组,每个元素都可以命中索引。搞清楚索引的使用场景非常重要:频繁作为查询条件的字段一定要建索引,否则只能走全仓库扫描,数据量一大性能就崩了。
2.3 事务(Transaction):保证数据一致性的核心机制
IndexedDB的所有读写操作都必须在一个事务(IDBTransaction)里执行。事务的概念和关系型数据库里的事务类似:要么全部成功,要么全部回滚。默认情况下,打开一个事务后会在这个事务内执行一系列操作,事务结束时统一提交;如果中途出现错误,所有操作都会被回滚。
事务有三种模式:readonly(只读)、readwrite(读写)、versionchange(版本变更)。版本变更事务是数据库结构修改专用的,比如建仓库、删仓库、建索引,都必须在versionchange事务里进行,这个机制保证了在修改结构时不会有其他事务并发操作,从根本上避免了结构错乱。很多人第一次写IndexedDB代码时容易忽略事务的提交时机,这里先提个醒:事务在事件循环的微任务结束后自动提交,所以你不能在同一个事务里分两步做异步操作,否则事务早就提交了。
3. 实操:从打开数据库到增删改查的完整实现
3.1 打开数据库和处理版本升级
打开一个IndexedDB数据库使用的是indexedDB.open(name, version)方法。如果数据库不存在,浏览器会自动创建;如果版本号大于当前版本,会触发onupgradeneeded事件,这是你创建对象仓库和索引的唯一时机。
const request = indexedDB.open('myAppDB', 1); request.onupgradeneeded = function(event) { const db = event.target.result; // 创建对象仓库,keyPath指定id字段作为主键 const store = db.createObjectStore('orders', { keyPath: 'id' }); // 建索引:按customerId查询 store.createIndex('idx_customer', 'customerId', { unique: false }); // 建唯一索引:按orderNo查询 store.createIndex('idx_orderNo', 'orderNo', { unique: true }); }; request.onsuccess = function(event) { const db = event.target.result; // 后续操作都通过db实例 }; request.onerror = function(event) { console.error('打开数据库失败', event.target.error); };这里注意一点:createObjectStore和createIndex只能在onupgradeneeded里调用。如果你想在数据库已经存在的情况下修改结构,唯一的办法是把版本号调高,然后在新触发的onupgradeneeded里继续操作。如果单纯在onsuccess里尝试createObjectStore,会直接抛错。
3.2 增加数据:readwrite事务的写操作
写入数据之前,必须先开启一个读写事务。这里用一个网上常见的示例来演示:往orders仓库里添加一条订单记录。
function addOrder(db, order) { return new Promise((resolve, reject) => { const tx = db.transaction('orders', 'readwrite'); const store = tx.objectStore('orders'); const request = store.add(order); request.onsuccess = function() { resolve(request.result); // 返回新记录的key }; request.onerror = function(event) { // 注意:事件冒泡会导致事务abort event.stopPropagation(); reject(request.error); }; tx.oncomplete = function() { // 事务成功提交 }; tx.onabort = function() { reject(tx.error); }; }); }写操作尤其需要处理onerror里的事件冒泡问题。在IndexedDB里,如果某个请求出错且你没有调用event.stopPropagation(),错误会一路冒泡到事务层,导致整个事务被标记为abort,所有操作回滚。有时候你只是想处理单条请求的失败(比如主键冲突),结果整个事务都崩了,这种情况下记得在onerror里拦截。
3.3 读取数据:get、getAll和游标查询
读取数据相对简单。使用store.get(key)可以按主键读取单条记录;store.getAll()可以读取仓库里所有记录;index.get(value)是按索引值读取第一条匹配记录。如果查询条件比较复杂,比如按范围查询、排序、分页,你就需要游标(cursor)了。
function getOrdersByCustomer(db, customerId) { return new Promise((resolve, reject) => { const tx = db.transaction('orders', 'readonly'); const store = tx.objectStore('orders'); const index = store.index('idx_customer'); // 游标遍历所有匹配记录 const request = index.openCursor(IDBKeyRange.only(customerId)); const results = []; request.onsuccess = function(event) { const cursor = event.target.result; if (cursor) { results.push(cursor.value); cursor.continue(); } else { resolve(results); // 游标遍历结束 } }; request.onerror = function() { reject(request.error); }; }); }游标的核心机制是:每次continue()都会触发一次新的onsuccess事件,直到遍历完所有匹配记录。你可以用IDBKeyRange构造各种查询范围,only是精确匹配,lowerBound是从某个值开始,upperBound是到某个值结束,bound是区间查询。排序靠游标的方向参数控制,next是升序,prev是降序。这个机制初看不太习惯,但它就是IndexedDB里最核心的查询方式,一定要掌握。
3.4 修改和删除:put与delete的使用边界
更新一条记录可以直接用put方法。put的行为是:如果主键已存在,则覆盖整条记录;如果主键不存在,则插入新记录。这就意味着put是“有则更新,无则插入”的语义,和add(只在无主键冲突时插入)不同。
function updateOrder(db, order) { return new Promise((resolve, reject) => { const tx = db.transaction('orders', 'readwrite'); const store = tx.objectStore('orders'); const request = store.put(order); request.onsuccess = function() { resolve(); }; request.onerror = function(event) { event.stopPropagation(); reject(request.error); }; }); }删除同样简单,store.delete(key)传入主键值即可。但要注意:如果你需要删除多条符合某个索引条件的记录,IndexedDB没有提供“按索引批量删除”的原生API,你需要先通过游标找到所有匹配的主键,再逐一调用delete。这个过程是真实的异步操作序列,不是在一个事务里用一条命令完成。
4. 进阶:游标、索引查询和批量操作的性能实践
4.1 游标的性能开销与优化思路
很多人觉得游标操作很慢,实际是因为它每次continue()都会经历一次完整的请求-事件循环。数据量大的时候,逐条游标遍历确实会有性能问题。常见优化思路有两种:一是尽量在事务内完成所有游标遍历,减少事务开启次数;二是尽可能用getAll或getAllKeys一次性拉出结果,而不是逐条continue。
// 使用getAll一次性获取所有匹配记录 function getOrdersPaginated(db, offset, limit) { return new Promise((resolve, reject) => { const tx = db.transaction('orders', 'readonly'); const store = tx.objectStore('orders'); const request = store.getAll(); request.onsuccess = function() { const all = request.result; resolve(all.slice(offset, offset + limit)); }; request.onerror = function() { reject(request.error); }; }); }不过getAll也有局限:它无法支持排序(底层游标是自然顺序,按主键升序返回),也无法支持复杂的条件过滤。所以实际项目中我一般这么分工:精确查询用index配合IDBKeyRange;简单全量数据用getAll;需要排序或分页的时候用游标,但游标内循环结束后要break防止不必要的遍历。这几种方式组合起来,大部分查询场景都能覆盖。
4.2 批量写入的正确姿势:事务复用
往仓库里批量塞几千条数据时,新手常见的错误是每条数据单独开一个事务,结果写入速度慢到无法接受。正确做法是:在一个事务里连续发起多条add请求,让它们共享同一个事务,最后统一提交。
function bulkAddOrders(db, orders) { return new Promise((resolve, reject) => { const tx = db.transaction('orders', 'readwrite'); const store = tx.objectStore('orders'); orders.forEach(order => { store.add(order); // 注意:这里无需处理每个请求的onsuccess }); tx.oncomplete = function() { resolve(); }; tx.onerror = function(event) { event.stopPropagation(); reject(tx.error); }; tx.onabort = function() { reject(tx.error); }; }); }这里的关键点在于:多个add请求在同一个事务中逐个执行,浏览器会按顺序把操作入队,事务结束后一次性提交。如果其中某一条add因为主键冲突失败,会导致整个事务abort,所有写入全部回滚。这种“全量回滚”语义虽然保证了原子性,但也意味着你必须在写入前处理好主键冲突的逻辑,比如先查询判断再写入,或者干脆用put覆盖。
4.3 索引查询的边界:复合索引与多条件筛选
单字段索引能解决一部分查询需求,但现实业务经常需要“按时间范围查某个客户的订单”这种复合条件。IndexedDB支持复合索引,也就是对多个字段建立联合索引。
// 创建复合索引:customerId + time store.createIndex('idx_customer_time', ['customerId', 'time'], { unique: false }); // 查询客户在某个时间段的订单 const range = IDBKeyRange.bound( [customerId, startTime], [customerId, endTime] ); const request = index.openCursor(range);复合索引的本质是把多个字段的值拼成一个数组作为索引键,查询时必须按顺序从第一个字段开始匹配。如果你要查询的字段组合里第一个字段没指定值,索引就无法使用。这一点和SQL里复合索引的最左前缀原理是一样的。所以在设计索引时,一定要分析查询条件的组合习惯,把区分度高、查询频率高的字段放在前面。
5. 常见坑与排查经验:事务生命周期、版本升级和错误处理
5.1 事务自动提交的陷阱
事务什么时候提交?不是在你调用某个提交方法的时候,而是在当前任务队列执行完毕、没有更多待处理请求时,浏览器自动提交。这意味着如果你在事务的回调里发起了异步请求(比如fetch),然后想在fetch回来后再往事务里塞一条操作,那已经晚了——事务早就提交了。这个陷阱非常隐蔽,很多人写代码时觉得一切正常,但数据就是没写入,排查半天才发现是事务生命周期的问题。
解决方案是:所有事务内的操作必须在同一个执行序列里同步入队,不能夹带任何跨宏任务的操作。如果确实需要先请求网络再写入数据,那就在网络请求完成后再新建事务。
5.2 版本升级异常:升级事务里不能做的操作
versionchange事务比较特殊。首先,在onupgradeneeded中,你不能异步等待(比如不能await一个fetch结果后再建索引),因为版本升级事务在执行过程中不允许有其他操作介入。其次,如果升级过程中出现任何错误,整个数据库都会变成阻塞状态,后续所有连接都无法打开。这实际是个很严重的问题,但很少有人提前说明。
我踩过的一个坑是:升级时给已有仓库建索引,但仓库里已经有大量旧数据,有些数据不满足索引字段的要求(比如缺字段),建索引操作直接报错,整个数据库版本升级事务就abort了。排查了很久才明白,IndexedDB建索引时会扫描所有现有数据,数据不符合要求就会失败。解决方案是先写一个数据清洗逻辑,在升级事务的开头把旧数据补全或者清理掉,再建索引。
5.3 浏览器兼容性与安全隐患检查
IndexedDB的兼容性在现代浏览器里已经很成熟了,但有两个点要特别注意:一是私有模式(无痕模式)下,部分浏览器会禁用IndexedDB或表现为打开数据库后立即报错;二是存储空间配额不够时,写入操作会失败并抛出QuotaExceededError。这两种情况都需要在业务代码里做兜底。
function checkIDBCompatibility() { return new Promise((resolve) => { if (!('indexedDB' in window)) { resolve(false); return; } try { const request = indexedDB.open('__compat_test__', 1); request.onsuccess = function(event) { const db = event.target.result; db.close(); indexedDB.deleteDatabase('__compat_test__'); resolve(true); }; request.onerror = function() { resolve(false); }; } catch (e) { resolve(false); } }); }这段代码专门用来做启动时的能力检测。如果检测不通过,就应该把存储策略切换到内存存储或服务端存储,而不是让应用在数据写入失败时才暴露问题。
5.4 数据损坏与备份策略
虽然IndexedDB在正常情况下比较稳定,但浏览器崩溃、磁盘异常、版本升级失败等情况仍可能导致数据损坏。我习惯的做法是:为关键数据做双写备份。比如某个离线应用,我会在每次写入主仓库的同时,把最近一份数据序列化后放到localStorage作为兜底快照(但注意localStorage容量只有5MB左右,所以这个快照只能放精简数据)。另一个做法是定期导出数据为JSON文件,让用户手动备份。大项目里还可以考虑用Service Worker做定期快照同步到服务器。
从实际体验来说,LocalStorage适合小而频繁的配置项,IndexedDB适合大而结构化的业务数据。判断标准很简单:如果数据超过几百KB、或者需要索引查询、事务保证一致性,就直接用IndexedDB,别绕弯。
6. 用一个完整示例串联全流程:订单管理Demo
讲概念和API容易散,我习惯用一个完整demo把整个链路串起来。假定你要做一个本地订单管理的演示应用,支持添加订单、按客户查询、按时间范围统计、删除订单。
6.1 初始化数据库结构
function initDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('orderDemo', 1); request.onupgradeneeded = function(event) { const db = event.target.result; if (!db.objectStoreNames.contains('orders')) { const store = db.createObjectStore('orders', { keyPath: 'id', autoIncrement: true }); store.createIndex('idx_customer', 'customerId', { unique: false }); store.createIndex('idx_time', 'time', { unique: false }); store.createIndex('idx_status', 'status', { unique: false }); } }; request.onsuccess = function(event) { resolve(event.target.result); }; request.onerror = function() { reject(request.error); }; }); }autoIncrement: true的意思是主键由浏览器自动生成。如果你在业务场景里不想维护主键值,这个配置就非常省事。但要注意:自增主键不会因为删除记录而重置,这个行为不必感到意外。
6.2 初始化演示数据
async function seedOrders(db) { const orders = [ { customerId: 'A1001', time: Date.now() - 86400000 * 3, status: 'pending', amount: 128 }, { customerId: 'A1002', time: Date.now() - 86400000 * 2, status: 'done', amount: 256 }, { customerId: 'A1001', time: Date.now() - 86400000 * 1, status: 'done', amount: 512 } ]; await bulkAddOrders(db, orders); }这个seed数据模拟了3条不同客户、不同时间、不同状态的订单。有了这组基础数据,才能验证后面的索引查询是否正确。
6.3 综合查询示例
需求:查客户A1001最近两天的订单,按时间倒序排列。
async function queryOrders(db, customerId, startTime, endTime) { const tx = db.transaction('orders', 'readonly'); const store = tx.objectStore('orders'); const index = store.index('idx_customer_time'); // 根据索引设计选择查询方式 // 这里直接用customerId索引精确匹配,再在JS里过滤时间 const request = index.openCursor(IDBKeyRange.only(customerId)); const results = []; return new Promise((resolve, reject) => { request.onsuccess = function(event) { const cursor = event.target.result; if (cursor) { const order = cursor.value; if (order.time >= startTime && order.time <= endTime) { results.push(order); } cursor.continue(); } else { // 按时间倒序排序 results.sort((a, b) => b.time - a.time); resolve(results); } }; request.onerror = function() { reject(request.error); }; }); }注意这里没有用复合索引,而是用单字段索引加JS过滤。这个设计是有原因的:示例数据量小,JS过滤的性能损失可以忽略;但如果你要处理几十万条数据,就应该像前面说的那样建复合索引,用IDBKeyRange.bound直接在索引层完成过滤。选择哪种方案,取决于你已经了解的数据特征。这也是实战里最需要灵活变通的地方。
7. 数据库升级策略:从v1到v2的平滑迁移
实际开发中,需求变化肯定会导致数据结构调整。IndexedDB的版本升级机制不是让你直接改旧库的结构,而是通过提升版本号触发升级事务,在升级事务里对新旧结构做迁移。这里分享一个我常用的升级模式。
7.1 升级流程全景
function migrateDB(oldVersion, newVersion) { return new Promise((resolve, reject) => { const request = indexedDB.open('orderDemo', newVersion); request.onupgradeneeded = function(event) { const db = event.target.result; const tx = event.target.transaction; const oldVer = event.oldVersion; // 按版本号逐步迁移 if (oldVer < 2) { // v1 -> v2: 新增一个字段索引 const store = tx.objectStore('orders'); if (!store.indexNames.contains('idx_amount')) { store.createIndex('idx_amount', 'amount', { unique: false }); } } if (oldVer < 3) { // v2 -> v3: 新增一个对象仓库 if (!db.objectStoreNames.contains('customers')) { const customers = db.createObjectStore('customers', { keyPath: 'id' }); customers.createIndex('idx_name', 'name', { unique: false }); } } }; request.onsuccess = function() { const db = event.target.result; resolve(db); }; request.onerror = function() { reject(request.error); }; }); }这种按oldVersion做分段迁移的方式,最大的好处是:无论用户当前停留在哪个旧版本,只要打开应用,就能一路按顺序迁移到最新版本,不会出现跨版本冲突。
7.2 升级时的数据清洗策略
升级不只是加字段加索引,很多时候还要清洗旧数据。比如v1的订单数据里没有status字段,但v2的查询逻辑依赖status索引。此时如果直接建索引一定会报错。正确的做法是:在同一个版本升级事务里,先遍历所有旧数据,补上默认值,再建索引。
if (oldVer < 2) { const store = tx.objectStore('orders'); // 遍历所有旧数据,补status字段 const cursorReq = store.openCursor(); cursorReq.onsuccess = function(event) { const cursor = event.target.result; if (cursor) { const data = cursor.value; if (!data.status) { data.status = 'unknown'; cursor.update(data); // 原地更新 } cursor.continue(); } }; // 注意:必须在游标遍历完成后建索引 tx.oncomplete = function() { if (!store.indexNames.contains('idx_status')) { store.createIndex('idx_status', 'status', { unique: false }); } }; }这里有个容易出错的点:升级事务里的createIndex必须在游标遍历结束之后进行。我之所以在tx.oncomplete里再建索引,就是为了确保数据清理先行完成。如果你在游标遍历的中途直接建索引,IndexedDB会抛InvalidStateError。
8. 我的一些实践经验:关于错误边界与代码组织
8.1 给所有数据库操作包一层Promise封装
IndexedDB的原生API是基于事件回调的,如果直接在业务代码里到处写request.onsuccess,代码会非常散乱。我建议把所有的数据库操作封装成统一的Promise风格工具函数,比如前面写的addOrder、queryOrders这类。这样业务层只需要await即可,代码清晰度和错误处理都会方便很多。
实际项目中,我一般会维护一个db.js工具模块,里面集中管理:数据库连接(单例)、版本迁移逻辑、所有仓库的增删改查函数。业务组件不复用底层API,而是通过工具模块调用。这个习惯在项目规模变大后收益非常明显——数据库结构变更时,只需要改一个文件,而不是满项目搜索。
8.2 错误处理的分类策略
IndexedDB的错误类型五花八门,但归类起来无非几种:
| 错误类型 | 常见原因 | 处理建议 |
|---|---|---|
| QuotaExceededError | 存储空间不足 | 提示用户清理空间,或降级存储策略 |
| ConstraintError | 主键或唯一索引冲突 | 改用put或先查询再写入 |
| InvalidStateError | 事务已提交后继续操作 | 检查是否在异步回调里操作了旧事务 |
| TransactionInactiveError | 事务已结束 | 检查事务生命周期 |
| VersionError | 版本号比当前低 | 检查版本号逻辑 |
排查错误时,先看错误名,再看错误消息,基本就能定位问题。我最常遇到的是ConstraintError,尤其是在批量导入数据的时候。这种情况下最好的解法是导入前先在内存里去重,再决定用add还是put。
8.3 数据量过大时的分页策略
getAll会把全量数据一次性拉进内存,数据量上百MB时这其实是不可接受的。正确的分页方式是游标加IDBKeyRange,每次只读一个相对小的数据块。经典做法是用openCursor记录最后一条key,然后下一次从那个key之后继续查询。
async function getPage(db, storeName, lastKey, pageSize) { const tx = db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); const range = lastKey ? IDBKeyRange.lowerBound(lastKey, true) : null; const request = store.openCursor(range); const results = []; return new Promise((resolve, reject) => { request.onsuccess = function(event) { const cursor = event.target.result; if (cursor && results.length < pageSize) { results.push(cursor.value); cursor.continue(); } else { resolve({ data: results, lastKey: results.length ? results[results.length - 1].id : lastKey, hasMore: !!cursor }); } }; request.onerror = function() { reject(request.error); }; }); }这种游标分页方案在数据量大时表现稳定,因为它不会在内存中保存全量数据,而是按需逐块拉取。lowerBound(lastKey, true)排除了lastKey本身,保证下一页不会出现重复数据。这个方案在手写分页组件时非常实用,建议收藏。
9. 结尾:说点实际的
最后分享一个实用的小技巧:IndexedDB的调试。大多数人会在Chrome的Application面板里手动查看数据,但如果你的数据结构比较复杂、索引比较多,逐个点击查看效率很低。我习惯在开发环境里写一个Debug工具函数,一键导出整个数据库的JSON,方便核对数据是否正确。
async function dumpDB(db) { const storeNames = Array.from(db.objectStoreNames); const result = {}; for (const storeName of storeNames) { const tx = db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); const data = await new Promise((resolve) => { const req = store.getAll(); req.onsuccess = () => resolve(req.result); }); result[storeName] = data; } return result; }IndexedDB这套API上手需要一点时间,但一旦你用顺了,离线应用、本地缓存、批量数据管理这类需求都会变得非常可控。我个人的体会是:不要被它的回调风格吓退,用Promise封装一遍之后,它的开发体验和用小型NoSQL数据库差别不大。如果你正准备在项目里落地本地存储方案,IndexedDB值得你投入时间。