mcpField
W
web-pwa-offline-first

web-pwa-offline-first

github.com/agents-inc开发26127 次安装2 天前更新
代码仓库

关于 web-pwa-offline-first

面向构建离线优先 Web 应用的开发者,提供本地存储、变更队列和数据同步模式。内容涵盖 IndexedDB、冲突处理及同步状态展示。

主要功能

  1. 01以 IndexedDB 作为本地数据源
  2. 02为变更排队并在重连后同步
  3. 03使用墓碑记录删除
  4. 04提供冲突解决策略
  5. 05检测连接并展示同步状态

适用场景

  1. 01让应用在断网时继续读写
  2. 02重连后重试待处理的变更
  3. 03处理多端并发编辑
  4. 04提示用户数据保存与同步进度

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/agents-inc/skills --skill web-pwa-offline-first

安装后在 Claude Code 中输入/web-pwa-offline-first确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

metadata.yaml
reference.md
examples/
core.md
indexeddb.md
sync.md

详细介绍

离线优先应用模式

快速指南: 读写操作首先进入本地数据库,之后网络再进行同步。IndexedDB 是存储层,可通过 Dexie(响应式查询,体积较大)或 idb(轻量,约 1.2KB)等封装库访问;每条可同步记录都带有 _syncStatus、_lastModified 和 _localVersion,以便队列知道哪些内容尚未处理。删除操作应使用墓碑标记,而不是直接移除,否则延迟同步会让记录复活。Background Sync API 仅适用于 Chromium,因此应使用 online 事件监听器作为机制,并将后台同步作为优化手段。

详细资源:

  • examples/core.md — 可同步实体、存储库、带退避机制的同步队列、连接状态检测、乐观更新、状态和变更钩子
  • examples/indexeddb.md — Dexie 模式和迁移、idb 替代方案、多标签页协调、配额管理
  • examples/sync.md — 最后写入胜出、字段级合并、冲突界面、版本向量、增量游标、后台同步、拉取-推送、状态指示器
  • reference.md — 存储和冲突策略选择、按症状排查问题、性能和安全说明

适用路径

  • 以读取为主,写入需要连接 — 本地副本用于读取,联网时刷新,不需要队列或冲突解决。模式 1、4 和 6 就是全部内容。
  • 完整的离线 CRUD — 变更会进入队列,会发生冲突,因此本文中的每种模式都适用。请从 examples/core.md 开始,并从 examples/sync.md 中选择解决策略。

<critical_requirements>

编写离线优先代码前

将本地数据库视为权威数据源。 每次读取都来自本地数据库,每次写入都会先写入本地数据库,再发送任何内容;因此无论连接状况如何,UI 都能即时响应。

为每条可同步记录添加 _syncStatus、_lastModified 和 _localVersion。 没有这些字段,就无法查询哪些内容尚未处理,也无法区分冲突和新写入。

通过写入 _deletedAt 墓碑标记来删除。 删除后的行没有内容可供同步,因此下一次拉取会使记录重新出现。

将每次变更都加入队列,并在重新连接时清空队列,同时采用指数退避和抖动。 否则,短暂的 502 会导致写入永久丢失;多个客户端同步重试,则会把短暂故障变成长时间故障。

不要在 IndexedDB 事务中加入任何其他 await。 如果没有待处理的请求,事务会在控制权返回事件循环时立即关闭,因此在事务中途等待 fetch 会导致 TRANSACTION_INACTIVE_ERR。先获取数据,再打开事务。

</critical_requirements>


自动检测: IndexedDB、indexedDB.open、IDBDatabase、IDBObjectStore、openDB、DBSchema、Dexie、 useLiveQuery、dexie-react-hooks、idb-keyval、同步队列、墓碑标记、_syncStatus、_lastModified、 最后写入胜出、版本向量、离线优先、本地优先、navigator.onLine、navigator.storage.persist、 BroadcastChannel、QuotaExceededError

适用范围:

  • 选择并设计可在重新加载和断开连接后继续保留数据的本地存储
  • 为用户离线编辑的记录建模同步元数据
  • 将变更加入队列、重试,并报告尚未处理的内容
  • 检测实际连接状态,而非网络接口
  • 解决并发编辑,从最后写入胜出到版本向量
  • 向用户显示哪些内容已保存在本地,哪些内容已到达服务器

其他内容:

  • 拦截网络请求并对 HTTP 响应缓存进行版本管理——响应缓存存储服务器返回的内容并使其过期;本技能负责用户创建的记录
  • 预缓存应用外壳,使应用无需网络也能启动
  • 协同字符级编辑需要的是收敛的复制数据类型,而非队列和合并

## 理念

网络是增强功能。本地存储是数据库,服务器是与之协调的对等方——这颠倒了通常的安排:本地存储只是事实数据的缓存。

由此得出两点。写入永远不会因等待请求而阻塞,因此 UI 的响应速度取决于磁盘,而非网络。每次写入也都会成为一项可能引发争议的声明,这就是为什么同步元数据是基础而非附加功能:没有版本的记录,是任何合并都无法判断的记录。

User action
    │
Local database  ←── the single source of truth
    │
UI updates immediately
    │
Sync queue (background)
    │
Server, when reachable
    │
Conflict resolution, if the record moved on both sides
    │
Local database updated

用户剩下要做的就是信任:他们需要看到更改已保存、已排队,并且最终已同步成功。同步状态是产品界面的一部分,而不是调试工具。


核心模式

模式 1:可同步实体

