1. 项目概述:为什么是IndexedDB
1.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 搜索功能与游标性能的平衡
对IndexedDB里的文本做模糊搜索,目前没有原生的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,等到网络恢复时再批量上传到服务端。在didn't尝试过之前,我总觉得Service Worker和IndexedDB的组合会很复杂,真正实现下来后会发现,挂载在全局的Promise封装思路在Worker里同样成立,只是你不能访问window对象,得用self.indexedDB。
这个方向如果你感兴趣,之后的文章我可以展开讲讲具体的同步机制。这里想强调的核心是:IndexedDB不只是一个“页面里存点数据的小工具”,它其实是浏览器赋予前端应用真正的本地数据库能力,对于离线优先、弱网补偿、大文件分块这类需求,扎扎实实研究好它是很值得的。
我个人的经验是,IndexedDB重要的是在动手前把“存什么、怎么查、何时升级”这三件事想清楚。很多看起来复杂的问题(像重复打开连接、事务失效、游标不迭代),本质上是没有理解它的生命周期模型。跑通一个增删改查只是起步,把它当作一个真正有事务约束的数据库来对待,后续的设计和扩展才会顺畅得多。