1. 离线优先思维模型:数据本地优先、弱网可用、最终一致

离线优先(Offline-First)不是简单的“在手机上缓存几个页面”,而是一套从数据写入、读取、同步到冲突解决的完整思维模型。传统应用假定网络永远可达,用户操作直接请求远端服务器;离线优先则反转这个假设:本地设备是主要数据载体,网络是可选同步通道。

本章定义三个核心原则,构成离线优先的思维基座:

原则要解决的问题实现思路
数据本地优先每次写入/读取都先操作本地存储,不依赖网络往返IndexedDB / SQLite / 本地文件 + 统一数据层
弱网可用网络慢或断开时应用仍可读写,不阻塞、不报错离线缓存 + 乐观更新 + 重试队列
最终一致允许短期数据不一致,网络恢复后自动收敛版本向量 / 时间戳 + 冲突合并策略

原理:从“请求-响应”到“持久化-同步”

离线优先应用的数据流不再是一条单向直线,而是一个闭合回路:

  1. 用户操作 → 直接写入本地数据库 → 界面立即更新(乐观渲染)
  2. 变更记录被推入同步队列,同时监听网络状态
  3. 网络可用时,同步引擎将队列中的变更按序发送到远端服务
  4. 远端处理变更后返回确认,本地标记该变更已同步
  5. 如果远端有冲突,应用拉取最新版本并在本地合并

整个过程对用户透明:即使同步失败,用户看到的始终是本地最新快照。

关键权衡: 最终一致放弃了强一致性换来了 100% 的可用性。如果你的业务需要实时串行化(如银行转账),必须在同步层加入冲突检测或回滚机制。

实现流程:一个最简离线优先 Demo

下面用纯浏览器 API 演示离线优先的最小闭环。假设我们有一个任务列表应用,所有数据保存在 IndexedDB,网络恢复后自动与远端同步。

1. 初始化本地数据库

// 打开 IndexedDB,版本 1
const DB_NAME = 'offline-tasks';
const DB_VERSION = 1;

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = (ev) => {
      const db = ev.target.result;
      if (!db.objectStoreNames.contains('tasks')) {
        const store = db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
        store.createIndex('synced', 'synced', { unique: false });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

2. 写入本地,并标记为“待同步”

async function createTaskLocally(title) {
  const db = await openDB();
  const tx = db.transaction('tasks', 'readwrite');
  const store = tx.objectStore('tasks');
  const record = { title, synced: false, createdAt: Date.now() };
  const id = await new Promise((resolve, reject) => {
    const req = store.add(record);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
  // 推入同步队列
  enqueueSync(id);
  return id;
}

3. 同步队列 & 发送请求

const pendingSyncs = new Set();

function enqueueSync(localId) {
  pendingSyncs.add(localId);
  scheduleSync();
}

async function flushSyncQueue() {
  if (pendingSyncs.size === 0) return;
  const db = await openDB();
  const tx = db.transaction('tasks', 'readonly');
  const store = tx.objectStore('tasks');
  for (const id of pendingSyncs) {
    const record = await store.get(id);
    if (!record || record.synced) continue;
    try {
      const resp = await fetch('/api/tasks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: record.title })
      });
      const serverRecord = await resp.json();
      // 标记本地记录已同步,并保存服务器 ID
      const updateTx = db.transaction('tasks', 'readwrite');
      const updateStore = updateTx.objectStore('tasks');
      updateStore.put({ ...record, synced: true, serverId: serverRecord.id });
      pendingSyncs.delete(id);
    } catch (err) {
      console.warn('sync failed for', id, err);
      // 不删除,等待下一次重试
    }
  }
}

function scheduleSync() {
  if (navigator.onLine) {
    setTimeout(flushSyncQueue, 100);
  }
}

window.addEventListener('online', scheduleSync);
window.addEventListener('offline', () => console.log('offline mode'));

4. 读取时始终从本地读取

async function getTasks() {
  const db = await openDB();
  const tx = db.transaction('tasks', 'readonly');
  const store = tx.objectStore('tasks');
  const all = await new Promise((resolve, reject) => {
    const req = store.getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
  return all.sort((a,b) => a.createdAt - b.createdAt);
}

调试:利用浏览器开发者工具

离线应用调试重点在 Application(应用) 面板:

  • IndexedDB — 查看本地数据是否正确写入,synced 字段是否更新。
  • Cache Storage — 如果使用 Service Worker 做请求缓存,在此检查缓存内容。
  • Service Workers — 启用“Offline”复选框模拟离线,观察页面是否正常渲染本地数据。
提示: 在 Chrome DevTools 中按 Ctrl+Shift+I 打开 Application 面板,可以手动修改 IndexedDB 数据来测试冲突场景(例如强行把 synced 设为 false)。

部署注意事项

无需特殊服务器配置,但若使用 Service Worker 进行缓存拦截,必须启用 HTTPS(或 localhost)。对于纯 IndexedDB 方案,HTTPS 非强制。部署时建议单独打包离线资源(HTML/CSS/JS)并与主应用分离。

# 示例 Nginx 配置:确保 index.html 可被 Service Worker 注册
server {
    listen 443 ssl;
    server_name example.com;
    root /var/www/offline-app;
    location / {
        try_files $uri $uri/ /index.html;
    }
}

常见错误 & 性能优化

错误/问题原因解决方案
离线写入后数据丢失IndexedDB 事务未提交或浏览器清理确认 tx.commit() 或等待 oncomplete;使用持久存储 API
同步时重复创建记录没有防重放机制(幂等性)本地记录携带 clientId,服务端去重
弱网下请求超时卡死fetch 默认无超时添加 AbortController + 超时逻辑
冲突导致数据被覆盖未实现冲突检测使用最后写入者获胜(LWW)或版本向量合并
陷阱: 不要在同步成功之前删除本地记录。如果服务端写入成功但客户端断开连接,记录会永久消失。正确做法:先标记 synced,再通过垃圾回收清理已确认同步的旧记录。

性能优化建议:

  • 批量同步:将多个待同步记录合并为一次 HTTP 请求,减少连接开销。
  • 指数退避:同步失败后等待 1s、2s、4s… 避免无意义重试。
  • 乐观更新:写入本地后立即返回,不等待同步完成,显著提升感知速度。

离线优先架构图

下图展示了一个典型离线优先应用的核心组件与数据流向。所有用户操作首先命中本地存储;同步引擎作为桥梁,在网络可用时与远端交换变更;最终通过冲突合并实现收敛。

离线优先应用架构 客户端应用 (浏览器 / 移动端) 本地数据库 IndexedDB / SQLite 同步引擎 队列 + 重试 网络 HTTP / WebSocket 远程服务器 REST / GraphQL 读写本地 弱网 回退 拉取变更 推送 / 拉取 请求 响应/冲突 数据流向说明 1. 客户端只读写本地数据库,界面即时更新(乐观)。 2. 同步引擎将待同步记录推送到网络,网络可用时发送到远端。 3. 远端响应后标记本地记录已同步;若冲突则合并后写回本地。