用 IndexedDB 做离线优先缓存:事务、版本迁移与冲突处理
做离线优先的应用,数据层通常绕不开 IndexedDB。它容量比 localStorage 大得多,支持索引和事务,但 API 偏底层,几个关键点没想清楚,后面很容易改不动。
一、先把对象仓库和索引设计好
IndexedDB 是键值数据库,数据按对象仓库(object store)组织。设计时先想清楚查询方式,再决定主键和索引:
const req = indexedDB.open('notes', 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
const store = db.createObjectStore('notes', { keyPath: 'id' });
store.createIndex('by_updated', 'updatedAt');
store.createIndex('by_synced', 'synced');
};
主键尽量用客户端生成的 UUID,而不是自增数字。离线创建的数据如果等服务器分配 id,就没法在本地立即关联,体验会很差。
二、事务的生命周期要短
IndexedDB 的事务是自动提交的:一旦当前任务队列里没有新的请求,事务就结束。常见的错误是在事务里 await 一个网络请求,回来再写库,这时事务早就关闭了。
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
store.put(note);
await txDone(tx); // 只等本地事务
正确做法是把网络请求放在事务外面:先请求成功,再开一个新事务写入。事务里只做本地操作,才能保证它不会莫名其妙地失效。
三、版本迁移要可重复、可回退
数据库版本只能升不能降,onupgradeneeded 里要按 oldVersion 逐级迁移,而不是直接假设从 0 开始:
req.onupgradeneeded = (e) => {
const db = e.target.result;
const old = e.oldVersion;
if (old < 1) {
db.createObjectStore('notes', { keyPath: 'id' });
}
if (old < 2) {
const store = e.target.transaction.objectStore('notes');
store.createIndex('by_updated', 'updatedAt');
}
};
每一步迁移都要考虑已经存在的数据,尤其是新增必填字段时,最好在迁移里给老数据补默认值。
四、离线修改怎么和线上合并
最省心的模型是「最后写入者胜」,但它会悄悄丢数据。稍微稳妥一点的做法是给每条记录加三个字段:updatedAt、synced、deleted。
同步时先拉取服务端变更,再和本地记录比较 updatedAt:本地更新则保留本地并标记待推送,服务端更新则覆盖本地。删除不要直接删记录,而是标记 deleted,等同步完成后再清理,否则离线删除会在下次同步时被服务端的旧数据复活。
if (local.updatedAt > remote.updatedAt) {
queue.push(local);
} else {
await store.put(remote);
}
如果业务对冲突敏感,可以保留一份冲突副本让用户选择,而不是自动覆盖。
五、几个容易踩的坑
打开数据库要复用连接。每次操作都 open 一次会拖慢启动,也会在版本升级时卡住。用一个单例 Promise 缓存连接,并在 versionchange 事件里主动关闭。
不要在 Safari 隐私模式下假设可用。部分浏览器在隐私模式下会禁用或限制 IndexedDB,初始化失败时要降级到内存存储,而不是直接白屏。
大文件不要塞进同一条记录。图片、附件建议单独存一个仓库,主记录只存引用,避免一次读取把整个对象都载入内存。
批量写入要合并。几千条记录逐条 await 会很慢,放在同一个事务里批量提交,速度差别非常明显。
六、小结
IndexedDB 的难点不在 API,而在数据模型和同步策略。把主键、索引、版本迁移和冲突规则提前定清楚,离线能力才是可持续的;否则功能越加越多,最后会被事务和版本问题拖住。
- 点赞
- 收藏
- 关注作者
评论(0)