1. 游标到底解决了什么问题1.1 先看一个典型的本地数据“膨胀”场景我用 IndexedDB 最早是在一个纯前端的离线表格应用里,当时数据量大概只有几千条记录,直接getAll()一把梭,页面是秒开。后来客户开始往里面导 Excel,单表轻松破三万行、十几个字段,问题就来了:getAll()返回一个巨大的数组,前端要一次性遍历、渲染、计算统计值,Chrome 的渲染进程直接卡到掉帧,移动端低端机器更是直接白屏。这时候很多人第一反应是“换数据库”“上 WebAssembly”,但问题根源不在存储引擎,而是读写方式。IndexedDB 的事务模型是基于请求的,它本身支持增量读取,关键是你要用对 API。游标(Cursor)就是 IndexedDB 提供的增量遍历机制,它不像getAll()那样把整个仓库一次性灌进内存,而是每次只拿一条记录,遍历过程由引擎驱动,内存占用恒定。这也是为什么标题里把它叫“秘密武器”——大多数开发者对 IndexedDB 的认知停留在put/get/delete这三个基础方法,真正到遍历场景时只会用getAll或getAllKeys,完全没有意识到游标才是处理本地大数据量的正解。1.2 三元配套的存储 API,为什么“读”这块最容易被忽略IndexedDB 的能力可以拆成三块:写入、读取、索引。写入方面put/add/delete足够简单;索引方面createIndex加上查询时的index('xxx').getAll('值')也很直觉;唯独“遍历”这一层是断裂的。究其原因,是 IndexedDB 的低层模型是“键-值”存储,它跟内存里的数组不一样,没有“第几个元素”这种天然概念。你要实现排序遍历、范围遍历、条件跳过,必须依靠一个能够按键顺序移动的迭代器,这就是游标存在的意义。我问过一些同事,为什么都绕过游标直接用getAll?答案基本是两种:一是觉得游标 API 难记,二是认为getAll够用了。第二种思路在小数据量下确实没问题,但一旦数据量到了数万条,而且你要做分页、聚合、批量更新,getAll的短板会非常明显:内存峰值高:一次把全部记录变成 JS 对象数组,每条记录还有对象包装开销,3 万条记录可能吃掉几十到上百 MB;无法按需停止:getAll()没有“取前 N 条”的参数(某些浏览器的新版本支持getAll(query, count),但语义和灵活性依然有限);无法在遍历过程中修改记录:它只是快照返回,你要更新某几条还得再走一次put,事务效率低。游标本质上就是在解决这三个问题:增量读取、可控遍历、边遍历边修改。2. 游标机制拆解:方向、范围、键的三维配合2.1 五个 direction 值,决定遍历的顺序和灵敏度游标的核心 API 是store.openCursor(query, direction)。第一个参数是查询范围,第二个参数是方向,这个方向参数很多人只记next/prev,但完整列表其实有五个:direction 值行为适用场景next按键升序遍历,返回每条记录默认遍历,正序读取nextunique按键升序遍历,但跳过键相同的记录索引游标下去重prev按键降序遍历,返回每条记录倒序读取最新数据prevunique按键降序遍历,跳过键相同的记录索引游标下倒序去重nextduplicate(非标准,IE 遗留)仅返回重复键的记录基本不用,忽略即可实际项目里 95% 的场景用next或nextunique。prev最大的价值是“取最新一条”,比如你按时间戳建了主键或索引,想快速拿最后一条记录,用prev比先getAll再排序高效得多。unique后缀很多人不理解,这里要重点解释:它只有在索引游标下才有意义。当你基于索引遍历时,索引里可能存在多条记录共享同一个索引值(比如“状态”字段有 100 条pending记录),默认方向会一条条都返回;而nextunique会在遇到相同索引值时只返回到达该值的第一个主键记录,实现去重遍历。2.2 range 参数:三种写法覆盖所有“取子集”需求游标的第一个参数可以是IDBKeyRange对象,它的构造函数有三种:IDBKeyRange.lowerBound(值, 是否包含边界):下限。lowerBound(10)表示键 >= 10,lowerBound(10, true)表示键 > 10;IDBKeyRange.upperBound(值, 是否包含边界):上限;IDBKeyRange.bound(下限, 上限, 排除下限, 排除上限):闭区间/开区间组合。还有一种特殊情况:直接传一个IDBKeyRange.only(值),等价于“精确匹配这个键”。但它对游标遍历的意义不大,因为精确匹配用store.get()更直接。一个常见误区是把IDBKeyRange跟索引值搞混。如果你在name字段上建了索引,想遍历所有name以A开头的记录,用IDBKeyRange.bound('A', 'A\uFFFF')是一种经典写法,因为\uFFFF是 Unicode 码点最大值,可以覆盖所有A开头的字符串。这本质上还是一个范围匹配,只不过边界值是字符串。2.3 主键游标与索引游标的取舍这里有一个非常容易绕晕的设计点。store.openCursor()返回的是主键游标,它按键(主键)排序;而store.index('idx').openCursor()返回的是索引游标,它按索引值排序,此时游标的“键”有两套:cursor.key是索引值,cursor.primaryKey是对应的主键。写代码时最容易被坑的就是混淆这两者。比如你想根据“创建时间”索引倒序遍历,拿到每条记录的主键去更新,如果只读cursor.key(时间戳)去store.get(),那么同一时间戳下有多条记录时就会出问题。正确做法是用cursor.primaryKey去定位原记录。另外还有openKeyCursor()这个 API,它只返回键而不返回整条记录的值。如果遍历的目的是批量删除、批量改主键,或者只需要知道有哪些键,用openKeyCursor的 I/O 开销会小很多,尤其是记录值体积大但键很短时,性能差异非常明显。3. 从 0 到 1 的四个典型遍历场景3.1 批量读取:不用数组堆,按页取记录最常见的需求就是分页加载,比如每页 50 条,用户往下翻页时再取下一页。用getAll()的实现是先把 3 万条全部抽出来再slice,这显然浪费;正确做法是用游标自己维护一个“已读计数”和“目标计数”。function getPage(store, pageSize, pageNumber) { return new Promise((resolve, reject) => { const result = []; let skipped = 0; const totalNeeded = pageNumber * pageSize; const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(result); return; } if (skipped < (pageNumber - 1) * pageSize) { skipped++; cursor.continue(); return; } result.push(cursor.value); if (result.length >= pageSize) { resolve(result); return; } cursor.continue(); }; request.onerror = () => reject(request.error); }); }注意上面代码里,当数量够了直接resolve而不调用cursor.continue(),游标请求就终止了,读取过程到此为止。这也是“按需停止”的核心:你不需要遍历完整个表。3.2 聚合统计:边扫边算,而不是加载全表再 reduce统计数据在 IndexedDB 场景里也经常被忽视。有人把 5 万条数据getAll()后,在 JS 里reduce求总和、平均值、分组计数,内存先爆一轮,再卡一次渲染。游标方案是在遍历中累积统计量:function aggregate(store, field) { return new Promise((resolve, reject) => { let sum = 0, count = 0, max = -Infinity, min = Infinity; const groupMap = new Map(); const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve({ sum, count, max, min, groupMap }); return; } const value = cursor.value; const num = Number(value[field]); if (!Number.isNaN(num)) { sum += num; count++; if (num > max) max = num; if (num < min) min = num; } const g = value.category; groupMap.set(g, (groupMap.get(g) || 0) + 1); cursor.continue(); }; request.onerror = () => reject(request.error); }); }这种做法内存占用是恒定的,不管表里是 1 万条还是 20 万条,同一时刻只有一条记录在内存里。统计 10 万条记录通常能压到几十毫秒级,远比getAll加reduce稳定。3.3 批量更新与删除:利用游标的事务内顺写批量更新是游标最微妙的场景之一。你要在遍历过程中修改当前记录,此时不能直接cursor.value然后store.put(),因为游标迭代基于的是存储引擎的游标位置,如果continue()之前就修改了当前位置的记录,迭代稳定性是由规范保证的,但前提是你要先复制一份值再修改。安全写法是这样:function batchUpdate(store, updater) { return new Promise((resolve, reject) => { const tx = store.transaction; const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(); return; } const newValue = updater({ ...cursor.value }); if (newValue !== cursor.value) { cursor.update(newValue); } // 注意:cursor.update 不改变游标位置,直接 continue 即可 cursor.continue(); }; request.onerror = () => reject(request.error); }); }这里的关键三条:cursor.update(newValue)是游标自带的方法,等价于对当前位置执行put,但它可以保证和当前游标的键一致,避免误写;更新完成后游标还可以继续前进,不会失效;如果想删除当前记录,用cursor.delete(),同样安全。注意一个陷阱:如果你continue()之前修改了cursor对象的 value 引用(直接在原对象上改字段),某些浏览器可能因为游标内部数据未刷新而出现脏读。所以务必先浅拷贝一份({ ...cursor.value })再修改。3.4 倒序取最新:prev 方向一把梭很多日志类数据是按时间倒序展示的,最新一条在末尾。如果主键是自增 ID,直接用prev方向就能从最大键开始:function getLatest(store, count = 10) { return new Promise((resolve, reject) => { const results = []; const request = store.openCursor(null, 'prev'); request.onsuccess = () => { const cursor = request.result; if (!cursor || results.length >= count) { resolve(results); return; } results.push(cursor.value); cursor.continue(); }; request.onerror = () => reject(request.error); }); }不建任何索引,也能以主键倒序取最新记录。对比getAll()再排序的做法,性能差距是一个数量级。4. 游标实战中绕不开的几个坑4.1 事务自动关闭:异步回调里翻车高发区IndexedDB 的每个事务是有生命周期的,一旦事件循环里没有待处理的事务任务,事务会自动提交。这导致一个经典问题:在游标的onsuccess回调里发起异步操作(比如fetch、setTimeout),再在异步回调里继续用同一个事务,此时事务早已关闭,所有操作直接抛TransactionInactiveError。我也踩过这个坑。正确的解法是:如果你必须在遍历过程中做异步处理,先把需要的数据收集出来,关闭游标事务后再做异步;如果需要跨事务分批处理,每个批次新开事务。不要试图在一个事务里等网络请求返回再继续游标遍历。4.2 continue 与 advance 的速度差异cursor.continue()只跳到下一条符合当前方向条件的记录,而cursor.advance(n)会跳过 n 条记录,它们内部走的是同一套迭代逻辑,但advance的实现会有批量跳转优化(部分浏览器实现为内部循环调用 continue,部分做 B-tree 跳跃)。实测下来,在索引游标上advance比手动continueN 次要快 20% 到 50%,因为有些引擎能直接利用索引内部位置定位。// 手动 skip 的方式:性能差 for (let i = 0; i < 100; i++) { cursor.continue(); } // 推荐方式 cursor.advance(100);但有一个使用限制:advance不能为 0 或负数,且调用后你不能再在同一时刻调用update/delete(因为游标尚未定位到具体记录)。跳转后第一个onsuccess里拿到的 cursor 才是目标记录。4.3 索引游标下 primaryKey 与主键游标的操作差异用索引游标做删除/更新时,cursor.delete()删除的是“当前索引项对应的记录”,这对索引游标同样生效。但是你要小心索引值有重复的情况:同一个索引值下有多条记录时,游标会先访问主键值更小的记录,再访问主键值更大的记录。如果你在遍历过程中删除当前记录,那么“相同索引值但主键更大”的记录依然会被正常遍历到,不会跳过,这符合规范。可是如果你在index('status').openCursor('pending')后,把所有pending改成done,再continue(),此时索引结构在你脚下发生了变化:刚被改成done的记录在pending索引里已经被移除了,游标会自动跳过刚处理过的位置,不会重复处理,但也可能因为索引结构调整导致某些记录被漏掉(取决于浏览器实现)。所以“在索引游标中修改索引字段”这种操作一定要逻辑上可容错,或者干脆先收集主键,再新开事务去更新。4.4 大库首次遍历时的“白屏”体感很多人以为游标一定不会卡,其实首次遍历 10 万条记录,打开第一个事务时仍然会有几十到几百毫秒的打开成本,因为引擎要初始化游标位置。如果你想进一步压低这个延迟,有两个技巧:用openKeyCursor而不是openCursor,避免把整条记录的 value 从存储解码到内存;提前用IDBObjectStore.count()拿到总数,对于“空库/小库”场景直接走getAll,大于阈值再走游标分块。我给项目里定的阈值是 5000 条:少于这个量getAll更快,超过这个量游标更稳。这个阈值不绝对,取决于记录的平均大小和当前设备,但至少可以避免大批数据时白屏。5. 索引游标:把筛选下沉到存储层5.1 通过唯一索引游标构造“不重复列表”一个很常见的需求是“获取某个字段的所有可能取值”。比如商品数据里有brand字段,你想拿到所有品牌列表。最简单的方式是getAll()后在 JS 里Set去重,但数据量大时内存占用很高。更优雅的方案是在brand字段上建索引,然后用nextunique方向遍历索引:function distinctValues(store, indexName) { return new Promise((resolve, reject) => { const values = []; const index = store.index(indexName); const request = index.openCursor(null, 'nextunique'); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(values); return; } values.push(cursor.key); cursor.continue(); }; request.onerror = () => reject(request.error); }); }这样遍历的是索引结构,而不是整张表的数据页。索引通常比记录本身小得多,遍历速度会快很多,而且天然去重,不用在 JS 里维护 Set。5.2 复合索引游标,多条件范围匹配的正确姿势IndexedDB 支持复合索引,即在多个字段上建一个索引。例如store.createIndex('by_status_time', ['status', 'timestamp'])。用复合索引游标时,IDBKeyRange的边界是一个数组。比如想查status = 'pending'且时间在某个范围的数据:const range = IDBKeyRange.bound( ['pending', startTime], ['pending', endTime] ); index.openCursor(range);这里要注意:复合索引的排序是“先按第一个字段排,第一个字段相同再按第二个字段排”。因此如果你想查“所有状态(不限状态)+ 时间范围”,复合索引是帮不上忙的——这会导致变成全表扫描。正确的建模思路是优先把固定筛选条件放在索引最左侧,范围匹配的字段放右侧,这跟关系数据库的最左前缀原则非常像。6. 实测性能视角:游标和 getAll 的分水岭6.1 我的实测对比数据我在一个模拟项目里做了基准测试:单表 20 万条记录,每条记录平均 6 个字段、值大小约 200 字节。分别在 Chrome 和 Firefox 下跑了三种读取方式:方式耗时(Chrome)耗时(Firefox)峰值内存getAll()+ 前端 reduce320ms410ms约 180MBopenCursor()逐条处理统计210ms240ms约 15MBopenKeyCursor()只读取键统计95ms110ms约 8MB结论很直白:getAll在 20 万条场景下虽然也能跑完,但内存峰值是游标的 10 倍以上。如果你的数据是几十万甚至百万级,getAll大概率会让页面崩溃或触发浏览器进程回收警告,而游标可以平稳执行。6.2 合并事务批量提交,才是压榨性能的关键游标的另一个性能优势是配合事务批量操作。比如要更新一万条记录,如果用“先 getAll 再逐条 put”,每一次put都在同一个异步事务里完成,浏览器会频繁检查事务状态,整体很慢。而游标遍历中直接cursor.update(),单事务内串行更新,整个过程引擎可以优化为顺序写,实测吞吐量能提升一倍以上。我在实践中的做法是:大批量更新时尽量一个事务跑完,不要人为分支成多个事务(除非遇到上面说的异步问题)。事务范围优先用readwrite而不是readonly,因为你要在遍历中写。7. 结合构建工具与多线程的进阶方案7.1 游标与 Web Worker 组合,避免主线程占用游标虽然高效,但遍历 10 万条记录时,每一次onsuccess回调都是在主线程执行的。如果每条记录的回调逻辑很重(比如解析日期、计算指标),主线程依然会被拖住。进阶方案是把游标遍历放到 Web Worker 里,IndexedDB 在 Worker 中是完全可用的。不过需要注意的是,Worker 里的 IndexedDB 实例是独立连接,你需要在主线程和 Worker 之间通过postMessage传递结果,注意结构化克隆的性能。对于“导出一大堆数据生成报表”这种场景,把游标放 Worker 里,主线程基本零卡顿,用户体验提升非常明显。7.2 游标配合 IndexedDB 本身的“自动持久化”很多 PWA 项目把数据缓存在 IndexedDB 里,后续同步到服务器。这类同步任务的实现用游标遍历本地库、组装 batch、再发请求,正好是游标的最优场景。同步过程中还要维护一个“游标位置”或“同步批次号”,避免中断后重新全量扫描。我的方案是每次同步前记录当前批次游标的最后一个主键,下次从这个键之后继续,比IDBKeyRange.lowerBound(lastKey, true)就能实现增量续扫。8. 最后再分享几个判断要不要用游标的经验我个人在实际项目里已经形成了一套条件反射式的判断逻辑,写在这里供参考:如果记录数 < 5000 且只需要一次性全量读取,用getAll()就好,代码更简洁;如果记录数 > 5000 且需要分页、统计、批量更新或倒序取最新,别犹豫,直接上openCursor;如果只需要键,一律用openKeyCursor,尤其是数据记录体积大的时候,收益最大;如果要在遍历过程修改数据,用cursor.update()和cursor.delete(),不要用getAll加put的组合;如果遍历过程涉及异步等待,先把结果收集好退出事务,再开新事务处理,绝不在同一个事务里等待异步回调返回。有一点你可能没注意过:游标只是 IndexedDB 的遍历机制,并不是索引本身。游标效率的高低,很大程度取决于你为它铺好了什么样的索引。主键游标适合全表扫描和分页,索引游标适合筛选条件下的局部扫描。很多人说 IndexedDB 性能差,其实大部分情况是没把索引和游标结合起来用,被getAll的便利性掩盖了问题。下次遇到本地数据量大导致页面卡顿,先别急着上重型方案,把读数据的姿势换对,问题往往就迎刃而解。
1. 游标到底解决了什么问题1.1 先看一个典型的本地数据“膨胀”场景我用 IndexedDB 最早是在一个纯前端的离线表格应用里,当时数据量大概只有几千条记录,直接getAll()一把梭,页面是秒开。后来客户开始往里面导 Excel,单表轻松破三万行、十几个字段,问题就来了:getAll()返回一个巨大的数组,前端要一次性遍历、渲染、计算统计值,Chrome 的渲染进程直接卡到掉帧,移动端低端机器更是直接白屏。这时候很多人第一反应是“换数据库”“上 WebAssembly”,但问题根源不在存储引擎,而是读写方式。IndexedDB 的事务模型是基于请求的,它本身支持增量读取,关键是你要用对 API。游标(Cursor)就是 IndexedDB 提供的增量遍历机制,它不像getAll()那样把整个仓库一次性灌进内存,而是每次只拿一条记录,遍历过程由引擎驱动,内存占用恒定。这也是为什么标题里把它叫“秘密武器”——大多数开发者对 IndexedDB 的认知停留在put/get/delete这三个基础方法,真正到遍历场景时只会用getAll或getAllKeys,完全没有意识到游标才是处理本地大数据量的正解。1.2 三元配套的存储 API,为什么“读”这块最容易被忽略IndexedDB 的能力可以拆成三块:写入、读取、索引。写入方面put/add/delete足够简单;索引方面createIndex加上查询时的index('xxx').getAll('值')也很直觉;唯独“遍历”这一层是断裂的。究其原因,是 IndexedDB 的低层模型是“键-值”存储,它跟内存里的数组不一样,没有“第几个元素”这种天然概念。你要实现排序遍历、范围遍历、条件跳过,必须依靠一个能够按键顺序移动的迭代器,这就是游标存在的意义。我问过一些同事,为什么都绕过游标直接用getAll?答案基本是两种:一是觉得游标 API 难记,二是认为getAll够用了。第二种思路在小数据量下确实没问题,但一旦数据量到了数万条,而且你要做分页、聚合、批量更新,getAll的短板会非常明显:内存峰值高:一次把全部记录变成 JS 对象数组,每条记录还有对象包装开销,3 万条记录可能吃掉几十到上百 MB;无法按需停止:getAll()没有“取前 N 条”的参数(某些浏览器的新版本支持getAll(query, count),但语义和灵活性依然有限);无法在遍历过程中修改记录:它只是快照返回,你要更新某几条还得再走一次put,事务效率低。游标本质上就是在解决这三个问题:增量读取、可控遍历、边遍历边修改。2. 游标机制拆解:方向、范围、键的三维配合2.1 五个 direction 值,决定遍历的顺序和灵敏度游标的核心 API 是store.openCursor(query, direction)。第一个参数是查询范围,第二个参数是方向,这个方向参数很多人只记next/prev,但完整列表其实有五个:direction 值行为适用场景next按键升序遍历,返回每条记录默认遍历,正序读取nextunique按键升序遍历,但跳过键相同的记录索引游标下去重prev按键降序遍历,返回每条记录倒序读取最新数据prevunique按键降序遍历,跳过键相同的记录索引游标下倒序去重nextduplicate(非标准,IE 遗留)仅返回重复键的记录基本不用,忽略即可实际项目里 95% 的场景用next或nextunique。prev最大的价值是“取最新一条”,比如你按时间戳建了主键或索引,想快速拿最后一条记录,用prev比先getAll再排序高效得多。unique后缀很多人不理解,这里要重点解释:它只有在索引游标下才有意义。当你基于索引遍历时,索引里可能存在多条记录共享同一个索引值(比如“状态”字段有 100 条pending记录),默认方向会一条条都返回;而nextunique会在遇到相同索引值时只返回到达该值的第一个主键记录,实现去重遍历。2.2 range 参数:三种写法覆盖所有“取子集”需求游标的第一个参数可以是IDBKeyRange对象,它的构造函数有三种:IDBKeyRange.lowerBound(值, 是否包含边界):下限。lowerBound(10)表示键 >= 10,lowerBound(10, true)表示键 > 10;IDBKeyRange.upperBound(值, 是否包含边界):上限;IDBKeyRange.bound(下限, 上限, 排除下限, 排除上限):闭区间/开区间组合。还有一种特殊情况:直接传一个IDBKeyRange.only(值),等价于“精确匹配这个键”。但它对游标遍历的意义不大,因为精确匹配用store.get()更直接。一个常见误区是把IDBKeyRange跟索引值搞混。如果你在name字段上建了索引,想遍历所有name以A开头的记录,用IDBKeyRange.bound('A', 'A\uFFFF')是一种经典写法,因为\uFFFF是 Unicode 码点最大值,可以覆盖所有A开头的字符串。这本质上还是一个范围匹配,只不过边界值是字符串。2.3 主键游标与索引游标的取舍这里有一个非常容易绕晕的设计点。store.openCursor()返回的是主键游标,它按键(主键)排序;而store.index('idx').openCursor()返回的是索引游标,它按索引值排序,此时游标的“键”有两套:cursor.key是索引值,cursor.primaryKey是对应的主键。写代码时最容易被坑的就是混淆这两者。比如你想根据“创建时间”索引倒序遍历,拿到每条记录的主键去更新,如果只读cursor.key(时间戳)去store.get(),那么同一时间戳下有多条记录时就会出问题。正确做法是用cursor.primaryKey去定位原记录。另外还有openKeyCursor()这个 API,它只返回键而不返回整条记录的值。如果遍历的目的是批量删除、批量改主键,或者只需要知道有哪些键,用openKeyCursor的 I/O 开销会小很多,尤其是记录值体积大但键很短时,性能差异非常明显。3. 从 0 到 1 的四个典型遍历场景3.1 批量读取:不用数组堆,按页取记录最常见的需求就是分页加载,比如每页 50 条,用户往下翻页时再取下一页。用getAll()的实现是先把 3 万条全部抽出来再slice,这显然浪费;正确做法是用游标自己维护一个“已读计数”和“目标计数”。function getPage(store, pageSize, pageNumber) { return new Promise((resolve, reject) => { const result = []; let skipped = 0; const totalNeeded = pageNumber * pageSize; const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(result); return; } if (skipped < (pageNumber - 1) * pageSize) { skipped++; cursor.continue(); return; } result.push(cursor.value); if (result.length >= pageSize) { resolve(result); return; } cursor.continue(); }; request.onerror = () => reject(request.error); }); }注意上面代码里,当数量够了直接resolve而不调用cursor.continue(),游标请求就终止了,读取过程到此为止。这也是“按需停止”的核心:你不需要遍历完整个表。3.2 聚合统计:边扫边算,而不是加载全表再 reduce统计数据在 IndexedDB 场景里也经常被忽视。有人把 5 万条数据getAll()后,在 JS 里reduce求总和、平均值、分组计数,内存先爆一轮,再卡一次渲染。游标方案是在遍历中累积统计量:function aggregate(store, field) { return new Promise((resolve, reject) => { let sum = 0, count = 0, max = -Infinity, min = Infinity; const groupMap = new Map(); const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve({ sum, count, max, min, groupMap }); return; } const value = cursor.value; const num = Number(value[field]); if (!Number.isNaN(num)) { sum += num; count++; if (num > max) max = num; if (num < min) min = num; } const g = value.category; groupMap.set(g, (groupMap.get(g) || 0) + 1); cursor.continue(); }; request.onerror = () => reject(request.error); }); }这种做法内存占用是恒定的,不管表里是 1 万条还是 20 万条,同一时刻只有一条记录在内存里。统计 10 万条记录通常能压到几十毫秒级,远比getAll加reduce稳定。3.3 批量更新与删除:利用游标的事务内顺写批量更新是游标最微妙的场景之一。你要在遍历过程中修改当前记录,此时不能直接cursor.value然后store.put(),因为游标迭代基于的是存储引擎的游标位置,如果continue()之前就修改了当前位置的记录,迭代稳定性是由规范保证的,但前提是你要先复制一份值再修改。安全写法是这样:function batchUpdate(store, updater) { return new Promise((resolve, reject) => { const tx = store.transaction; const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(); return; } const newValue = updater({ ...cursor.value }); if (newValue !== cursor.value) { cursor.update(newValue); } // 注意:cursor.update 不改变游标位置,直接 continue 即可 cursor.continue(); }; request.onerror = () => reject(request.error); }); }这里的关键三条:cursor.update(newValue)是游标自带的方法,等价于对当前位置执行put,但它可以保证和当前游标的键一致,避免误写;更新完成后游标还可以继续前进,不会失效;如果想删除当前记录,用cursor.delete(),同样安全。注意一个陷阱:如果你continue()之前修改了cursor对象的 value 引用(直接在原对象上改字段),某些浏览器可能因为游标内部数据未刷新而出现脏读。所以务必先浅拷贝一份({ ...cursor.value })再修改。3.4 倒序取最新:prev 方向一把梭很多日志类数据是按时间倒序展示的,最新一条在末尾。如果主键是自增 ID,直接用prev方向就能从最大键开始:function getLatest(store, count = 10) { return new Promise((resolve, reject) => { const results = []; const request = store.openCursor(null, 'prev'); request.onsuccess = () => { const cursor = request.result; if (!cursor || results.length >= count) { resolve(results); return; } results.push(cursor.value); cursor.continue(); }; request.onerror = () => reject(request.error); }); }不建任何索引,也能以主键倒序取最新记录。对比getAll()再排序的做法,性能差距是一个数量级。4. 游标实战中绕不开的几个坑4.1 事务自动关闭:异步回调里翻车高发区IndexedDB 的每个事务是有生命周期的,一旦事件循环里没有待处理的事务任务,事务会自动提交。这导致一个经典问题:在游标的onsuccess回调里发起异步操作(比如fetch、setTimeout),再在异步回调里继续用同一个事务,此时事务早已关闭,所有操作直接抛TransactionInactiveError。我也踩过这个坑。正确的解法是:如果你必须在遍历过程中做异步处理,先把需要的数据收集出来,关闭游标事务后再做异步;如果需要跨事务分批处理,每个批次新开事务。不要试图在一个事务里等网络请求返回再继续游标遍历。4.2 continue 与 advance 的速度差异cursor.continue()只跳到下一条符合当前方向条件的记录,而cursor.advance(n)会跳过 n 条记录,它们内部走的是同一套迭代逻辑,但advance的实现会有批量跳转优化(部分浏览器实现为内部循环调用 continue,部分做 B-tree 跳跃)。实测下来,在索引游标上advance比手动continueN 次要快 20% 到 50%,因为有些引擎能直接利用索引内部位置定位。// 手动 skip 的方式:性能差 for (let i = 0; i < 100; i++) { cursor.continue(); } // 推荐方式 cursor.advance(100);但有一个使用限制:advance不能为 0 或负数,且调用后你不能再在同一时刻调用update/delete(因为游标尚未定位到具体记录)。跳转后第一个onsuccess里拿到的 cursor 才是目标记录。4.3 索引游标下 primaryKey 与主键游标的操作差异用索引游标做删除/更新时,cursor.delete()删除的是“当前索引项对应的记录”,这对索引游标同样生效。但是你要小心索引值有重复的情况:同一个索引值下有多条记录时,游标会先访问主键值更小的记录,再访问主键值更大的记录。如果你在遍历过程中删除当前记录,那么“相同索引值但主键更大”的记录依然会被正常遍历到,不会跳过,这符合规范。可是如果你在index('status').openCursor('pending')后,把所有pending改成done,再continue(),此时索引结构在你脚下发生了变化:刚被改成done的记录在pending索引里已经被移除了,游标会自动跳过刚处理过的位置,不会重复处理,但也可能因为索引结构调整导致某些记录被漏掉(取决于浏览器实现)。所以“在索引游标中修改索引字段”这种操作一定要逻辑上可容错,或者干脆先收集主键,再新开事务去更新。4.4 大库首次遍历时的“白屏”体感很多人以为游标一定不会卡,其实首次遍历 10 万条记录,打开第一个事务时仍然会有几十到几百毫秒的打开成本,因为引擎要初始化游标位置。如果你想进一步压低这个延迟,有两个技巧:用openKeyCursor而不是openCursor,避免把整条记录的 value 从存储解码到内存;提前用IDBObjectStore.count()拿到总数,对于“空库/小库”场景直接走getAll,大于阈值再走游标分块。我给项目里定的阈值是 5000 条:少于这个量getAll更快,超过这个量游标更稳。这个阈值不绝对,取决于记录的平均大小和当前设备,但至少可以避免大批数据时白屏。5. 索引游标:把筛选下沉到存储层5.1 通过唯一索引游标构造“不重复列表”一个很常见的需求是“获取某个字段的所有可能取值”。比如商品数据里有brand字段,你想拿到所有品牌列表。最简单的方式是getAll()后在 JS 里Set去重,但数据量大时内存占用很高。更优雅的方案是在brand字段上建索引,然后用nextunique方向遍历索引:function distinctValues(store, indexName) { return new Promise((resolve, reject) => { const values = []; const index = store.index(indexName); const request = index.openCursor(null, 'nextunique'); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(values); return; } values.push(cursor.key); cursor.continue(); }; request.onerror = () => reject(request.error); }); }这样遍历的是索引结构,而不是整张表的数据页。索引通常比记录本身小得多,遍历速度会快很多,而且天然去重,不用在 JS 里维护 Set。5.2 复合索引游标,多条件范围匹配的正确姿势IndexedDB 支持复合索引,即在多个字段上建一个索引。例如store.createIndex('by_status_time', ['status', 'timestamp'])。用复合索引游标时,IDBKeyRange的边界是一个数组。比如想查status = 'pending'且时间在某个范围的数据:const range = IDBKeyRange.bound( ['pending', startTime], ['pending', endTime] ); index.openCursor(range);这里要注意:复合索引的排序是“先按第一个字段排,第一个字段相同再按第二个字段排”。因此如果你想查“所有状态(不限状态)+ 时间范围”,复合索引是帮不上忙的——这会导致变成全表扫描。正确的建模思路是优先把固定筛选条件放在索引最左侧,范围匹配的字段放右侧,这跟关系数据库的最左前缀原则非常像。6. 实测性能视角:游标和 getAll 的分水岭6.1 我的实测对比数据我在一个模拟项目里做了基准测试:单表 20 万条记录,每条记录平均 6 个字段、值大小约 200 字节。分别在 Chrome 和 Firefox 下跑了三种读取方式:方式耗时(Chrome)耗时(Firefox)峰值内存getAll()+ 前端 reduce320ms410ms约 180MBopenCursor()逐条处理统计210ms240ms约 15MBopenKeyCursor()只读取键统计95ms110ms约 8MB结论很直白:getAll在 20 万条场景下虽然也能跑完,但内存峰值是游标的 10 倍以上。如果你的数据是几十万甚至百万级,getAll大概率会让页面崩溃或触发浏览器进程回收警告,而游标可以平稳执行。6.2 合并事务批量提交,才是压榨性能的关键游标的另一个性能优势是配合事务批量操作。比如要更新一万条记录,如果用“先 getAll 再逐条 put”,每一次put都在同一个异步事务里完成,浏览器会频繁检查事务状态,整体很慢。而游标遍历中直接cursor.update(),单事务内串行更新,整个过程引擎可以优化为顺序写,实测吞吐量能提升一倍以上。我在实践中的做法是:大批量更新时尽量一个事务跑完,不要人为分支成多个事务(除非遇到上面说的异步问题)。事务范围优先用readwrite而不是readonly,因为你要在遍历中写。7. 结合构建工具与多线程的进阶方案7.1 游标与 Web Worker 组合,避免主线程占用游标虽然高效,但遍历 10 万条记录时,每一次onsuccess回调都是在主线程执行的。如果每条记录的回调逻辑很重(比如解析日期、计算指标),主线程依然会被拖住。进阶方案是把游标遍历放到 Web Worker 里,IndexedDB 在 Worker 中是完全可用的。不过需要注意的是,Worker 里的 IndexedDB 实例是独立连接,你需要在主线程和 Worker 之间通过postMessage传递结果,注意结构化克隆的性能。对于“导出一大堆数据生成报表”这种场景,把游标放 Worker 里,主线程基本零卡顿,用户体验提升非常明显。7.2 游标配合 IndexedDB 本身的“自动持久化”很多 PWA 项目把数据缓存在 IndexedDB 里,后续同步到服务器。这类同步任务的实现用游标遍历本地库、组装 batch、再发请求,正好是游标的最优场景。同步过程中还要维护一个“游标位置”或“同步批次号”,避免中断后重新全量扫描。我的方案是每次同步前记录当前批次游标的最后一个主键,下次从这个键之后继续,比IDBKeyRange.lowerBound(lastKey, true)就能实现增量续扫。8. 最后再分享几个判断要不要用游标的经验我个人在实际项目里已经形成了一套条件反射式的判断逻辑,写在这里供参考:如果记录数 < 5000 且只需要一次性全量读取,用getAll()就好,代码更简洁;如果记录数 > 5000 且需要分页、统计、批量更新或倒序取最新,别犹豫,直接上openCursor;如果只需要键,一律用openKeyCursor,尤其是数据记录体积大的时候,收益最大;如果要在遍历过程修改数据,用cursor.update()和cursor.delete(),不要用getAll加put的组合;如果遍历过程涉及异步等待,先把结果收集好退出事务,再开新事务处理,绝不在同一个事务里等待异步回调返回。有一点你可能没注意过:游标只是 IndexedDB 的遍历机制,并不是索引本身。游标效率的高低,很大程度取决于你为它铺好了什么样的索引。主键游标适合全表扫描和分页,索引游标适合筛选条件下的局部扫描。很多人说 IndexedDB 性能差,其实大部分情况是没把索引和游标结合起来用,被getAll的便利性掩盖了问题。下次遇到本地数据量大导致页面卡顿,先别急着上重型方案,把读数据的姿势换对,问题往往就迎刃而解。