
一句话:离线优先的本质是「把『本地数据库』当成唯一读写入口,把『网络同步』当成后台异步过程」——所以真正要设计的不是「缓存」,而是「队列的可靠性(重试、顺序、幂等、死信)、冲突解决、以及同步状态的可见性」。
📌 与已讲内容的分工:
10.11 PWA讲「Service Worker 的缓存与离线壳」;3.14 跨标签页同步讲「多标签页一致」;本篇讲「数据层视角的离线队列与本地库」。
一、三种策略:先选清楚
⭐⭐⭐ 三种「数据策略」(不是「缓存策略」):
① 【网络优先(Network-first)】
读:先请求网络,失败再用缓存
写:直接请求网络,失败就报错
✅ 实现最简单;✅ 数据最新
⚠️ 弱网下「每次都要等超时」;离线时「完全不可用」
⭐ 适合:**绝大多数管理后台**(在线是常态)
② 【缓存优先(Cache-first)】
读:先用缓存(可配「后台刷新」)
写:⭐ **仍然要网络**
✅ 读很快、离线可读
⚠️ 数据可能陈旧;写操作离线仍然失败
⭐ 适合:**读多写少、容忍陈旧**(新闻、文档、商品列表)
③ 【离线优先(Offline-first)】⭐ 本篇主题
读:**先读本地库**(本地库是「真相」),后台同步
写:⭐ **先写本地库 + 入队**,联网后自动同步
✅ 离线完全可用;✅ 体感最快(写操作「零延迟」)
⚠️ **代价很大**:
· 需要「本地数据库 + 队列 + 同步引擎」
· 需要「冲突解决」(多设备/多标签页)
· 需要「同步状态可见」(否则用户以为「已经生效了」)
⭐ 适合:**现场作业类应用**(外勤、盘点、医院、工地、销售拜访、离线笔记)
—— 这些场景「网络不可靠」是常态,且「丢掉用户输入」的代价很高
⭐ 判据(一句话):「**用户会不会在『没网』的环境里使用,并且『丢掉他输入的数据』是不可接受的?**」
会 → 离线优先;不会 → 网络优先 + 一点缓存就够。
二、离线优先的四个能力
⭐⭐⭐ 四个必须都做到的能力(缺一个就会「半吊子」):
① 【本地库是「唯一读写入口」】⭐
❌ 组件里「有时读网络、有时读本地」→ 状态来源混乱、逻辑分叉
✅ 组件只读「本地库」(内存 store / IndexedDB);
网络同步「只负责把本地库与服务端对齐」
⭐ 好处:UI 逻辑只有一条路径(不用写「在线/离线」两套分支)
② 【写操作「先落本地 + 入队」】
用户点「保存」→ ① 立刻写本地库(UI 立刻反映)→ ② 入队 → ③ 后台尝试同步
⭐ 关键:「入队」与「写本地」必须「原子」(要么都成功,要么都不做)
⚠️ 否则会出现「本地有数据但队列里没有」→ 永远不同步
③ 【队列要「可靠」】⭐ 最容易被做残的地方
重试(指数退避)· 顺序(同一实体的操作要保序)· 幂等(重复提交安全)
· 死信(永久失败的怎么办)· 持久化(刷新后队列还在)
· 取消/合并(用户撤销了,队列里的操作也要撤)
④ 【同步状态「可见」】⭐
用户必须知道:「这条还在同步」「这条同步失败了」「这条可能是旧的」
⚠️ 不可见的后果:用户以为「已经保存了」,结果关掉标签页就丢了(⭐ 信任崩塌)
三、本地库:为什么是 IndexedDB
⭐⭐⭐ 本地库选择的四条判据(与 3.15 的介质对比同源):
· **容量**:「几百条待同步记录 + 本地业务数据」可能几 MB~几十 MB
→ ❌ `localStorage` 只有 5MB 且**同步阻塞**(会卡 UI)
→ ✅ **IndexedDB**(容量大、异步、支持索引与事务)
· **查询**:需要「按状态筛」「按时间排序」「按实体查」
→ ✅ IndexedDB 的**索引**
· **事务**:需要「写本地 + 写队列」原子
→ ✅ IndexedDB 的**事务**(把两个 store 放进同一个事务)
· **结构化克隆**:能直接存 `Date`/`Blob`/`ArrayBuffer`
→ ✅ IndexedDB 原生支持(⭐ 避免了 3.15 里的「JSON 陷阱」)
// ✅ 用 Dexie 定义本地库(比裸 IndexedDB 好写得多)
import Dexie, { type Table } from 'dexie';
/** ⭐ 本地业务表 */
interface LocalNote {
id: string;
title: string;
content: string;
updatedAt: number;
/** ⭐⭐ 同步元数据(每条记录都要有) */
sync: {
/** 'synced' | 'pending' | 'failed' */
state: 'synced' | 'pending' | 'failed';
/** 服务端版本号(乐观锁,见 7.11) */
serverVersion: number | null;
/** ⭐ 本地最后修改时间(用于「冲突时谁赢」的判断) */
localUpdatedAt: number;
/** 最后同步尝试的时间与错误 */
lastTriedAt: number | null;
lastError: string | null;
/** ⭐ 重试次数(指数退避 + 上限) */
attempts: number;
};
}
/** ⭐ 队列项(⭐ 独立的表,便于「按顺序取」与「按状态筛」) */
interface QueueItem {
/** ⭐⭐ 自增主键(保证「入队顺序」稳定) */
seq?: number;
/** 幂等键(⭐ 服务端据此去重,见 10.11) */
idempotencyKey: string;
/** 操作类型(用「联合类型」表达,便于穷举处理) */
op:
| { kind: 'note.create'; payload: LocalNote }
| { kind: 'note.update'; payload: LocalNote }
| { kind: 'note.delete'; payload: { id: string } };
/** ⭐「依赖」:必须等某个 seq 完成后才能执行(保证同一实体的操作顺序) */
dependsOnSeq?: number;
/** ⭐ 实体 id(用于「合并同实体的操作」与「保序」) */
entityId: string;
createdAt: number;
attempts: number;
/** ⭐ 下一次可尝试的时间(指数退避) */
nextAttemptAt: number;
status: 'pending' | 'inflight' | 'failed' | 'dead';
lastError: string | null;
}
/** ⭐ 冲突记录(让用户「手动决定」比「静默覆盖」好得多) */
interface Conflict {
id: string;
entityId: string;
local: LocalNote;
remote: LocalNote;
detectedAt: number;
}
export class AppDatabase extends Dexie {
notes!: Table<LocalNote, string>;
queue!: Table<QueueItem, number>;
conflicts!: Table<Conflict, string>;
constructor() {
super('app-db');
// ⭐⭐ 版本迁移(与 3.15 同样的纪律:每个版本一个 upgrade 函数)
this.version(1).stores({
notes: 'id, updatedAt, sync.state', // ⭐ 索引:主键 + 常用查询字段
queue: '++seq, status, nextAttemptAt, entityId',
conflicts: 'id, entityId',
});
this.version(2)
.stores({
// ⭐ v2:给 notes 加「标签」索引(旧数据自动迁移,新字段为 undefined)
notes: 'id, updatedAt, sync.state, tags',
queue: '++seq, status, nextAttemptAt, entityId, dependsOnSeq',
conflicts: 'id, entityId',
})
.upgrade(async (tx) => {
// ⭐ 显式迁移(给旧数据补默认值)
await tx.table<LocalNote>('notes').toCollection().modify((note) => {
(note as LocalNote & { tags?: string[] }).tags ??= [];
});
});
}
}
export const db = new AppDatabase();
四、队列:可靠性的七个细节
⭐⭐⭐ 队列「做残」的七种方式(每一条都对应一个真实故障):
① 【只存内存】→ 刷新/关闭标签页后「待同步操作丢失」
✅ 队列必须**持久化**(IndexedDB 表)
② 【没有幂等键】→ 网络超时后重试 → **服务端执行两次**
(⭐ 典型:「重复下单」「重复退款」)
✅ 每个队列项带 `idempotencyKey`,**服务端按它去重**(见 10.11)
③ 【没有顺序保证】→ 「先创建后更新」可能变成「更新一个还不存在的实体」
✅ 两种做法:
· **按实体保序**:同一 `entityId` 的操作串行(用 `dependsOnSeq` 串成链)
· **操作合并**:把同一实体的多次修改「合并成一个」(⭐ 更高效,见下)
④ 【没有重试退避】→ 断网时「每秒重试一次」→ 打爆电量与电池
✅ **指数退避**(1s → 2s → 4s → … 上限 60s)+ 抖动
⑤ 【不区分「可重试」与「永久失败」】→ 「参数错误」被无限重试
✅ 按错误类型分流:`4xx`(除 408/429)→ **死信**;`5xx`/网络 → 重试
⑥ 【没有死信处理】→ 永久失败的操作「卡在队列里」→ 阻塞后续(如果保序)
✅ 把永久失败的移到 `dead` 状态 + ⭐ **通知用户**(让他决定「重试/丢弃/导出」)
⑦ 【没有「取消/合并」】→ 用户「删了刚建的笔记」,队列里还有「创建 + 删除」两条
✅ 入队时「合并同实体的操作」(⭐ 见下文的「合并规则表」)
// ✅ 操作合并规则(⭐ 「减少队列长度」与「避免无意义请求」的关键)
/**
* 同一实体的「连续操作」可以合并:
*
* create → update ⇒ create(把 update 的字段并进 payload)
* create → delete ⇒ ⭐ 「什么都不做」(直接移除两条)—— 净效果是「没发生」
* update → update ⇒ update(合并字段,只保留最后一次)
* update → delete ⇒ delete(丢弃 update)
* delete → create ⇒ update(「重建」等价于「更新」,⭐ 但要保留 id)
*
* ⚠️ 但有几条「不能合并」的情形:
* · 操作「有服务端副作用」(如「发送通知」「扣款」)→ 必须逐条执行
* · 操作「依赖前一步的返回值」(如「创建拿 id 再更新」)→ 不能合并
* · 「幂等键语义」要求「每次都是一个独立请求」(如「埋点上报」)
*/
export function mergeQueueOps(existing: QueueItem | undefined, next: QueueItem): QueueItem | null {
if (!existing) return next;
const a = existing.op;
const b = next.op;
// ⭐ create → update:合并成 create(并把 idempotencyKey 保留「最初的」)
if (a.kind === 'note.create' && b.kind === 'note.update') {
return { ...existing, op: { kind: 'note.create', payload: { ...a.payload, ...b.payload } } };
}
// ⭐ create → delete:净效果「什么都没发生」→ 移除两条(返回 null)
if (a.kind === 'note.create' && b.kind === 'note.delete') {
return null;
}
// ⭐ update → update:合并字段
if (a.kind === 'note.update' && b.kind === 'note.update') {
return { ...existing, op: { kind: 'note.update', payload: { ...a.payload, ...b.payload } } };
}
// update → delete:只留 delete
if (a.kind === 'note.update' && b.kind === 'note.delete') {
return { ...existing, op: b };
}
// 其余情况:「追加」(不合并)
return next;
}
/** ============ 队列引擎(250 行里的核心部分) ============ */
const BASE_DELAY_MS = 1_000;
const MAX_DELAY_MS = 60_000;
const MAX_ATTEMPTS = 6;
function backoffDelay(attempts: number): number {
// ⭐ 指数退避 + 抖动(⭐ 抖动很重要:避免「多个客户端同时重试」造成尖峰)
const exp = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempts);
const jitter = Math.random() * 0.3 * exp;
return Math.round(exp + jitter);
}
export class SyncQueue {
private running = false;
private listeners = new Set<() => void>();
/** ⭐ 入队(⭐ 与「写本地」放在同一个 IndexedDB 事务里——保证原子) */
async enqueue(item: Omit<QueueItem, 'seq' | 'attempts' | 'nextAttemptAt' | 'status' | 'lastError'>) {
return db.transaction('rw', db.queue, db.notes, async () => {
// ⭐ 找「同一实体的待处理项」看能否合并
const pending = await db.queue
.where('entityId')
.equals(item.entityId)
.filter((q) => q.status === 'pending' || q.status === 'failed')
.last(); // ⭐ 取「最后一条」(顺序合并)
const merged = mergeQueueOps(pending, {
...item,
attempts: 0,
nextAttemptAt: Date.now(),
status: 'pending',
lastError: null,
});
if (merged === null) {
// ⭐ 「create → delete」→ 移除已有那条(净效果「什么都没发生」)
if (pending?.seq !== undefined) await db.queue.delete(pending.seq);
return;
}
if (pending?.seq !== undefined && merged !== pending) {
// ⭐ 合并进已有项(⚠️ 不新增队列项 → 队列不会无限增长)
await db.queue.update(pending.seq, {
op: merged.op,
createdAt: Date.now(),
attempts: 0,
nextAttemptAt: Date.now(),
status: 'pending',
});
return;
}
await db.queue.add(merged);
});
}
/** ⭐ 取「下一个可执行」的项(⭐ 保证「同一实体的顺序」) */
private async pickNext(): Promise<QueueItem | undefined> {
const now = Date.now();
const candidates = await db.queue
.where('status')
.anyOf('pending', 'failed')
.filter((q) => q.nextAttemptAt <= now)
.toArray();
if (candidates.length === 0) return undefined;
// ⭐⭐ 顺序保证:对「同一实体」,只执行「seq 最小」的那条
const minSeqByEntity = new Map<string, number>();
for (const item of candidates) {
const seq = item.seq!;
const cur = minSeqByEntity.get(item.entityId);
if (cur === undefined || seq < cur) minSeqByEntity.set(item.entityId, seq);
}
const runnable = candidates.filter((item) => minSeqByEntity.get(item.entityId) === item.seq);
// ⭐ 再按 seq 全局排序(先来先做)
runnable.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
return runnable[0];
}
/** ⭐⭐ 执行一项(⭐ 核心:区分「可重试」与「永久失败」) */
private async execute(item: QueueItem): Promise<void> {
await db.queue.update(item.seq!, { status: 'inflight' });
try {
await performRequest(item); // ⭐ 真正发请求(带幂等键)
await db.transaction('rw', db.queue, db.notes, async () => {
await db.queue.delete(item.seq!);
// ⭐ 标记「已同步」(⭐ 如果「期间又有新的本地修改」,就保持 pending)
const local = await db.notes.get(item.entityId);
if (local && local.sync.localUpdatedAt <= item.createdAt) {
await db.notes.update(item.entityId, {
sync: { ...local.sync, state: 'synced', serverVersion: (local.sync.serverVersion ?? 0) + 1, lastError: null, attempts: 0 },
});
}
});
this.notify();
} catch (err) {
const classified = classifyQueueError(err); // ⭐ 分类(见下)
const attempts = item.attempts + 1;
if (classified.kind === 'permanent' || attempts >= MAX_ATTEMPTS) {
// ⭐⭐ 死信:永久失败 → 不再重试,但「保留在队列里 + 标红」(让用户决定)
await db.queue.update(item.seq!, {
status: 'dead',
attempts,
lastError: classified.message,
nextAttemptAt: Number.MAX_SAFE_INTEGER, // ⭐ 永不自动执行
});
// ⭐ 本地标记「失败」(UI 显示「同步失败,点击重试」)
await db.notes.update(item.entityId, {
sync: { ...(await db.notes.get(item.entityId))!.sync, state: 'failed', lastError: classified.message, attempts },
});
} else if (classified.kind === 'conflict') {
// ⭐⭐ 冲突:不重试,而是「记录下来 + 让用户决定」(见第五节)
await db.queue.delete(item.seq!);
await this.recordConflict(item, classified.remote);
} else {
// ⭐ 可重试:指数退避
await db.queue.update(item.seq!, {
status: 'failed',
attempts,
lastError: classified.message,
nextAttemptAt: Date.now() + backoffDelay(attempts),
});
}
this.notify();
}
}
/** ⭐ 主循环(⭐ 单实例:用「running 标志」防并发执行) */
async flush(): Promise<void> {
if (this.running) return; // ⚠️ 防重入(否则同一条会被执行两次)
if (!navigator.onLine) return; // ⭐ 离线直接跳过
this.running = true;
try {
// ⭐ 最多连续执行 20 条(避免「一次同步几千条」阻塞)
for (let i = 0; i < 20; i++) {
const item = await this.pickNext();
if (!item) break;
await this.execute(item);
}
} finally {
this.running = false;
}
}
/** ⭐ 订阅「队列状态」(供 UI 显示「待同步 N 条」) */
subscribe(fn: () => void) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
private notify() { this.listeners.forEach((fn) => fn()); }
/** ⭐ 统计(UI 用) */
async stats() {
const all = await db.queue.toArray();
return {
pending: all.filter((q) => q.status === 'pending' || q.status === 'inflight').length,
failed: all.filter((q) => q.status === 'failed').length,
dead: all.filter((q) => q.status === 'dead').length,
};
}
/** ⭐ 手动重试死信 */
async retryDead(seq: number) {
await db.queue.update(seq, { status: 'pending', attempts: 0, nextAttemptAt: Date.now(), lastError: null });
void this.flush();
}
private async recordConflict(item: QueueItem, remote: LocalNote) {
const local = await db.notes.get(item.entityId);
if (!local) return;
await db.conflicts.put({
id: item.idempotencyKey,
entityId: item.entityId,
local,
remote,
detectedAt: Date.now(),
});
}
}
/** ============ 错误分类(⭐ 决定「重试 / 死信 / 冲突」的关键) ============ */
type QueueError =
| { kind: 'retryable'; message: string } // 5xx / 超时 / 网络
| { kind: 'permanent'; message: string } // 4xx(除 408/429)
| { kind: 'conflict'; message: string; remote: LocalNote }; // 409(乐观锁失败)
function classifyQueueError(err: unknown): QueueError {
const status = (err as { status?: number })?.status;
const message = (err as { message?: string })?.message ?? '未知错误';
if (status === 409) {
// ⭐ 冲突:带「服务端当前版本」(让用户能对比)
const remote = (err as { remote?: LocalNote })?.remote;
if (remote) return { kind: 'conflict', message, remote };
return { kind: 'permanent', message }; // 「没有 remote」时降级为「永久失败」
}
// ⭐ 「可重试」:网络层错误(无 status)/ 5xx / 408 / 429
if (status === undefined || status >= 500 || status === 408 || status === 429) {
return { kind: 'retryable', message };
}
// ⭐ 「永久失败」:其余 4xx(400/401/403/404/422)
return { kind: 'permanent', message };
}
/** ⭐ 真正发请求(⭐ 带幂等键 —— 这是「重试安全」的全部依据) */
async function performRequest(item: QueueItem): Promise<void> {
const res = await fetch('/api/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// ⭐⭐ 幂等键:服务端按它去重(「第二次到达」直接返回「第一次的结果」)
'Idempotency-Key': item.idempotencyKey,
},
body: JSON.stringify(item.op),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// ⭐ 把「冲突时的服务端数据」带出来(供用户对比)
throw Object.assign(new Error(body.message ?? `HTTP ${res.status}`), { status: res.status, remote: body.remote });
}
}
五、冲突解决:三条路线
⭐⭐⭐ 冲突(多设备/多标签页改同一条)只有三条路线:
① 【最后写入赢(LWW)】⭐ 最简单
用「时间戳/版本号」决定谁赢,输的一方被覆盖
✅ 实现简单;⚠️ **静默丢数据**(用户不知道自己的修改没了)
⭐ 仅适合「后写覆盖前写」语义的字段(备注、状态)
② 【乐观锁 + 提示冲突(⭐ 推荐)】
客户端提交「我读到的版本号」→ 服务端不匹配 → **409**
→ 前端「记录下来 + 提示用户」(⭐ 让用户选择「用我的 / 用服务端的 / 合并」)
✅ **不丢数据**;✅ 用户有掌控感
⚠️ 需要「冲突 UI」(多写一点前端代码,但值得)
③ 【自动合并(CRDT / 字段级合并)】
· **字段级合并**:不同字段的修改自动合并(⭐ 多数冲突其实都是「改了不同字段」)
· **CRDT**:把「状态」改成「可交换的操作」(计数器、集合、文本)
✅ 最先进;⚠️ 复杂度最高(文本 CRDT 尤其)
⭐ 实践建议:**先做「字段级合并」**(能解决大部分冲突),
真的需要「同一段文本并发编辑」再上 CRDT 库(Yjs/Automerge)。
/** ============ 字段级合并(⭐ 性价比最高的冲突策略) ============ */
export interface FieldMergeResult {
/** 能否自动合并 */
canAutoMerge: boolean;
/** 合并后的结果 */
merged: LocalNote;
/** ⭐ 真正冲突的字段(需要用户决定) */
conflictFields: Array<keyof LocalNote>;
}
/** ⭐⭐ 用「字段级的最后修改时间」做三方合并(base / local / remote) */
export function mergeNoteChanges(
base: LocalNote, // 上次同步时的版本
local: LocalNote, // 本地当前(可能被改过)
remote: LocalNote // 服务端当前
): FieldMergeResult {
const merged: LocalNote = { ...remote };
const conflictFields: Array<keyof LocalNote> = [];
// ⭐ 只比较「业务字段」(不含 sync 元数据)
const businessKeys: Array<keyof LocalNote> = ['title', 'content'];
// ⭐ 「标签」用「集合并集」合并(⭐ 两边加的标签都保留 —— 这是「无冲突」的)
const localTags = new Set((local as LocalNote & { tags?: string[] }).tags ?? []);
const remoteTags = new Set((remote as LocalNote & { tags?: string[] }).tags ?? []);
(merged as LocalNote & { tags?: string[] }).tags = [...new Set([...localTags, ...remoteTags])];
for (const key of businessKeys) {
const baseVal = base[key];
const localVal = local[key];
const remoteVal = remote[key];
// ⭐ 情况 A:只有本地改了 → 用本地
if (localVal !== baseVal && remoteVal === baseVal) {
(merged[key] as unknown) = localVal;
continue;
}
// ⭐ 情况 B:只有远端改了(或都没改)→ 用远端(已经是 merged 的初值)
if (remoteVal !== baseVal && localVal === baseVal) continue;
// ⭐⭐ 情况 C:两边都改了同一个字段 → 真冲突(⚠️ 需要用户决定)
if (localVal !== remoteVal) conflictFields.push(key);
}
return { canAutoMerge: conflictFields.length === 0, merged, conflictFields };
}
/** ============ 冲突 UI(让用户决定,而不是静默覆盖) ============ */
function ConflictResolver({ conflictId }: { conflictId: string }) {
const [conflict, setConflict] = useState<Conflict | null>(null);
useEffect(() => {
void db.conflicts.get(conflictId).then((c) => setConflict(c ?? null));
}, [conflictId]);
if (!conflict) return null;
const localOnly = conflict.local.content;
const remoteOnly = conflict.remote.content;
return (
<div role="dialog" aria-modal="true" aria-label="修改冲突">
<h2>这条笔记在别处也被修改了</h2>
{/* ⭐ 并排对比(⭐ 让用户「看得懂」比「自动决定」重要得多) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<section>
<h3>你的版本({new Date(conflict.local.updatedAt).toLocaleString('zh-CN')})</h3>
<pre style={{ whiteSpace: 'pre-wrap' }}>{localOnly}</pre>
<button onClick={() => void resolveConflict(conflict.id, 'local')}>保留我的</button>
</section>
<section>
<h3>服务端版本({new Date(conflict.remote.updatedAt).toLocaleString('zh-CN')})</h3>
<pre style={{ whiteSpace: 'pre-wrap' }}>{remoteOnly}</pre>
<button onClick={() => void resolveConflict(conflict.id, 'remote')}>保留服务端的</button>
</section>
</div>
{/* ⭐ 或者「都要」(把本地版本作为「新笔记」保留,避免丢内容) */}
<button onClick={() => void resolveConflict(conflict.id, 'both')}>两个都保留</button>
</div>
);
}
async function resolveConflict(conflictId: string, choice: 'local' | 'remote' | 'both') {
const conflict = await db.conflicts.get(conflictId);
if (!conflict) return;
if (choice === 'remote') {
// ⭐ 用服务端版本覆盖本地
await db.notes.put({ ...conflict.remote, sync: { ...conflict.remote.sync, state: 'synced' } });
} else if (choice === 'local') {
// ⭐ 用本地版本「重新提交」(带「服务端最新版本号」做乐观锁,⭐ 避免再次冲突)
await db.notes.update(conflict.entityId, {
sync: { ...conflict.local.sync, state: 'pending', serverVersion: conflict.remote.sync.serverVersion },
});
await queue.enqueue({
idempotencyKey: crypto.randomUUID(),
op: { kind: 'note.update', payload: conflict.local },
entityId: conflict.entityId,
createdAt: Date.now(),
});
} else {
// ⭐ 「都保留」:服务端版本保存在原记录,「本地版本」作为新笔记
await db.notes.put({ ...conflict.remote, sync: { ...conflict.remote.sync, state: 'synced' } });
const newId = crypto.randomUUID();
await db.notes.put({
...conflict.local,
id: newId,
title: `${conflict.local.title}(冲突副本)`,
sync: { ...conflict.local.sync, state: 'pending', serverVersion: null },
});
await queue.enqueue({
idempotencyKey: crypto.randomUUID(),
op: { kind: 'note.create', payload: { ...conflict.local, id: newId } },
entityId: newId,
createdAt: Date.now(),
});
}
await db.conflicts.delete(conflictId);
}
六、本篇特有的坑
// ① 「写本地」与「入队」不在同一个事务(本地有数据但队列没有)
await db.notes.put(note);
await db.queue.add(item); // ⚠️ 中间崩溃 → 永不同步
// ✅ db.transaction('rw', db.notes, db.queue, async () => { ...两者一起... })
// ② 队列只存内存(刷新后丢失)
const queue = []; // ❌
// ✅ 持久化到 IndexedDB
// ③ 没有幂等键(重试导致「重复下单」)⭐
// ✅ 每个队列项带 `idempotencyKey`,服务端按它去重
// ④ 同一实体的操作「乱序执行」(更新一个还不存在的实体)
// ⚠️ 并发执行队列项时最容易出现
// ✅ 「同一 entityId 串行」(或「操作合并」)
// ⑤ 并发执行「同一条队列项」(重入)
flush(); flush(); // ⚠️ 同一条被执行两次
// ✅ `running` 标志防重入
// ⑥ 「4xx 也无限重试」(用户等到第 6 次才看到「参数错误」)
// ✅ 分类:4xx(除 408/429)→ 死信
// ⑦ 没有指数退避(断网时「每秒重试」→ 耗电)
// ✅ 退避 + 抖动
// ⑧ 没有死信处理(永久失败卡住队列)
// ✅ `dead` 状态 + UI 提示 + 手动重试入口
// ⑨ 队列「无限增长」(用户狂改一条笔记 → 几百条队列)
// ✅ 入队时「合并同实体的操作」
// ⑩ 「create → delete」在队列里两条都留着(净效果是「没发生」)
// ✅ 合并规则里「直接移除两条」
// ⑪ 「删除」用「物理删除」(本地直接删掉,无法「回滚」)
// ⚠️ 同步失败时「数据没了」
// ✅ 用「软删除」(`deletedAt` 标记)+ 同步成功后再物理删
// ⑫ 「同步状态不显示」(用户以为已保存)
// ✅ ⭐ UI 上「每条记录的状态徽标」+「全局待同步数」
// ⑬ 「离线改动」在「重新联网」时没有自动触发同步
// ✅ 监听 `online` 事件 + `visibilitychange`(恢复可见时)+ `Leader 选举`(只让主标签页同步,见 3.14)
// ⑭ 「多个标签页各自同步」(重复提交)
// ⭐ 两个标签页同时 flush → 同一条队列项被执行两次(虽然有幂等键兜底,但浪费)
// ✅ Leader 选举(Web Locks)+「队列项加 inflight 状态 + 超时回收」
// ⑮ 「inflight 状态」在「标签页崩溃」后残留(永远卡在 inflight)
// ✅ 启动时「把超过 N 分钟的 inflight 重置为 pending」
// ⑯ 「服务端返回的 id」与「本地临时 id」不一致(关联丢失)
// ⭐ 典型:「先本地创建(temp id)→ 服务端返回真实 id」→ 本地要「替换 id 并更新引用」
// ✅ 让服务端「接受客户端生成的 id」(⭐ 最省心:客户端用 uuid,服务端照存)
// 或「在响应里返回 id 映射」,前端做一次「引用改写」(⭐ 要处理「其他记录引用了它」)
// ⑰ 「大数据量」一次性读取(内存爆)
const all = await db.notes.toArray(); // ⚠️ 几万条会卡
// ✅ 分页 + 索引查询 + 虚拟列表(见 5.5)
// ⑱ 「同步时」全量拉取(每次同步都拉全表)
// ✅ 「增量同步」(带 `updatedSince` 或 cursor,⭐ 或服务端推送变更)
// ⑲ 「离线时」用户看到「数据不一致但没提示」
// ✅ 全局「离线」状态条 + 「这条可能是旧的」标记(见 10.11)
// ⑳ 「时钟不可靠」(用 `Date.now()` 做「最后修改时间」)
// ⚠️ 用户改系统时间 / 多设备时钟偏差 → LWW 判断错
// ✅ 用「服务端时间」或「逻辑时钟(Lamport)」/「版本号」而非墙钟
// ㉑ 「冲突」静默覆盖(LWW)→ 用户数据丢失且无感知 ⭐
// ✅ 乐观锁 + 冲突 UI(或至少「字段级合并」)
// ㉒ 「IndexedDB 被清理」(浏览器存储压力下会清掉「best-effort」存储)
// ✅ `navigator.storage.persist()` 申请「持久存储」+「关键数据在联网时尽快同步」
// ㉓ 「隐私模式」下 IndexedDB 不可用(或打开失败)
// ✅ 降级到「内存模式」+ 明确提示「当前无法离线保存」
// ㉔ 「同步引擎」写在组件里(随组件卸载而停)
// ✅ 独立于 React 的「服务模块」(在应用启动时初始化)
// ⑮ 的完整处理(回收「卡住的 inflight」)
async function recoverStaleInflight(timeoutMs = 2 * 60_000) {
const cutoff = Date.now() - timeoutMs;
const stale = await db.queue
.where('status')
.equals('inflight')
.filter((q) => q.nextAttemptAt <= cutoff) // ⭐ 用 nextAttemptAt 记「开始执行的时间」
.toArray();
for (const item of stale) {
await db.queue.update(item.seq!, {
status: 'pending',
nextAttemptAt: Date.now(),
lastError: '上次执行被中断(可能标签页崩溃)',
});
}
}
// 应用启动时调用一次
// ⑬ 的完整处理(同步的触发时机)
export function startSyncEngine(queue: SyncQueue) {
// ⭐ ① 网络恢复
window.addEventListener('online', () => void queue.flush());
// ⭐ ② 恢复可见(后台标签页可能错过事件、浏览器也会节流)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void queue.flush();
});
// ⭐ ③ 定时兜底(⚠️ 后台会被节流,只作为「尽力而为」)
setInterval(() => void queue.flush(), 30_000);
// ⭐ ④ 启动时先恢复卡住的项,再 flush
void recoverStaleInflight().then(() => queue.flush());
// ⭐⑤ 用 Leader 选举「只让主标签页同步」(避免多标签页重复提交,见 3.14)
// leader.onBecomeLeader(() => void queue.flush());
}
七、面试延伸
- 「什么是『离线优先』?和『缓存』有什么区别?」
缓存是「网络优先 + 兜底」——读可以走缓存,但写操作仍然必须联网(离线时写失败);离线优先是「本地库是唯一读写入口,网络同步是后台异步过程」——⭐ 读先读本地库(本地是「真相」),⭐ 写先写本地库 + 入队,联网后自动同步。离线优先的代价明显更大:需要「本地数据库 + 可靠队列 + 冲突解决 + 同步状态可见」。选型判据:⭐ 「用户会不会在『没网』的环境里使用,并且『丢掉他输入的数据』不可接受?」——会(外勤、盘点、工地、离线笔记)→ 离线优先;不会(绝大多数管理后台)→ 网络优先 + 一点缓存就够。 -
「离线队列要可靠,必须做到什么?」
七个细节:① ⭐ 持久化(IndexedDB 表,不能只在内存);② ⭐ 幂等键(每个队列项带idempotencyKey,服务端按它去重——否则「网络超时后重试」会重复下单/重复退款);③ ⭐ 顺序保证(「同一实体」的操作必须串行,否则会出现「更新一个还不存在的实体」);④ ⭐ 指数退避 + 抖动(断网时别每秒重试,也别让所有客户端同时重试);⑤ ⭐ 区分「可重试」与「永久失败」(5xx/网络/408/429 → 重试;其余 4xx → 死信);⑥ ⭐ 死信处理(永久失败不能卡住队列 → 移到dead状态 + 通知用户让他决定);⑦ ⭐ 取消/合并(用户「建了又删」→ 队列里不该留两条)。另外还有两个容易被忽略的:「写本地」与「入队」必须原子(同一事务)、「inflight 状态」要能回收(标签页崩溃后不能永远卡住)。 -
「队列项为什么要『合并』?」
两个目的:① ⭐ 避免队列无限增长——用户「狂改一条笔记」会产生几十条update,合并后只剩一条(⭐ 而且「只发最后一次」比「发几十次」快得多);② ⭐ 避免无意义请求——「create → delete」净效果是「什么都没发生」,两条都发就是「建了又删」。合并规则:create→update⇒create(字段并入);create→delete⇒ 移除两条;update→update⇒update(合并字段);update→delete⇒delete。⚠️ 不能合并的情形:① 操作有服务端副作用(「发通知」「扣款」必须逐条);② 操作依赖前一步的返回值;③ 幂等键语义要求「每次都是独立请求」(如埋点上报)。 -
「离线优先里,「冲突」怎么处理?」
三条路线:① LWW(最后写入赢)——用时间戳/版本号决定,⚠️ 静默丢数据(用户不知道自己的修改没了),⭐ 只适合「后写覆盖前写」的字段;② ⭐ 乐观锁 + 冲突 UI(推荐)——客户端提交「我读到的版本号」,服务端不匹配就返回 409 → 前端记录下来 + 并排展示「我的」与「服务端的」+ 让用户选(保留我的 / 保留服务端的 / 两个都保留)——✅ 不丢数据、用户有掌控感;③ ⭐ 自动合并——字段级合并(⭐ 性价比最高:多数冲突其实是「改了不同字段」或「加了不同标签」,都能自动合)与 CRDT(最先进但最复杂)。实践建议:先做「字段级合并」,实在需要「同一段文本并发编辑」再上 CRDT 库(Yjs/Automerge)——⚠️ 不要为了「技术优雅」手写文本 CRDT。 -
「本地库为什么用 IndexedDB 而不是 localStorage?」
四个判据:① ⭐ 容量——「几百条待同步记录 + 本地业务数据」可能几十 MB,而localStorage只有 5MB;② ⭐ 不能阻塞主线程——localStorage是同步 API(写大对象会卡 UI),IndexedDB 是异步的;③ ⭐ 事务——需要「写本地 + 入队」原子(把两个 store 放进同一个事务),localStorage没有事务;④ ⭐ 结构化克隆——IndexedDB 能直接存Date/Blob/ArrayBuffer(避免 3.15 里的 JSON 陷阱),而localStorage只能存字符串。另外:IndexedDB 有索引(按状态/时间/实体查询,⭐ 队列的「取下一个可执行项」就靠它)。实践上用 Dexie(idb也行)——它把「版本迁移」也变简单了(⭐ 与 3.15 的迁移纪律一致:每个版本一个upgrade函数)。 -
「同步状态怎么让用户看到?」
⭐ 至少三层:① 每条记录的状态徽标(「已同步 / 待同步 / 同步失败」)——用户能知道「我刚改的这条是不是保存了」;② ⭐ 全局的待同步计数(如「3 条待同步」,⭐ 并配「正在同步…」的进度);③ ⭐ 离线/失败的状态条(「当前离线,改动会在联网后同步」/「2 条同步失败,点击查看」)。为什么必须做:⚠️ 「不可见的同步」的后果是「用户以为已经保存了,结果关掉标签页就丢了」——⭐ 一旦发生一次,用户就再也不信这个应用了。另外:① 离线时明确标注「数据可能是旧的」(见 10.11);② 「同步失败」要给用户出路(重试 / 导出 / 丢弃)。 -
「离线优先有什么坑?」
六个高频:① ⭐ 「本地临时 id」与「服务端 id」不一致——最省心的做法是「让服务端接受客户端生成的 uuid」;否则要「做一次 id 映射 + 引用改写」(⚠️ 处理「别的记录引用了它」很麻烦);② ⭐ 多标签页重复同步——两个标签页同时flush→ 同一条被执行两次(虽然有幂等键兜底,但浪费)→ ✅ Leader 选举(Web Locks,见 3.14)+inflight状态;③ ⭐inflight卡死——标签页崩溃后项永远停在inflight→ ✅ 启动时「把超过 N 分钟的 inflight 重置为 pending」;④ 时钟不可靠——用墙钟做「最后修改时间」时,用户改系统时间或多设备偏差会导致 LWW 判断错 → ✅ 用服务端时间或逻辑时钟/版本号;⑤ IndexedDB 被浏览器清理(存储压力下 best-effort 存储会被清)→ ✅navigator.storage.persist()+ 「联网时尽快同步」;⑥ 隐私模式下 IndexedDB 不可用 → ✅ 降级到内存模式 + 明确提示「当前无法离线保存」。 -
「如果让你给一个应用加离线能力,会怎么做?」
⭐ 分阶段,不要一步到位:第一步(成本最低、收益最高)——① Service Worker 缓存应用壳(⭐ 至少「离线能打开」而不是白屏,见 10.11);② 只读数据的缓存(「最近一次成功的数据」+ 明确标注「离线数据,可能不是最新」);③ 离线状态条(让用户知道当前离线)。第二步——④ 写操作的「乐观 UI + 队列 + 幂等键」(⭐ 这一步就开始有「离线优先」的味道了,且能解决「用户填了半天点保存却失败」这个最痛的点);⑤ 同步状态可见(待同步数 + 每条的状态)。第三步(只有真的需要才做)——⑥ 本地库成为唯一读写入口(完整的离线优先);⑦ 字段级合并 + 冲突 UI;⑧ 多标签页 Leader 选举 + 增量同步。⭐ 判据:「先解决『丢数据』的痛,再考虑『完全离线可用』」——很多场景「第一步 + 队列」就够了,不必上完整的离线优先进阶。
一句话速记
离线优先的本质是「本地库是唯一读写入口,网络同步是后台异步过程」——读先读本地、写先写本地 + 入队;⭐ 选型判据:「用户会不会在没网环境下使用、且丢掉输入不可接受」(外勤/盘点/工地/离线笔记 → 离线优先;绝大多数后台 → 网络优先够);本地库用 IndexedDB(容量大 / 异步不阻塞 / 有事务(「写本地 + 入队」必须原子) / 结构化克隆存 Date 不用怕 JSON 陷阱 / 有索引),配 Dexie 并每个版本一个
upgrade(同 3.15 纪律);⭐ 队列可靠的七个细节:持久化、幂等键(防重复下单)、顺序保证(同实体串行)、指数退避 + 抖动、区分可重试(5xx/网络/408/429)与永久失败(其余 4xx → 死信)、死信可见可处理、操作合并(create→delete⇒ 直接移除两条;update→update⇒ 合并字段;⚠️ 有服务端副作用/依赖返回值/埋点语义的不能合并);⭐ 冲突三条路线:LWW(简单但静默丢数据,只适合「后写覆盖前写」)→ ⭐ 乐观锁 + 冲突 UI(推荐,并排展示 + 让用户选「我的/服务端的/都保留」)→ 自动合并(⭐ **先做「字段级合并」,真要「同段文本并发编辑」再上 CRDT);⭐ **同步状态必须可见(每条的状态徽标 + 全局待同步数 + 离线状态条——⚠️ 不可见的后果是「用户以为保存了、结果丢了」,信任崩塌);高频坑:本地临时 id 与服务端 id 不一致(✅ 让服务端接受客户端 uuid)、多标签页重复同步(✅ Leader 选举 + inflight)、inflight 卡死(✅ 启动时回收)、时钟不可靠(✅ 用服务端时间/逻辑时钟)、IndexedDB 可能被清(✅persist())、隐私模式降级;落地分三步:离线壳 + 只读缓存 + 状态条 → 乐观 UI + 队列 + 幂等键 + 同步可见 → 完整离线优先 + 冲突 UI。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!