1. 离线优先思维模型:数据本地优先、弱网可用、最终一致
离线优先(Offline-First)不是简单的“在手机上缓存几个页面”,而是一套从数据写入、读取、同步到冲突解决的完整思维模型。传统应用假定网络永远可达,用户操作直接请求远端服务器;离线优先则反转这个假设:本地设备是主要数据载体,网络是可选同步通道。
本章定义三个核心原则,构成离线优先的思维基座:
| 原则 | 要解决的问题 | 实现思路 |
|---|---|---|
| 数据本地优先 | 每次写入/读取都先操作本地存储,不依赖网络往返 | IndexedDB / SQLite / 本地文件 + 统一数据层 |
| 弱网可用 | 网络慢或断开时应用仍可读写,不阻塞、不报错 | 离线缓存 + 乐观更新 + 重试队列 |
| 最终一致 | 允许短期数据不一致,网络恢复后自动收敛 | 版本向量 / 时间戳 + 冲突合并策略 |
原理:从“请求-响应”到“持久化-同步”
离线优先应用的数据流不再是一条单向直线,而是一个闭合回路:
- 用户操作 → 直接写入本地数据库 → 界面立即更新(乐观渲染)
- 变更记录被推入同步队列,同时监听网络状态
- 网络可用时,同步引擎将队列中的变更按序发送到远端服务
- 远端处理变更后返回确认,本地标记该变更已同步
- 如果远端有冲突,应用拉取最新版本并在本地合并
整个过程对用户透明:即使同步失败,用户看到的始终是本地最新快照。
关键权衡: 最终一致放弃了强一致性换来了 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… 避免无意义重试。
- 乐观更新:写入本地后立即返回,不等待同步完成,显著提升感知速度。
离线优先架构图
下图展示了一个典型离线优先应用的核心组件与数据流向。所有用户操作首先命中本地存储;同步引擎作为桥梁,在网络可用时与远端交换变更;最终通过冲突合并实现收敛。