关于 web-pwa-offline-first
面向构建离线优先 Web 应用的开发者,提供本地存储、变更队列和数据同步模式。内容涵盖 IndexedDB、冲突处理及同步状态展示。
主要功能
- 01以 IndexedDB 作为本地数据源
- 02为变更排队并在重连后同步
- 03使用墓碑记录删除
- 04提供冲突解决策略
- 05检测连接并展示同步状态
适用场景
- 01让应用在断网时继续读写
- 02重连后重试待处理的变更
- 03处理多端并发编辑
- 04提示用户数据保存与同步进度
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-pwa-offline-first安装后在 Claude Code 中输入/web-pwa-offline-first确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/core.mdindexeddb.mdsync.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) - 比较不同设备上的时间戳只能检测差异,无法确定因果关系——时钟偏差会让顺序编辑看起来像并发编辑,反之亦然