Tips & Tricks (更新: 2026/6/2)

Claude Code IndexedDB 入门:本地数据、离线队列与同步

用 Claude Code 安全实现 IndexedDB:schema、迁移、索引、事务、quota、离线同步和测试。

Claude Code IndexedDB 入门:本地数据、离线队列与同步

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 IndexedDBMDN storage quotas and eviction criteriaweb.dev Storage for the webidb READMEDexie.js docs。如果你想把这种边界写进日常提示词,也可以继续看 Claude Code 生产力技巧Claude Code 测试策略

什么时候不用 localStorage

localStorage 适合保存主题、语言、关闭过的 banner、最后打开的 tab 这类少量设置。它简单,但也是同步 API,会阻塞主线程;它没有索引,也不适合频繁更新大对象。把 5000 条记录序列化成一个 JSON 字符串,每次改一条都整体重写,是很多前端性能问题的起点。

IndexedDB 更像浏览器端的小数据库。object store 类似表,key 是主键,index 是按其他字段查询的路径,transaction 则保证一组读写一起成功或一起失败。它并不替代服务端数据库,而是让浏览器在离线、弱网、重复读取时更可靠。

场景localStorageIndexedDB
主题、短设置、UI 模式合适通常过重
长表单、文章草稿、恢复输入容量和阻塞风险高合适
可搜索的商品或文章缓存会变成巨大 JSON用 index 查询
图片 Blob、附件元数据不合适可以保存
离线操作队列顺序和重试难管理适合用事务

真实应用至少有四类用例。第一,CMS 或博客编辑器在用户输入时保存草稿,并按 updatedAtsyncStatus 查询未同步内容。第二,SaaS 管理台缓存商品、套餐、帮助文档或用户列表,按分类、更新时间或状态读取。第三,PWA 在离线时把评论、咨询表单、库存调整放入队列,网络恢复后重放。第四,Claude Code 或其他 AI 工具把较大的分析结果存在本地,并设置过期时间,避免每次切页面都重新请求。

先设计 schema、version 和 index

IndexedDB 的第一步不是“存什么”,而是“以后用什么条件取出来”。如果只按 id 取一条记录,设计很简单。但业务通常会问“只取未同步草稿”“取最旧缓存”“按创建时间发送队列”“找失败次数少于 3 的任务”。这些都需要 index。后加 index 时,必须提升数据库 version,并在 upgrade 逻辑里写迁移。

常见失败之一是最初只建 notes store,后来需要按 syncStatus 找数据,却只能全量读取再用 JavaScript filter。几十条数据看不出来,几千条就会拖慢首屏。另一个失败是使用 1.1 这种小数版本。IndexedDB 的 version 应按整数处理,建议明确使用 123

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 培训与咨询 把这些检查变成项目模板;个人开发者可以从 免费 cheatsheetClaude Code 模板 开始。

实际验证结果

Masa 在一个小型笔记编辑器里试了这套结构。最明显的改进是先决定 syncStatusupdatedAt index。第一版用全量读取加 JavaScript filter,示例数据少时没有问题,测试数据增加后首屏变慢。改用 idb,并把草稿写入和队列插入放进同一个 transaction 后,失败状态更容易解释和测试。fake-indexeddb 覆盖了保存和查询,但无法验证 quota 和多标签页阻塞。最后用 Chrome DevTools 清空 storage、切到 Offline、保存草稿、刷新、恢复 Online,再确认队列请求被发送。

#Claude Code #IndexedDB #本地存储 #离线 #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。