多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

如何用 cursor.continue 实现本地海量数据的分页查询加载:TaoToken 统一 Key 下的 IndexedDB 游标实践

如何用 cursor.continue 实现本地海量数据的分页查询加载:TaoToken 统一 Key 下的 IndexedDB 游标实践 1. 为什么海量本地数据不能一次性读出来做离线优先的应用时IndexedDB 几乎是绕不开的存储层。它容量大、支持索引、能在浏览器里持久化但很多人第一次用它加载几万条日志或商品数据时都会踩同一个坑getAll()一把梭。数据量小的时候没问题一旦记录数上万主线程直接卡死内存飙升页面白屏几秒甚至崩溃。我试过在一个本地日志查看器里用getAll()读 8 万条记录Chrome 标签页内存瞬间涨到 1.2GB滚动条都拖不动。后来改成游标分页首屏只读 20 条内存稳定在几十 MB滚动加载下一页几乎无感。这就是cursor.continue的价值所在。cursor.continue()的本质不是「跳过前 N 条」而是「从当前游标位置继续往下走」。它配合lastKey参数可以精确 seek 到某个键或比它更大的下一条记录。这意味着它天然适合「下一页」这种连续递进式加载而不适合 SQL 那种OFFSET 10000的随机跳页。理解这一点是写出高性能分页查询的前提。本文聚焦 IndexedDB 海量本地数据分页场景围绕cursor.continue与lastKey游标推进机制拆解分页查询加载的完整链路。你会拿到可复制的建库建表代码、游标分页与 lastKey 续传配置以及分页边界与重复数据的验证动作。同时我会说明如何通过 TaoToken 统一 Key 管理调用凭证让本地数据层和远端模型调用共用一套接入方式。适合谁看正在做离线优先 Web 应用、本地日志系统、或者需要在浏览器里处理大量结构化数据的开发者。如果你只会getAll()这篇文章能帮你把内存和卡顿问题一次性解决。2. TaoToken 统一 Key 的前置准备在写游标分页之前先花几分钟把调用凭证这件事理顺。很多本地数据应用不只是读 IndexedDB还要把分页结果送去模型做摘要、分类或语义检索。如果每个功能各配一套 Key管理起来很乱也容易在代码里硬编码泄露。TaoToken 的思路是提供一个统一的 API 通道你只需要一个 Key就能在模型对话、编码计划、控制台等不同场景里复用。官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基址是 https://taotoken.net/api 注意 API 地址不带 UTM 参数配置时别写错。具体操作上你可以先到控制台创建 Keyhttps://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。创建后在 API Keys 页面复制https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。如果你要验证某个模型是否可用可以直接在模型对话页测试https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。对于长期编码或 Agent 类任务Coding Plan 会更合适https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content Claude Code 相关配置参考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。这里要强调一个原则Key 不要写进前端源码。本地 IndexedDB 分页逻辑跑在浏览器里但调用模型应该走后端代理或者至少用环境变量注入。TaoToken 的统一 Key 只是让你少管几套凭证不是让你把 Key 暴露在客户端。配置时记住三件套Base URL 填https://taotoken.net/apiKey 填你创建的那串Model ID 按文档里支持的模型名填。这三样在 Cline、Codex 的auth.json、或者 CC Switch 里都是同样的结构。下面给一个通用的 JSON 配置片段路径按你实际项目调整{ provider: taotoken, baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, model: 按文档填写的ModelID }如果你用的是 TOML 风格的配置等价写法是[provider.taotoken] base_url https://taotoken.net/api api_key sk-你的Key model 按文档填写的ModelID前置准备做完接下来进入正题IndexedDB 的建库建表和游标分页。3. 可复制的 IndexedDB 建库与游标分页配置这一节是全文核心我会给出完整的建库、建索引、游标分页、lastKey 续传代码。你可以直接复制到项目里改。先建库。假设我们要存日志字段有id自增主键、timestamp时间戳、level、message。我们按timestamp建索引用于倒序分页。const DB_NAME log_db; const DB_VERSION 1; const STORE_NAME logs; function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.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(timestamp, timestamp, { unique: false }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }注意timestamp索引设成unique: false因为同一毫秒可能有多条日志。这一点很关键后面处理重复数据时会用到。接下来是分页查询函数。核心逻辑首次查询用openCursor(null, prev)打开倒序游标取pageSize条记下最后一条的timestamp作为lastKey。下一页时调用cursor.continue(lastKey)游标会定位到timestamp ≤ lastKey的下一条。async function fetchPage(db, pageSize 20, lastKey null) { return new Promise((resolve, reject) { const tx db.transaction(STORE_NAME, readonly); const store tx.objectStore(STORE_NAME); const index store.index(timestamp); const results []; let nextKey null; // lastKey 为 null 时从末尾开始否则从 lastKey 位置继续 const range lastKey ! null ? IDBKeyRange.upperBound(lastKey) : null; const cursorReq index.openCursor(range, prev); cursorReq.onsuccess (event) { const cursor event.target.result; if (!cursor) { resolve({ items: results, nextKey }); return; } if (results.length pageSize) { results.push(cursor.value); nextKey cursor.key; cursor.continue(); } else { resolve({ items: results, nextKey }); } }; cursorReq.onerror () reject(cursorReq.error); }); }这里有个细节IDBKeyRange.upperBound(lastKey)配合prev方向会从lastKey开始往前遍历。但这样会把lastKey那条记录也包含进来导致下一页第一条和上一页最后一条重复。解决办法是用upperBound(lastKey, true)排除边界值或者用主键做二级去重。更稳妥的做法是同时记录lastKey和lastId用复合条件过滤。但 IndexedDB 的游标只支持单索引 seek所以实际工程里常用「主键去重」下一页拿到结果后过滤掉id已经出现过的记录。async function fetchPageSafe(db, pageSize 20, lastKey null, seenIds new Set()) { return new Promise((resolve, reject) { const tx db.transaction(STORE_NAME, readonly); const store tx.objectStore(STORE_NAME); const index store.index(timestamp); const results []; let nextKey lastKey; const range lastKey ! null ? IDBKeyRange.upperBound(lastKey, true) : null; const cursorReq index.openCursor(range, prev); cursorReq.onsuccess (event) { const cursor event.target.result; if (!cursor || results.length pageSize) { resolve({ items: results, nextKey }); return; } if (!seenIds.has(cursor.value.id)) { results.push(cursor.value); seenIds.add(cursor.value.id); nextKey cursor.key; } cursor.continue(); }; cursorReq.onerror () reject(cursorReq.error); }); }upperBound(lastKey, true)的第二个参数true表示排除边界值这样就不会重复读到上一页的最后一条。seenIds用来兜底处理同一 timestamp 的多条记录。如果你需要按其他字段分页比如按level过滤后再按时间倒序可以建复合索引[level, timestamp]然后用IDBKeyRange.bound([level, -Infinity], [level, lastKey], false, true)来 seek。复合索引的游标 key 是数组cursor.key返回[level, timestamp]取cursor.key[1]作为lastKey。配置层面如果你用 Cline 或 Codex 的auth.json来管理模型调用记得把 TaoToken 的三件套写全{ baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, model: 按文档填写的ModelID }这样本地分页读出来的数据可以直接送去模型做摘要不用再单独配一套凭证。4. 验证请求与成功结果代码写完必须验证分页边界和重复数据。我一般分三步测。第一步造数据。往 IndexedDB 里插 1000 条日志timestamp 从 1 到 1000故意让部分 timestamp 重复比如每 10 条里有 2 条同毫秒。async function seedData(db, count 1000) { const tx db.transaction(STORE_NAME, readwrite); const store tx.objectStore(STORE_NAME); for (let i 0; i count; i) { const ts Math.floor(i / 10) * 10 (i % 3 0 ? 0 : 1); store.add({ timestamp: ts, level: i % 2 0 ? info : error, message: log message ${i} }); } return new Promise((resolve) { tx.oncomplete () resolve(done); }); }第二步连续翻页检查总数和重复。写一个循环每页 20 条一直翻到nextKey为 null把所有id收集起来。async function verifyPagination(db) { const seen new Set(); let lastKey null; let page 0; let total 0; while (true) { const { items, nextKey } await fetchPageSafe(db, 20, lastKey, seen); if (items.length 0) break; total items.length; page; console.log(第 ${page} 页${items.length} 条nextKey${nextKey}); lastKey nextKey; if (items.length 20) break; } console.log(总条数${total}去重后${seen.size}); return { total, unique: seen.size }; }预期结果total应该等于 1000seen.size也等于 1000。如果total大于 1000说明有重复如果小于 1000说明漏数据。我实测下来用upperBound(lastKey, true)加seenIds去重1000 条数据翻 50 页总数和去重数完全一致。第三步验证首屏和末页边界。首屏lastKey null应该返回最新的 20 条timestamp 最大的。末页应该返回最旧的若干条且nextKey最终为 null。如果末页之后还能翻出数据说明upperBound的边界没处理好。如果你要把分页结果送去模型可以在每页加载后调一次 TaoToken 的模型对话接口验证连通性。先在模型对话页手动测一条https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 确认返回正常再写进代码。async function summarizePage(items) { const res await fetch(https://taotoken.net/api/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.TAOTOKEN_KEY} }, body: JSON.stringify({ model: 按文档填写的ModelID, messages: [ { role: user, content: 总结这些日志${JSON.stringify(items)} } ] }) }); return res.json(); }成功结果应该是分页总数准确、无重复、无遗漏模型调用返回 200 且内容正常。如果模型调用报 401先检查 Key 和 Base URL如果分页报错往下看排障部分。5. 本篇常见错误排查这一节列几个真实会遇到的报错对照着查。报错一Failed to execute openCursor on IDBIndex: The parameter is not a valid key.原因通常是lastKey传了undefined或NaN。首次查询时lastKey应该是null代码里要判断lastKey ! null再构造IDBKeyRange。如果你从上一页拿到的nextKey是undefined说明游标没取到 key检查cursor.key是否被正确赋值。报错二分页结果重复第一页最后一条出现在第二页第一条。这是upperBound没排除边界值导致的。把IDBKeyRange.upperBound(lastKey)改成IDBKeyRange.upperBound(lastKey, true)第二个参数true表示排除lastKey本身。如果还有重复说明同一 timestamp 有多条记录用seenIds兜底。报错三401 Unauthorized或local proxy failed。这是模型调用侧的报错不是 IndexedDB 的问题。检查三件套Base URL 是不是https://taotoken.net/api注意不带 UTMKey 是不是从 API Keys 页面复制的完整串Model ID 是不是文档里支持的。如果你用 Cline 或 Codex 的auth.json确认字段名没写错baseUrl、apiKey、model三个都要有。local proxy failed通常是本地代理配置和 Base URL 冲突把代理关掉直连试试。报错四Cannot read properties of undefined (reading choices)。模型返回体里没有choices说明请求没成功。先打印完整响应体看error字段。常见原因是 Model ID 填错或者请求体格式不对。TaoToken 的接口兼容 OpenAI 格式messages数组和model字段不能少。报错五OAuth 相关报错比如OAuth token expired。如果你用 Claude Code 接入参考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 重新走一遍授权。OAuth 过期后需要重新登录不是 Key 的问题。报错六翻页到后面越来越慢。检查是不是每次翻页都重新openDB()。数据库连接应该复用全局开一次后续所有事务共用。另外seenIds如果无限增长内存会涨可以在翻页结束后清空或者用WeakSet。报错七VersionError或建表失败。DB_VERSION和已有数据库版本不一致。如果你改了表结构把DB_VERSION加 1并在onupgradeneeded里处理迁移。别直接删库用户数据会丢。排查顺序建议先确认 IndexedDB 分页本身正确总数、去重数再确认模型调用连通401、choices。两边分开测别混在一起调。6. 把统一 Key 接进你的本地数据链路分页逻辑跑通后最后一步是把 TaoToken 的统一 Key 接进你的数据链路。本地 IndexedDB 负责海量数据的游标分页TaoToken 负责模型调用两者通过一个 Key 串起来。具体做法在项目里建一个config.js把 Base URL 和 Model ID 写死Key 从环境变量读。前端只调你自己的后端接口后端再转发到 TaoToken。这样 Key 不会暴露在浏览器里。// config.js export const TAOTOKEN_BASE https://taotoken.net/api; export const TAOTOKEN_MODEL 按文档填写的ModelID;后端转发示例Node.jsapp.post(/api/summarize, async (req, res) { const response await fetch(${TAOTOKEN_BASE}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.TAOTOKEN_KEY} }, body: JSON.stringify({ model: TAOTOKEN_MODEL, messages: req.body.messages }) }); const data await response.json(); res.json(data); });如果你需要长期跑编码或 Agent 任务Coding Plan 的额度更划算https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 遇到配置问题先翻文档。一个实用技巧把分页的lastKey持久化到localStorage用户刷新页面后能从上次位置继续加载不用从头翻。但要注意数据更新后lastKey可能失效加一个版本号校验。最后别忘了在 API Keys 页面定期轮换 Keyhttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。统一 Key 的好处是轮换一次所有场景同步生效不用逐个改配置。
返回列表