ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IndexedDB 深度指南:浏览器中的事务型对象数据库,TaoToken 统一 Key 通道下的离线数据实践

IndexedDB 深度指南:浏览器中的事务型对象数据库,TaoToken 统一 Key 通道下的离线数据实践 1. 离线待办应用为什么绕不开 IndexedDB 事务型对象数据库如果你正在做一个离线优先的待办清单、草稿箱或者笔记应用大概率会遇到同一个问题数据存在哪localStorage 只有 5–10 MB同步写入还会卡住主线程Cookie 更小只有 4 KB 左右每次请求都带着走。真正能扛住几百条待办 草稿自动保存 按标签检索这种需求的只有浏览器内置的 IndexedDB。IndexedDB 是一个事务型对象数据库它和 localStorage 的本质区别有三个。第一是异步所有读写都通过请求和事件完成不会阻塞 UI 渲染第二是事务一组操作要么全部成功要么全部回滚这对草稿保存到一半断电的场景非常关键第三是索引你可以按主键以外的字段建索引不用全表扫描就能查到数据。这三点加起来才让它配得上对象数据库这个称呼而不是一个放大的键值对。这篇内容面向的是正在做离线优先 Web 应用的开发者尤其是待办、草稿、笔记这类需要本地持久化 事务保证的场景。我会用一个离线待办 草稿自动保存的示例把对象仓库、索引、事务生命周期、版本升级这几件事串起来给出可以直接复制的建库与事务封装代码再演示在 DevTools 里怎么验证读写和回滚。同时如果你的应用里还要调用大模型做摘要、分类或者草稿润色我会说明怎么用 TaoToken 统一管理这些模型调用的 Key 和 API 通道避免把凭据散落在前端代码里。先明确一个认知IndexedDB 是较持久、但非绝对永久的本地存储。用户随时可以清除浏览器数据best-effort 模式下磁盘紧张时浏览器也可能驱逐。所以它适合做服务端数据的本地副本、纯本地可容忍丢失的数据而不是关键数据的唯一真相来源。这个定位想清楚了后面的设计才不会跑偏。2. TaoToken 统一 Key 通道模型调用凭据怎么管离线待办应用本身不需要联网但一旦你加上AI 帮我拆解任务自动给草稿打标签这类功能前端就要调用模型 API。这时候最忌讳的做法是把 API Key 硬编码在 JS 里——打包上线等于公开泄露。合理的做法是走一个统一的 Key/API 通道前端只跟这个通道打交道真正的模型凭据由服务端或统一网关持有。TaoToken 在这里扮演的就是统一 Key 通道的角色。它的官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基址是 https://taotoken.net/api 。你可以把它理解成一个凭据集中管理 请求转发的中间层前端配置里只写 Base URL 和一个受限的 Key模型选择通过 Model ID 指定换模型不用改业务代码。具体到配置无论你用的是 Claude Code、Cline 还是 Codex 这类工具接入时都要凑齐三件套Base URL、API Key、Model ID。以 Claude Code 的 settings 为例配置片段长这样{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的TaoTokenKey, ANTHROPIC_MODEL: claude-sonnet-4-20250514 } }如果你用的是 Codex配置写在~/.codex/auth.json里结构类似{ base_url: https://taotoken.net/api, api_key: sk-你的TaoTokenKey, model: gpt-4o }Cline 这类 VS Code 插件则在设置面板里填 Base URL、API Key、Model ID 三个字段Base URL 同样填https://taotoken.net/api。三件套缺一不可只填 Key 不填 Base URL请求会打到默认端点只填 Base URL 不填 Model ID网关不知道你要哪个模型。对于离线待办应用我的建议是把模型调用放在一个独立的 service 层Key 通过环境变量注入前端只调用自己的后端接口由后端去请求 TaoToken 通道。这样即使前端代码被扒也拿不到真实凭据。如果你确实需要前端直连比如纯静态部署的 demo至少要用受限 Key 并设置额度上限。需要说明的是TaoToken 管的是模型调用凭据不是数据库。IndexedDB 的数据始终在浏览器本地两者职责不重叠。把这两层分清楚架构上就不会乱本地数据用 IndexedDB 事务保证一致性模型调用走统一 Key 通道保证凭据安全。3. 可复制的建库与事务封装配置这一节是全文的核心我会给出一个完整的db.js包含建库、版本升级、事务封装和几个常用操作。你可以直接复制到项目里改。先看建库和版本管理。版本号是一个整数计数器代表 schema 的第几版真相永远在磁盘上代码只是声明期望版本。升级代码必须能从任意旧版本升上来所以用 fall-through 的 switch故意不写 break// db.js const DB_NAME todo-offline; const DB_VERSION 2; function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded (event) { const db request.result; const tx request.transaction; // 浏览器自动开启的 versionchange 事务 switch (event.oldVersion) { case 0: { // 新用户建待办仓库主键内联 const todos db.createObjectStore(todos, { keyPath: id }); todos.createIndex(by_status, status); todos.createIndex(by_created, createdAt); } // 故意不写 break逐版贯穿 case 1: { // 从 v1 升上来加草稿仓库和标签索引 const drafts db.createObjectStore(drafts, { keyPath: id }); drafts.createIndex(by_updated, updatedAt); const todos tx.objectStore(todos); todos.createIndex(by_tag, tags, { multiEntry: true }); } } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); request.onblocked () { console.warn(升级被其他标签页阻塞请关闭旧页面); }; }); }注意multiEntry: true这个索引当tags [work, urgent]时它会为每个标签各建一条索引项于是index.get(work)能命中这条记录。这是标签检索的关键。接下来是事务封装。这里有个必须避开的陷阱事务不是我创建、我关闭而是它在某段代码执行期间活着代码一交还控制权它就自己提交。所以封装的核心纪律是——事务内只做纯 IndexedDB 操作任何fetch、setTimeout、外部 Promise 都放到事务外。// 把 IDBRequest 包成 Promise但只在事务内使用 function req(request) { return new Promise((resolve, reject) { request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } // 事务完成信号 function txDone(tx) { return new Promise((resolve, reject) { tx.oncomplete () resolve(); tx.onerror () reject(tx.error); tx.onabort () reject(tx.error || new Error(transaction aborted)); }); } // 新增待办一个事务内完成写入 async function addTodo(db, todo) { const tx db.transaction(todos, readwrite); const store tx.objectStore(todos); store.add({ id: crypto.randomUUID(), title: todo.title, status: pending, tags: todo.tags || [], createdAt: Date.now(), }); await txDone(tx); // 等事务真正提交 } // 按标签查询走索引不全表扫描 async function getTodosByTag(db, tag) { const tx db.transaction(todos, readonly); const index tx.objectStore(todos).index(by_tag); const result await req(index.getAll(tag)); await txDone(tx); return result; }关键点在于await txDone(tx)它让你能明确知道事务什么时候提交完成而不是靠猜。如果你在事务里await fetch事务会因为没有挂起的 IDB 请求而自动提交之后再store.put就会抛TransactionInactiveError。正确做法是把外部数据先取好再开事务// 外部异步在事务之外 const extra await fetch(/api/meta).then(r r.json()); const tx db.transaction(todos, readwrite); const store tx.objectStore(todos); const todo await req(store.get(id)); store.put({ ...todo, extra }); await txDone(tx);批量写入用一个事务、同步链式发起多个请求既有性能又有原子性async function bulkAddTodos(db, list) { const tx db.transaction(todos, readwrite); const store tx.objectStore(todos); for (const item of list) { store.add({ id: crypto.randomUUID(), ...item, createdAt: Date.now() }); } await txDone(tx); // 全部成功才提交任一失败整体回滚 }这套封装下来你的离线待办应用就有了事务保证草稿保存要么完整写入要么完全回滚不会出现半条脏数据。4. 在 DevTools 里验证读写与回滚代码写完不算完得在浏览器里亲眼看到数据落盘、事务回滚才算真正验证过。下面是我常用的验证流程。第一步打开 Chrome DevTools切到 Application 面板左侧找到 IndexedDB展开todo-offline你能看到todos和drafts两个对象仓库以及各自的索引。点进todos右侧会列出所有记录每条记录展开能看到id、title、status、tags、createdAt字段。这一步确认建库和索引都成功了。第二步在 Console 里执行写入然后回到 Application 面板点刷新按钮看记录是否出现const db await openDB(); await addTodo(db, { title: 写周报, tags: [work] }); await addTodo(db, { title: 买牛奶, tags: [life, urgent] });刷新后你应该看到两条记录。接着验证索引查询const urgent await getTodosByTag(db, urgent); console.log(urgent); // 应该只返回买牛奶这条如果multiEntry索引没建对这里会返回空数组这是最常见的索引配置错误。第三步验证事务回滚。这是 IndexedDB 相比 localStorage 最有价值的地方。我在一个事务里故意制造约束冲突看前面的写入是否被撤销async function testRollback(db) { const tx db.transaction(todos, readwrite); const store tx.objectStore(todos); store.add({ id: fixed-id, title: 第一条, status: pending, createdAt: Date.now() }); store.add({ id: fixed-id, title: 重复ID, status: pending, createdAt: Date.now() }); // 主键冲突 try { await txDone(tx); } catch (e) { console.log(事务已回滚, e.name); // 期望看到 ConstraintError 或 AbortError } } await testRollback(db);执行后回到 Application 面板刷新你会发现fixed-id这条记录根本不存在——第一条写入虽然语法上成功了但因为同一事务里第二条冲突导致整个事务 abort第一条也被回滚了。这就是 ACID 里的原子性localStorage 给不了你。第四步验证版本升级。把DB_VERSION从 2 改成 3在onupgradeneeded的 switch 里加一个case 2刷新页面。DevTools 的 Console 会打印升级日志Application 面板里能看到新加的仓库或索引。如果你同时开了两个标签页旧标签页会收到versionchange事件触发onblocked警告——这时候在旧标签页执行db.close()或直接刷新升级才会继续。第五步验证配额和持久化const { usage, quota } await navigator.storage.estimate(); console.log(已用 ${(usage / 1024 / 1024).toFixed(2)} MB / 配额 ${(quota / 1024 / 1024).toFixed(0)} MB); const persisted await navigator.storage.persist(); console.log(持久化授权, persisted);persist()返回 true 表示浏览器同意不自动驱逐你的数据但用户手动清除仍然会丢。这一步是提醒自己IndexedDB 不是绝对永久的重要数据要有同步或备份设计。5. 本篇常见错误排查401、TransactionInactiveError 与 VersionError实际接入时报错往往集中在几个固定位置。我把最常见的几类列出来对照着排查。401 Unauthorized / invalid api key这类错误出现在模型调用环节不是 IndexedDB 本身。原因通常是三件套没凑齐——Base URL 填了但 Key 过期或者 Key 对了但 Model ID 写错。检查你的配置里ANTHROPIC_BASE_URL或base_url是否指向https://taotoken.net/apiKey 是否以sk-开头且没有多余空格Model ID 是否是网关支持的名称。如果用的是 Claude Code确认 settings 里的env字段层级没写错如果是 Codex确认auth.json是合法 JSON没有尾逗号。TransactionInactiveError这是 IndexedDB 最经典的坑。报错信息通常是Failed to execute put on IDBObjectStore: The transaction has finished。根因是你在事务期间await了一个非 IndexedDB 的异步操作比如fetch、setTimeout或者一个普通 Promise。事务在控制权交还事件循环且没有挂起 IDB 请求时会自动提交之后再用这个事务就晚了。解法是把所有外部异步移到事务外先取数据再开事务。我试过在onsuccess里await Promise.resolve()有时能侥幸存活但浏览器实现不一致千万别依赖。VersionError报错The requested version (1) is less than the existing version (2)。这发生在多标签页场景你发布了新代码用 v2 打开但用户某个旧标签页还停在 v1 代码它重连时请求 v1而磁盘已经是 v2于是抛错。解法是在db.onversionchange里db.close()并location.reload()强制加载最新代码。最省心的方案就是刷新页面顺带解决代码漂移。ConstraintError主键重复或唯一索引冲突。检查你的add是不是用了已存在的 key或者unique: true的索引字段是否有重复值。注意add和put的区别add遇到已存在主键会报错put会覆盖。DataError常见于键类型不合法。IndexedDB 的键只能是 number、string、Date、ArrayBuffer 或它们的数组boolean、null、undefined、NaN、普通对象都会抛 DataError。另外内联键有keyPath的仓库不能再传第二个 key 参数外联键无keyPath必须传 key二选一搞混也会报这个错。QuotaExceededError写入量超过配额。生产环境要对这个错误做降级处理比如提示用户清理旧数据或者把非关键数据从 IndexedDB 迁到内存缓存。local proxy failed / connection refused如果你在本地开发时通过某个代理访问模型接口可能遇到连接失败。先确认 Base URL 拼写正确再确认网络能通到https://taotoken.net/api。这类错误和 IndexedDB 无关属于网络层单独排查即可。排查顺序建议先看 Console 第一条红色报错定位是 IndexedDB 层还是网络层IndexedDB 层优先查事务是否提前提交、键类型是否合法、版本是否错位网络层优先查三件套配置。把这几类覆盖掉90% 的问题都能自己解决。6. 把本地事务和统一 Key 通道接起来回到最初的问题离线待办应用怎么既保证本地数据一致性又安全地调用模型能力。答案是把两层分开——本地数据用 IndexedDB 的事务型对象数据库兜底模型调用走 TaoToken 统一 Key 通道。具体落地时我建议这样组织代码db.js只负责 IndexedDB 的建库、升级和事务封装不掺任何网络逻辑ai.js只负责模型调用Base URL 固定为https://taotoken.net/apiKey 从环境变量注入Model ID 作为参数传入。业务层先await本地事务拿到数据再决定要不要调模型两者不交叉。如果你需要长期跑编码类 Agent或者想让模型持续参与草稿润色、任务拆解可以了解下 Coding Plan 这类按周期计费的方案比按次调用更适合高频场景。想先验证模型通不通可以直接在模型对话里发一条测试消息确认 Base URL 和 Key 配置正确后再写进代码。Key 的创建和管理在 API Keys 页面接入细节看接入文档里面有各工具的完整配置示例。最后留一个实用技巧在onupgradeneeded里做数据迁移时用游标cursor.update()逐条改写而不是删表重建。删表会丢光存量数据而游标迁移能在同一个 versionchange 事务里完成失败还能整体回滚。这个习惯能帮你在 schema 演进时少踩很多坑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进