Claude Code IndexedDB 入门:本地数据、离线队列与同步
用 Claude Code 安全实现 IndexedDB:schema、迁移、索引、事务、quota、离线同步和测试。
IndexedDB 是浏览器里的结构化数据库。当 localStorage 只能保存少量字符串时,IndexedDB 可以保存对象、Blob、索引和多条记录,还能用事务把多个写入合成一个原子操作。对下稿保存、搜索缓存、PWA 离线队列、AI 工具的中间结果缓存来说,它比把大 JSON 塞进 localStorage 更稳。
问题是 IndexedDB 的 API 不直观。初学者很容易卡在 onupgradeneeded、object store、index、version upgrade、transaction、quota、多个标签页阻塞升级这些点上。直接让 Claude Code “帮我加 IndexedDB”,通常会得到一个能演示的 happy path,但缺少迁移策略、容量错误、同步冲突和测试。
本文用可复制的 TypeScript 代码讲清楚 IndexedDB 的入门设计:什么时候用它而不是 localStorage,如何设计 schema 和 index,如何处理升级、事务、quota 和离线同步。官方参考请以原文为准:MDN Using IndexedDB、MDN storage quotas and eviction criteria、web.dev Storage for the web、idb README 和 Dexie.js docs。如果你想把这种边界写进日常提示词,也可以继续看 Claude Code 生产力技巧 和 Claude Code 测试策略。
什么时候不用 localStorage
localStorage 适合保存主题、语言、关闭过的 banner、最后打开的 tab 这类少量设置。它简单,但也是同步 API,会阻塞主线程;它没有索引,也不适合频繁更新大对象。把 5000 条记录序列化成一个 JSON 字符串,每次改一条都整体重写,是很多前端性能问题的起点。
IndexedDB 更像浏览器端的小数据库。object store 类似表,key 是主键,index 是按其他字段查询的路径,transaction 则保证一组读写一起成功或一起失败。它并不替代服务端数据库,而是让浏览器在离线、弱网、重复读取时更可靠。
| 场景 | localStorage | IndexedDB |
|---|---|---|
| 主题、短设置、UI 模式 | 合适 | 通常过重 |
| 长表单、文章草稿、恢复输入 | 容量和阻塞风险高 | 合适 |
| 可搜索的商品或文章缓存 | 会变成巨大 JSON | 用 index 查询 |
| 图片 Blob、附件元数据 | 不合适 | 可以保存 |
| 离线操作队列 | 顺序和重试难管理 | 适合用事务 |
真实应用至少有四类用例。第一,CMS 或博客编辑器在用户输入时保存草稿,并按 updatedAt 和 syncStatus 查询未同步内容。第二,SaaS 管理台缓存商品、套餐、帮助文档或用户列表,按分类、更新时间或状态读取。第三,PWA 在离线时把评论、咨询表单、库存调整放入队列,网络恢复后重放。第四,Claude Code 或其他 AI 工具把较大的分析结果存在本地,并设置过期时间,避免每次切页面都重新请求。
先设计 schema、version 和 index
IndexedDB 的第一步不是“存什么”,而是“以后用什么条件取出来”。如果只按 id 取一条记录,设计很简单。但业务通常会问“只取未同步草稿”“取最旧缓存”“按创建时间发送队列”“找失败次数少于 3 的任务”。这些都需要 index。后加 index 时,必须提升数据库 version,并在 upgrade 逻辑里写迁移。
常见失败之一是最初只建 notes store,后来需要按 syncStatus 找数据,却只能全量读取再用 JavaScript filter。几十条数据看不出来,几千条就会拖慢首屏。另一个失败是使用 1.1 这种小数版本。IndexedDB 的 version 应按整数处理,建议明确使用 1、2、3。
npm i idb
下面的 src/lib/local-db.ts 示例使用 idb。它保留了 IndexedDB 的概念,但把事件式 API 包成 Promise,更适合和 Claude Code 生成的 TypeScript 一起 review。如果需要更丰富的查询 helper、live query 或更高层的数据抽象,可以评估 Dexie;本文选择 idb,因为初学者更容易看清底层边界。
import {
openDB,
type DBSchema,
type IDBPDatabase,
} from "idb";
type SyncStatus = "clean" | "dirty" | "syncing" | "failed";
export interface DraftNote {
id: string;
title: string;
body: string;
updatedAt: number;
baseVersion: number;
syncStatus: SyncStatus;
}
export interface SyncJob {
id: string;
noteId: string;
operation: "upsert-note" | "delete-note";
payload: unknown;
createdAt: number;
attempts: number;
lastError?: string;
}
interface AppDB extends DBSchema {
notes: {
key: string;
value: DraftNote;
indexes: {
"by-updated-at": number;
"by-sync-status": SyncStatus;
};
};
syncQueue: {
key: string;
value: SyncJob;
indexes: {
"by-created-at": number;
"by-attempts": number;
};
};
}
const DB_NAME = "claude-code-indexeddb-demo";
const DB_VERSION = 2;
let dbPromise: Promise<IDBPDatabase<AppDB>> | undefined;
export function getDb() {
dbPromise ??= openDB<AppDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, _newVersion, tx) {
if (oldVersion < 1) {
const notes = db.createObjectStore("notes", {
keyPath: "id",
});
notes.createIndex("by-updated-at", "updatedAt");
const queue = db.createObjectStore("syncQueue", {
keyPath: "id",
});
queue.createIndex("by-created-at", "createdAt");
queue.createIndex("by-attempts", "attempts");
}
if (oldVersion < 2) {
const notes = tx.objectStore("notes");
if (!notes.indexNames.contains("by-sync-status")) {
notes.createIndex("by-sync-status", "syncStatus");
}
}
},
blocked() {
console.warn("Close other tabs to finish the DB upgrade.");
},
blocking() {
dbPromise?.then((db) => db.close()).catch(() => {});
},
});
return dbPromise;
}
upgrade 是 schema 的唯一入口。不要在普通读写函数里偷偷创建 store 或 index。多个标签页打开同一个旧版本数据库时,新版本可能被阻塞;blocked 应提示用户关闭其他标签页,blocking 则让当前连接不要阻碍未来升级。
用 transaction 同时保存草稿和队列
事务的价值在于避免半成功。保存草稿和添加同步任务其实是同一个业务动作。如果草稿写入成功,但队列写入失败,UI 可能显示“已保存”,服务端却永远收不到。把两次写入放进一个 readwrite transaction。
const createId = () =>
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2);
export async function saveDraft(input: {
id?: string;
title: string;
body: string;
baseVersion?: number;
}) {
const db = await getDb();
const now = Date.now();
const noteId = input.id ?? createId();
const note: DraftNote = {
id: noteId,
title: input.title,
body: input.body,
updatedAt: now,
baseVersion: input.baseVersion ?? 0,
syncStatus: "dirty",
};
const job: SyncJob = {
id: createId(),
noteId,
operation: "upsert-note",
payload: note,
createdAt: now,
attempts: 0,
};
const tx = db.transaction(["notes", "syncQueue"], "readwrite");
await tx.objectStore("notes").put(note);
await tx.objectStore("syncQueue").put(job);
await tx.done;
return note;
}
export async function getDirtyNotes() {
const db = await getDb();
return db.getAllFromIndex("notes", "by-sync-status", "dirty");
}
不要在 active transaction 中间等待 fetch。transaction 没有待处理的数据库请求时,浏览器可能自动关闭它。更安全的顺序是:先完成 IndexedDB 写入,再在外部发网络请求,成功后另开一个 transaction 更新状态。
quota、eviction 和错误提示
IndexedDB 不是无限硬盘。浏览器会根据设备空间、站点使用情况、私密模式和用户操作决定是否允许继续写入,也可能清理 best effort storage。重要数据不应该只存在浏览器里;IndexedDB 适合作为离线工作区和缓存,最终仍要同步到服务端。
具体失败例有两个。第一,写入失败只在 console 出现 QuotaExceededError,用户界面仍显示“保存成功”。第二,把付费、合同、库存等关键数据只放在 IndexedDB,后来用户清理浏览器数据后无法恢复。前者需要错误处理,后者需要同步和服务端版本控制。
export async function estimateStorage() {
if (!navigator.storage?.estimate) {
return { usage: undefined, quota: undefined };
}
const { usage, quota } = await navigator.storage.estimate();
return { usage, quota };
}
export async function requestPersistentStorage() {
if (!navigator.storage?.persist) {
return false;
}
return navigator.storage.persist();
}
export function isQuotaError(error: unknown) {
return (
error instanceof DOMException &&
error.name === "QuotaExceededError"
);
}
export async function saveDraftWithErrorMessage(
input: Parameters<typeof saveDraft>[0],
) {
try {
return await saveDraft(input);
} catch (error) {
if (isQuotaError(error)) {
throw new Error(
"浏览器存储空间不足。请删除旧草稿后再试。",
);
}
throw error;
}
}
navigator.storage.persist() 只是请求持久化,不是绝对保证。Claude Code 生成这部分时,要要求它同时补 UI 文案、旧缓存清理策略和测试,而不是只包一层 try/catch。
离线队列和同步冲突
离线队列的难点不是“稍后发送”,而是顺序、重试、去重和冲突。假设用户在一台电脑离线编辑笔记,同时又在另一台设备在线更新同一篇笔记。离线浏览器恢复后,如果直接重放旧任务,就可能覆盖新版本。
type SendJob = (job: SyncJob) => Promise<void>;
export async function flushSyncQueue(sendJob: SendJob) {
if (!navigator.onLine) return;
const db = await getDb();
const jobs = await db.getAllFromIndex(
"syncQueue",
"by-created-at",
);
for (const job of jobs) {
try {
await sendJob(job);
await db.delete("syncQueue", job.id);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown sync error";
await db.put("syncQueue", {
...job,
attempts: job.attempts + 1,
lastError: message,
});
}
}
}
window.addEventListener("online", () => {
void flushSyncQueue(async (job) => {
const response = await fetch("/api/sync", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(job),
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`);
}
});
});
冲突策略取决于业务。个人笔记可以展示本地版和远端版,让用户选择。库存、预约、账单、付费事件不应该只在前端合并,服务端应通过 version、ETag 或 idempotency key 拒绝过期写入。
export function detectConflict(input: {
local: DraftNote;
remoteVersion: number;
}) {
const { local, remoteVersion } = input;
return (
local.syncStatus === "dirty" &&
remoteVersion > local.baseVersion
);
}
测试、mock 和真实浏览器确认
fake-indexeddb 可以让 Vitest 在 Node 环境模拟 IndexedDB,适合测试保存、按 index 查询、队列插入和迁移逻辑。但它不能证明 quota、eviction、私密浏览、移动端存储压力、多标签页阻塞这些行为。单元测试负责业务函数,浏览器测试负责真实环境。
npm i -D vitest fake-indexeddb
import "fake-indexeddb/auto";
import { beforeEach, expect, test } from "vitest";
import { deleteDB } from "idb";
import {
getDirtyNotes,
saveDraft,
} from "./local-db";
beforeEach(async () => {
await deleteDB("claude-code-indexeddb-demo");
});
test("saves a draft and finds it by sync status", async () => {
await saveDraft({
title: "IndexedDB note",
body: "Saved while offline",
});
const dirtyNotes = await getDirtyNotes();
expect(dirtyNotes).toHaveLength(1);
expect(dirtyNotes[0]?.syncStatus).toBe("dirty");
});
发布前至少检查这些点:首次打开创建数据库,version 2 增加 syncStatus index,另一个标签页打开时能看到升级提示,离线保存后刷新仍能恢复,网络恢复后队列发送,存储失败时用户能理解。变现页面还要确认 CTA、商品链接、咨询链接和 analytics 事件没有被 IndexedDB 改造影响。
给 Claude Code 的安全提示词
好的提示词要写本地数据契约,而不是只写“做离线功能”。IndexedDB 的边界条件太多,必须把可改文件、DB 名称、version、store、index、事务、quota 和测试都说清楚。
claude <<'PROMPT'
Scope:
- Edit only src/lib/local-db.ts and src/lib/local-db.test.ts.
- Do not change API routes, pricing copy, analytics, or CTA links.
Build:
- Use IndexedDB through the idb package.
- Database name: claude-code-indexeddb-demo.
- Version 2 must create notes and syncQueue stores.
- Add indexes for updatedAt, syncStatus, createdAt, and attempts.
Reliability:
- Use readwrite transactions for note + queue writes.
- Do not await fetch inside an active IndexedDB transaction.
- Handle QuotaExceededError with a user-facing message.
- Explain blocked upgrades from another open tab.
Testing:
- Use vitest and fake-indexeddb.
- Test draft save, dirty-note lookup, and queue insertion.
- Return findings, patch summary, commands, and residual risk.
PROMPT
这段提示词的目标是让 Claude Code 产出可 review 的本地数据层,而不是一段看起来方便的浏览器存储代码。对有收入路径的网站来说,离线草稿、缓存和队列不能破坏 CTA、购买链接、咨询表单或统计事件。团队可以通过 Claude Code 培训与咨询 把这些检查变成项目模板;个人开发者可以从 免费 cheatsheet 和 Claude Code 模板 开始。
实际验证结果
Masa 在一个小型笔记编辑器里试了这套结构。最明显的改进是先决定 syncStatus 和 updatedAt index。第一版用全量读取加 JavaScript filter,示例数据少时没有问题,测试数据增加后首屏变慢。改用 idb,并把草稿写入和队列插入放进同一个 transaction 后,失败状态更容易解释和测试。fake-indexeddb 覆盖了保存和查询,但无法验证 quota 和多标签页阻塞。最后用 Chrome DevTools 清空 storage、切到 Offline、保存草稿、刷新、恢复 Online,再确认队列请求被发送。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。