> 版本:v1.0 | 适用范围:PWA / SPA / 桌面端(Electron、Tauri 等)及移动端 Hybrid 前端应用 > 关键词:离线优先(Offline-First)、更新暂停、断点续传、冲突解决、Service Worker、本地队列
—
## 1. 背景与问题定义
现代前端应用越来越多地运行在弱网或断网环境(地铁、电梯、跨国差旅、工业现场等)。在这些场景下,用户对"应用能用"的预期高于"数据和云端实时同步"。如果更新逻辑不做特殊处理,会暴露出三类典型问题:
1. **写操作丢失**:用户离线时提交的表单、草稿、状态变更,因请求失败而被直接丢弃或抛出错误,造成数据丢失与体验断裂。 2. **更新风暴**:应用重连后,堆积的更新请求未经编排地并发发出,瞬间打爆后端,引发限流、雪崩或重复写入。 3. **版本错乱**:PWA 的 Service Worker 在离线时尝试激活新版本,可能导致资源加载不全、白屏或运行态不一致。
**离线暂停更新策略(Offline Pause-and-Resume Update Strategy)** 的核心目标:在网络不可用时,**安全、有序地暂停所有"需要上云"的更新**,将其持久化到本地;在网络恢复后,**按可控节奏续传**这些更新,并对可能出现的冲突做可预测的处理。
—
## 2. 更新类型的分层
并非所有"更新"都应以同一种方式处理。建议按以下三层分类,分别制定暂停/恢复策略:
| 层级 | 更新类型 | 方向 | 离线处理 | 典型手段 | |——|———-|——|———-|———-| | L1 | 应用本体版本更新(SW / 资源包) | 云端 → 客户端 | 暂停激活,提示用户 | Service Worker 生命周期管理 | | L2 | 用户写操作(业务数据变更) | 客户端 → 云端 | 入队持久化,暂停提交 | 本地任务队列 + IndexedDB | | L3 | 增量数据拉取(配置、消息、列表) | 云端 → 客户端 | 暂停拉取,标记脏 | 增量游标 + 重连回源 |
> 本文重点覆盖 **L1(版本暂停)** 与 **L2(写操作队列化)**,这是离线策略中最关键、最易出错的两层。
—
## 3. 整体架构
``` ┌──────────────────────────────────────────────────────────────┐ │ 应用 UI 层 │ │ 用户操作 → 产生 Update 事件 → 调用 UpdateScheduler │ └───────────────┬──────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────┐ │ 网络状态感知 (NetworkMonitor) │ │ online / offline 事件 + 心跳探测(HTTP Ping / WebSocket) │ │ 对外暴露:isOnline、onChange、quality(弱网/正常) │ └───────────────┬──────────────────────────────────────────────┘ │ ┌───────┴────────┐ │ 在线 │ 离线 ▼ ▼ ┌──────────────┐ ┌────────────────────────────────────────────┐ │ 直接提交远端 │ │ UpdateQueue(持久化任务队列) │ │ (HTTP/Push) │ │ IndexedDB: {id, payload, meta, status} │ └──────────────┘ │ – enqueue: 写入 pending │ │ – pause: 状态机挂起 │ │ – resume: 按序 flush │ └────────────────────────────────────────────┘ │ 网络恢复时触发 ▼ ┌────────────────────────────────────────────┐ │ Flush Controller(续传编排器) │ │ – 并发度控制 / 限速 / 退避重试 │ │ – 冲突检测(版本号 / 时间戳 / CRDT) │ │ – 失败回滚与死信队列(DLQ) │ └────────────────────────────────────────────┘ ```
—
## 4. 关键策略详解
### 4.1 网络状态感知(不要只信 `navigator.onLine`)
`navigator.onLine` 与 `online/offline` 事件**不可靠**:它只反映"是否有网络接口",不反映"是否能真正访问业务后端"。必须叠加主动探测。
```ts class NetworkMonitor { private online = navigator.onLine; private listeners = new Set<(online: boolean) => void>(); private timer?: number;
constructor(private pingUrl = '/api/__health') { window.addEventListener('online', () => this.probe()); window.addEventListener('offline', () => this.set(false)); this.probe(); this.timer = window.setInterval(() => this.probe(), 15_000); }
get isOnline() { return this.online; }
onChange(fn: (online: boolean) => void) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
/** 用带超时的请求确认"真可达",而非仅看网卡 */ private async probe() { if (!navigator.onLine) return this.set(false); try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 3000); await fetch(this.pingUrl, { method: 'HEAD', cache: 'no-store', signal: ctrl.signal }); clearTimeout(t); this.set(true); } catch { this.set(false); } }
private set(v: boolean) { if (this.online === v) return; this.online = v; this.listeners.forEach(fn => fn(v)); } } ```
> 进阶:可用性 `Network Information API`(`navigator.connection.effectiveType`)识别弱网(2g/slow-2g),在弱网下同样进入"准离线"节流模式。
### 4.2 L1:应用版本更新的离线暂停
PWA 中 Service Worker 注册新版本时,默认会在页面下次加载时激活。若用户**离线时**SW 已经在后台安装完新版本,直接 `skipWaiting()` + `clients.claim()` 可能让页面在拿不到新资源的情况下运行,导致白屏。
**推荐策略:离线时暂停激活,联网后再提示刷新。**
```ts // sw.js let pendingActivation = false; self.addEventListener('install', (e) => { // 不立即 skipWaiting,等待控制器命令 self.skipWaiting(); // 仅当在线且用户确认时 }); self.addEventListener('message', (e) => { if (e.data === 'SKIP_WAITING') self.skipWaiting(); }); ```
```ts // 主线程:配合网络状态决定是否允许激活 const reg = await navigator.serviceWorker.ready; reg.addEventListener('updatefound', () => { const newWorker = reg.installing!; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { const hasUpdate = true; if (networkMonitor.isOnline) { // 在线:提示用户刷新以应用更新 showUpdateToast(() => newWorker.postMessage('SKIP_WAITING')); } else { // 离线:暂停更新,联网后再唤醒 networkMonitor.onChange((online) => { if (online) showUpdateToast(() => newWorker.postMessage('SKIP_WAITING')); }); } } }); }); ```
**要点**: – 离线时**不要**自动 `skipWaiting()`,避免运行态与资源版本不匹配。 – 联网后用 Toast/弹窗让用户主动触发刷新,保证更新发生在可预期时刻。
### 4.3 L2:写操作队列化(核心)
所有"需要上云"的用户更新,离线时不应直接 `fetch`。统一走 `UpdateScheduler`:
– 在线 → 直接提交; – 离线 → 写入 IndexedDB 队列,状态置 `pending`,UI 立即反馈"已保存(待同步)"。
```ts // 使用 idb-keyval 演示;生产建议用更完整的队列库或自研 import { set, get, del, keys } from 'idb-keyval';
interface UpdateTask { id: string; type: string; // 如 'order.update' payload: unknown; meta: { baseVersion?: number; createdAt: number; retry: number }; status: 'pending' | 'inflight' | 'done' | 'dead'; }
const QUEUE_PREFIX = 'uq:'; const enqueue = (task: UpdateTask) => set(QUEUE_PREFIX + task.id, task); const allPending = async () => (await keys()) .filter(k => String(k).startsWith(QUEUE_PREFIX)) .map(k => get(k)); ```
UI 侧反馈:
```ts async function submitUpdate(task: UpdateTask) { if (networkMonitor.isOnline) { try { await httpPost(task); markDone(task); } catch (e) { await enqueue(task); } // 临时失败也入队 } else { await enqueue(task); // 离线:仅落库 toast('已离线保存,联网后自动同步'); } } ```
### 4.4 L2:续传编排(Flush Controller)
网络恢复时,由编排器按**顺序 + 并发度 + 退避**进行续传,避免请求风暴:
```ts async function flush() { const tasks = (await allPending()) .filter(t => t.status === 'pending') .sort((a, b) => a.meta.createdAt – b.meta.createdAt); // FIFO
const CONCURRENCY = 3; for (let i = 0; i < tasks.length; i += CONCURRENCY) { const batch = tasks.slice(i, i + CONCURRENCY); await Promise.all(batch.map(sendOne)); if (!networkMonitor.isOnline) break; // 中途又断网,立刻暂停 } }
async function sendOne(task: UpdateTask) { task.status = 'inflight'; try { const res = await httpPost(task); if (res.status === 409) return resolveConflict(task, res); // 冲突 await markDone(task); } catch (e) { task.meta.retry++; if (task.meta.retry > 5) task.status = 'dead'; // 入死信队列 else await backoff(task); // 指数退避后重排 await set(QUEUE_PREFIX + task.id, task); } } ```
**暂停语义**:`flush` 中任何一次探测到 `isOnline === false`,立即 `break`,剩余任务保持 `pending`,等待下次 `online` 事件再触发 `flush()`。这就是"暂停—恢复"的闭环。
### 4.5 冲突解决策略
离线期间其他端可能已修改同一资源,续传时后端返回 `409 Conflict`。常见策略:
| 策略 | 适用场景 | 做法 | |——|———-|——| | 最后写入获胜(LWW) | 低协作、可接受覆盖 | 以 `updatedAt` 取最新,前端弹"已被他人修改"提示 | | 版本号校验(OCC) | 通用 | 提交带 `baseVersion`,不匹配则拒绝,前端拉新基线后合并 | | 操作变换(OT) | 协同编辑 | 将更新表达为操作,服务端重排 | | CRDT | 高并发无中心 | 数据结构天然可合并,无需冲突处理 |
> 推荐默认采用 **OCC(乐观并发控制)**:成本可控,且能把"暂停—恢复—冲突"三段逻辑解耦。
—
## 5. 状态机与边界情况
更新任务建议用显式状态机,避免"半提交"幽灵任务:
``` enqueue 在线提交失败 ┌───────────┐ ───────────▶ ┌──────────┐ │ pending │ │ pending │ └─────┬─────┘ ◀─────────── └────┬─────┘ │ 发送 │ 收到 409 ▼ ▼ ┌──────────┐ ┌─────────┐ ┌──────────┐ │ inflight │ │ done │ │ conflict │ ──▶ resolveConflict └────┬─────┘ └─────────┘ └──────────┘ │ 重试耗尽 ▼ ┌──────────┐ │ dead │ ──▶ 死信队列(人工/日志) └──────────┘ ```
**必处理的边界情况:** – **重连瞬间重复提交**:用任务 `id` 幂等键,后端做去重(同一 `id` 仅生效一次)。 – **页面关闭导致 inflight 丢失**:`inflight` 在重载后回退为 `pending` 重新发送(后端幂等兜底)。 – **队列无限膨胀**:设最大容量与 TTL,过期任务转 `dead` 并告警。 – **注销/切换账号**:清空队列或按 `userId` 隔离,防止串号串数据。
—
## 6. 可观测性
离线更新策略"看不见"就容易出问题,建议埋点:
– `queue_depth`:当前 pending 任务数(反映离线堆积压力) – `sync_latency`:从 enqueue 到 done 的时延 – `conflict_rate`:冲突占比,过高说明 OCC 粒度过粗 – `dead_letter_count`:死信数量,需告警 – `offline_duration`:单次离线时长分布
—
## 7. 最佳实践清单
1. **写操作一律走调度器**,不要在业务代码里散落 `fetch`,由 `UpdateScheduler` 统一决定直发还是入队。 2. **本地优先反馈**:离线保存即视为"已保存(待同步)",给确定感,不抛网络错误。 3. **版本更新离线暂停**:SW 不要在离线时自动激活,联网后由用户触发刷新。 4. **续传要有编排**:并发度、退避、幂等、断点暂停,缺一不可。 5. **冲突要显式处理**:默认 OCC,必要时升级到 CRDT;绝不静默覆盖用户数据。 6. **队列要可观测、可清理**:暴露深度与死信,设置容量上限与 TTL。 7. **存储选型**:队列持久化优先 **IndexedDB**(容量大、异步、事务安全),极小数据可用 `localStorage` 兜底。
—
## 8. 技术选型参考
| 能力 | 推荐 | 备注 | |——|——|——| | 本地持久化队列 | IndexedDB(idb / Dexie) | 异步、容量大、支持事务 | | 网络探测 | 自定义 + 心跳 | `navigator.onLine` 仅作初判 | | PWA 更新 | Workbox + 自定义 SW 消息 | 控制激活时机 | | 冲突合并 | 自研 OCC / Yjs(CRDT) | 协同场景用 Yjs | | 重试退避 | 自研或 p-retry | 指数退避 + 抖动 |
—
_文档结束。如需针对具体框架(React / Vue / Electron / Flutter Web)或后端协议(REST / GraphQL / WebSocket)补充落地示例,可在此基础上扩展对应章节。_
网硕互联帮助中心





评论前必须登录!
注册