IndexedDB实战:浏览器端离线存储与同步方案
上个月我们组接了个需求:给一个外勤巡检系统做离线支持。巡检员在信号差的工地拍照、填表单,数据先存本地,等有网了再同步上去。听起来不复杂对吧?localStorage 存一下不就完了?
我一开始也是这么想的。
这时候就不得不请出 IndexedDB 了。这东西 API 丑得让人想哭,但在浏览器端做离线存储,它几乎是唯一的正经选择。
离线数据模型设计:别偷懒,状态机是必须的
数据能存下来只是第一步。真正让我掉头发的是:怎么设计数据模型,才能在离线和在线之间无缝切换?
每条记录都要带同步状态
这个原则我们是踩了坑之后才确立的。一开始我们就存原始业务数据,等网络恢复了遍历一遍全量上传。结果发现:已经同步过的数据又传了一遍,同步失败的数据没有重试机制,用户改了已同步的数据不知道该怎么处理。
后来给每条记录加了 syncStatus 字段,整个世界清净了。
js
const record = {
id: crypto.randomUUID(), // 客户端生成 UUID,避免跟服务端 ID 冲突
title: '5号楼电梯年检', // 业务字段
photos: [], // 存的是 Blob 引用,实际图片存在单独的 object store
result: 'passed',
syncStatus: 'pending', // pending → syncing → synced / failed
syncAttempts: 0, // 重试次数,用于指数退避
lastSyncError: null, // 最近一次失败原因,方便排查
localUpdatedAt: Date.now(), // 本地最后修改时间
serverUpdatedAt: null // 服务端确认时间
}
这里有个容易忽略的细节:id 用 crypto.randomUUID() 在客户端生成,而不是等服务端返回自增 ID。原因是离线状态下你拿不到服务端 ID,如果用临时 ID 后续还得做一次 ID 映射,非常麻烦。
syncStatus 这个字段其实是个状态机:
markdown
创建/修改
↓
pending ──触发同步──→ syncing ──成功──→ synced
│ ↑
失败 │
↓ │
failed ──重试──→ syncing
为什么要用状态机而不是简单的布尔值 isSynced: true/false?因为布尔值无法表达"正在同步中"这个中间态。如果用户在同步过程中又改了数据,你需要知道当前这条记录是"正在传"还是"还没传"。布尔值做不到。我们早期用布尔值时出过一个 bug:同步请求还在飞,用户又改了表单,改完 isSynced 被设回 false,紧接着之前的请求返回成功又把它设成 true,导致用户的最新修改永远没同步上去。状态机彻底解决了这个问题。
同步策略:三种方案,各有各的坑
离线数据攒够了,网络恢复了,怎么把数据送上去?这里有三种常见思路。
方案二:Background Sync API
这是我个人觉得设计得最优雅的方案。通过 Service Worker 注册一个同步任务,浏览器会在"合适的时机"自动触发,哪怕用户关掉了页面。主线程只需要把数据写入 IndexedDB 然后注册一个 sync tag,剩下的交给 Service Worker 在后台完成:
js
// 主线程:保存数据并注册同步任务
async function saveAndSync(record) {
const db = await openDB('InspectionDB', 1)
await db.put('reports', record)
const registration = await navigator.serviceWorker.ready
await registration.sync.register('sync-reports')
}
// service-worker.js:监听 sync 事件,执行实际同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-reports') {
event.waitUntil(doSync()) // 浏览器会等 doSync() 完成
}
})
doSync() 的内部逻辑和方案一基本一样:从 IndexedDB 捞 pending 记录,逐条推送。
这个方案的坑在于兼容性 。截至 2026 年 3 月,Background Sync 基本只有 Chromium 系浏览器(Chrome、Edge)支持,Firefox 和 Safari 都没实现。如果你的用户群里有大量 iOS 用户,这个方案等于白搭。
我们的做法是把 Background Sync 当增强手段:支持就用,不支持就降级到方案一的 online 事件监听。
方案三:定时轮询 + 手动触发
最不"优雅"但最稳的方案。封装一个 SyncScheduler 类,每 30 秒检查一次有没有待同步记录,同时监听 online 事件做即时触发。核心就三件事:定时器轮询、网络恢复即时触发、明确离线时跳过。
js
class SyncScheduler {
constructor(db, interval = 30000) {
this.db = db
this.interval = interval
this.timer = null
}
start() {
this.timer = setInterval(() => this.trySync(), this.interval)
window.addEventListener('online', () => this.trySync())
}
async trySync() {
if (!navigator.onLine) return
const pending = await this.db.getAllFromIndex('reports', 'by_status', 'pending')
if (pending.length === 0) return
// 逐条同步,逻辑同方案一
}
}
这里 trySync 先检查 navigator.onLine 再查库,避免离线时做无意义的 IndexedDB 查询。stop() 方法用于页面卸载时清理定时器,防止内存泄漏。这个方案没什么花哨的技巧,但在生产环境里反而最让人放心,用户也喜欢看到一个"同步"按钮------给他们确定感。
三个方案我们最终是混着用的:Background Sync 做第一优先级,online 事件做第二优先级,定时轮询做兜底。手动同步按钮作为用户最后的"救命稻草"。
冲突处理:离线同步绕不过的硬骨头
两个巡检员同时离线编辑了同一条报告,回到有网的时候同步上去,服务端收到两个不同版本,听谁的?
策略一:Last Write Wins(最后写入胜出)
最简单------谁的时间戳新,就用谁的。服务端拿 incoming.localUpdatedAt 和已有记录比较,新的覆盖旧的,旧的直接拒绝。实现成本几乎为零。
js
function handleSync(incoming) {
const existing = db.findById(incoming.id)
if (!existing || incoming.localUpdatedAt > existing.localUpdatedAt) {
db.save(incoming)
return { status: 'accepted' }
}
return { status: 'rejected', reason: 'stale' }
}
问题也很明显:先提交的人的修改会被静默覆盖掉,他完全不知道自己的数据被人"踩"了。对于巡检系统这种场景,一条报告被覆盖可能意味着安全隐患被忽略。所以这个策略只适合数据覆盖后果不严重的场景,比如草稿自动保存。
策略二:服务端仲裁 + 冲突提示
同步的时候带上一个版本号。如果服务端发现版本号对不上,就拒绝写入,把冲突抛给客户端让用户自己决定。客户端在请求体里带上 expectedVersion(即"我修改时基于的版本号"),服务端比对后如果版本不匹配,就返回冲突状态和最新的服务端数据:
js
async function syncRecord(record) {
const res = await fetch('/api/reports/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...record, expectedVersion: record.version })
})
const result = await res.json()
if (result.conflict) {
await db.put('reports', {
...record, syncStatus: 'conflict', serverVersion: result.serverData
})
showConflictResolver(record, result.serverData) // 弹窗让用户对比选择
}
}
拿到冲突后,客户端把 syncStatus 设为 conflict,同时把服务端版本缓存到 serverVersion 字段。然后弹一个对比界面,左右两栏分别展示"我的版本"和"服务端版本",让用户逐字段选择保留哪个。用户选完后生成一个合并版本,带着新版本号重新提交。这套流程对用户有一定打扰,但对于巡检报告这类数据准确性要求高的场景,宁可多问一句也不能静默丢数据。
策略三:CRDT(无冲突数据类型)
CRDT 的思路是从数据结构层面消除冲突:每个客户端的操作都设计成可交换、可结合的,合并时不需要协调。举个最简单的例子------G-Counter(增长计数器)。假设要统计某个巡检点的检查次数,两个巡检员 A 和 B 各自离线期间分别检查了 3 次和 2 次:
css
A 的本地计数器: { A: 3, B: 0 }
B 的本地计数器: { A: 0, B: 2 }
合并规则:对每个节点取 max → { A: 3, B: 2 } → 总数 = 5
不管 A 和 B 的数据以什么顺序到达服务端,合并结果都是 5,不需要冲突处理。这对计数器、集合添加这类操作确实很优雅。
但问题在于,我们的巡检表单是"一个表单一堆字段"------检查结果、备注、整改意见、签名......这些字段是整体覆盖式更新,不是可累加的操作。你没法对"备注从'正常'改成'有裂缝'"和"备注从'正常'改成'需复检'"做 max 合并,因为文本字段没有偏序关系。要让 CRDT 处理这种任意字段的表单编辑,你得把每个字段拆成独立的 Last-Writer-Wins Register,再组合成一个 Map CRDT,实现复杂度直接起飞,而且最终效果和策略二的逐字段冲突对比差不多,还不如让用户自己选。
生产环境的架构拼图
跑了三个月之后整个离线同步系统的架构大致是这样的:
scss
┌──────────────────────────────────────────────────┐
│ 用户操作层 │
│ 表单提交 / 拍照上传 / 列表查看 │
└────────────────────┬─────────────────────────────┘
│
┌────────────────────▼─────────────────────────────┐
│ 离线数据管理层 │
│ CRUD API(idb) + 状态机管理 + 配额监控/清理 │
└────────────────────┬─────────────────────────────┘
│
┌────────────────────▼─────────────────────────────┐
│ IndexedDB │
│ reports 表 + attachments 表 │
└────────────────────┬─────────────────────────────┘
│
┌────────────────────▼─────────────────────────────┐
│ 同步调度层 │
│ Background Sync → online 事件 → 定时轮询 │
│ 并发控制(3路) + 指数退避重试 │
└────────────────────┬─────────────────────────────┘
│
┌──────▼──────┐
│ 服务端 API │
│ 版本号校验 │
│ 冲突检测 │
└─────────────┘
跑了三个月,稳定服务了 200 多个巡检员的日常使用。期间收集到的数据:| 指标 | 数值 | |------|------| | 单用户日均离线记录 | 1530 条 | | 平均单条记录大小(含图片引用) | 28KB(图片 Blob 另算) | | 单用户 IndexedDB 平均占用 | 45MB | | 同步成功率(首次尝试) | 94.7% | | 重试后最终同步成功率 | 99.6% | | 冲突发生率 | 0.3%(大部分是同一巡检点被两人同时检查) | | 清理后平均释放空间 | 每周约 12MB/用户 |
剩下 0.4% 同步始终失败的,基本都是网络极端不稳导致的超时,最后靠用户手动点同步按钮解决。
如果你也在做类似的离线功能,附一下我们实际的迭代过程供参考:
| 阶段 | 时间 | 方案 | 触发升级的事件 |
|---|---|---|---|
| V1 | 第1~2周 | idb + online 事件 + Last Write Wins |
能跑通基本流程 |
| V2 | 第3周 | 加入定时轮询兜底 | 发现工地 WiFi 频繁假在线,online 事件不触发同步 |
| V3 | 第5周 | 加入版本号冲突检测 | 两个巡检员覆盖了同一条报告,甲方投诉数据丢失 |
| V4 | 第7周 | 加入 Background Sync + 降级策略 | 用户反馈关掉页面后数据没同步,第二天才发现 |
| V5 | 第9周 | 加入配额监控和自动清理 | 一个巡检员的手机浏览器 IndexedDB 爆了 |
总共花了大约两个月从最简单的 V1 演进到当前的混合方案。每次升级都是被线上问题推着走的,没有一次是"提前设计"出来的。