其他所有模式都会读取这些字段。业务数据与同步元数据保持分离,并为元数据加上前缀,以便合并时可以整体跳过。

interface SyncableEntity {
  id: string;
  _syncStatus: "synced" | "pending" | "conflicted";
  _lastModified: number;
  _serverTimestamp?: number;
  _localVersion: string;
  _serverVersion?: string;
  _deletedAt?: number; // tombstone
}

完整代码:examples/core.md

模式 2:存储库

为一个集合提供统一的访问点,这样调用方就不必记住一次写入包含两个操作。读取时过滤墓碑记录;写入时标记元数据、保存到本地,然后加入队列。

interface DataRepository<T extends SyncableEntity> {
  get(id: string): Promise<T | null>; // null for a tombstone
  getAll(): Promise<T[]>;
  save(item: T): Promise<void>; // local write, then enqueue
  delete(id: string): Promise<void>; // tombstone, then enqueue
  getPendingCount(): Promise<number>;
}

完整代码:examples/core.md

模式 3:带退避机制的同步队列

采用有上限的指数延迟,并加入随机抖动,避免重新连接的客户端同时发起请求。

const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30_000;
const JITTER_FACTOR = 0.5;

function calculateBackoff(attempt: number): number {
  const delay = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
  const jitter = delay * JITTER_FACTOR * (Math.random() * 2 - 1);
  return Math.floor(delay + jitter);
}

完整代码:examples/core.md — 重试限制、按时间戳排序、死信处理

模式 4:连接状态检测

navigator.onLine 报告网络接口是否存在。即使处于强制门户之后,或连接到没有上游网络的路由器时,它也会报告为真。应通过请求进行确认。

async function checkConnectivity(): Promise<boolean> {
  if (!navigator.onLine) return false; // cheap negative, trustworthy
  try {
    const response = await fetch("/api/health", {
      method: "HEAD",
      cache: "no-store",
    });
    return response.ok;
  } catch {
    return false;
  }
}

完整代码:examples/core.md — 延迟采样和“缓慢”状态

模式 5:带回滚的乐观更新

写入前捕获旧值并返回撤销操作,这样调用方在错误处理路径中只需调用一次,而不必重新构建旧值。

async function applyOptimistically<T>(id: string, next: T) {
  const previous = (await localDb.get(id)) ?? null;
  await localDb.put(next);

  return async function rollback() {
    if (previous) await localDb.put(previous);
    else await localDb.delete(id);
  };
}

完整代码:examples/core.md

模式 6:感知连接状态的数据获取

在返回数据的同时指出数据来源,这样 UI 就能提示“正在显示已保存的数据”,而不是默默将过时数据当作当前数据呈现。

interface FetchResult<T> {
  data: T;
  source: "network" | "cache";
  timestamp: number;
}

// try network with a timeout, cache the success, fall back to cache on any failure
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });

完整代码:examples/core.md

模式 7:冲突解决

三种策略,成本依次递增。应根据字段的含义来选择,而不是选择最容易实现的策略。

  • 最后写入胜出 — 由最新的时间戳决定记录。适用于彼此独立的值;若两个人编辑了不同字段,则不适用。examples/sync.md 模式 17。
  • 字段级合并 — 将每个字段与最后的共同状态进行比较,仅在双方都修改了同一字段时产生冲突。examples/sync.md 模式 18。
  • 版本向量 — 每个客户端各自递增的计数器,无需信任任何时钟即可区分并发编辑与顺序编辑。examples/sync.md 模式 20。

如果双方的数据都不能舍弃,就呈现冲突并让用户选择: examples/sync.md 模式 19。


<red_flags>

风险信号

运行时会出错:

  • 在 IndexedDB 事务中使用 await fetch(...) 或 await new Promise(setTimeout)——事务此时已经关闭,下一次操作会抛出 TRANSACTION_INACTIVE_ERR——先完成异步工作,再开启一个短事务
  • 硬删除尚未同步的记录——下一次拉取会让它重新出现——写入 _deletedAt,并在确认墓碑记录已同步后清理
  • 写入前不检查配额——QuotaExceededError 会导致保存失败,却没有任何警告——检查 navigator.storage.estimate(),并在写入前清理数据
  • 队列没有上限——长时间离线会耗尽存储空间,一次性排空整个队列会耗尽内存——限制队列大小,并分批排空
  • 等待服务器响应后才更新 UI——应用现在成了多此一举的在线优先模式——先保存到本地,再加入队列

容易出乎意料的行为:

  • navigator.onLine === true 表示存在网络接口,并不代表任何服务都能访问
  • Safari 会将脚本可写存储空间限制为用户无交互时最多保留七天,包括 IndexedDB;navigator.storage.persist() 有帮助,而安装应用的帮助更大
  • navigator.storage.estimate() 需要安全上下文,否则会返回 { usage: 0, quota: 0 }
  • Background Sync 仅适用于 Chromium,因此在其他浏览器中必须依靠 online 监听器
  • IndexedDB 没有布尔索引类型——复合查询会将 [userId+completed] 与 [userId, 1] 匹配
  • Dexie 的 useLiveQuery 在加载时返回 undefined,而非 null,因此 === undefined 检查表示加载状态
  • 两个标签页会在没有协调的情况下写入同一个数据库;它们通过 BroadcastChannel 达成一致 (examples/indexeddb.md 模式 15)
  • 比较不同设备上的时间戳只能检测差异,无法确定因果关系——时钟偏差会让顺序编辑看起来像并发编辑,反之亦然

评论

支持 Markdown