IndexedDB 实战:封装一个支持版本迁移、事务与超时重试的本地存储层
为什么 localStorage 不够用
前端做本地缓存,第一反应通常是 localStorage.setItem(key, JSON.stringify(data))。它在几 KB 的配置项上很好用,但一旦数据量上来就会撞到三堵墙:容量(一般 5MB 左右,且各浏览器口径不一)、同步阻塞(读写都在主线程,大对象序列化会卡住渲染)、以及没有结构化查询(只能全量取出再在内存里过滤,无法按索引范围检索)。
IndexedDB 是浏览器内置的事务型键值库:容量按磁盘配额给、异步不阻塞主线程、支持索引与游标、所有操作都在事务里原子提交。代价是 API 偏底层、全是事件回调。本文用原生 API 封装一层可用的存储层,重点讲版本迁移、事务边界与超时重试这几个最容易出错的地方。
一、打开数据库与增量版本迁移
indexedDB.open(name, version) 返回的是一个请求对象。唯一能修改表结构(建 store、建索引)的时机是 onupgradeneeded,它只在首次建库或版本号提升时触发。关键是要按 oldVersion 写增量迁移,而不是删库重建——线上用户的数据必须保留。
function openDB(name, version) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version);
// 只有版本号变大或首次建库时触发,是唯一能改 schema 的地方
req.onupgradeneeded = (e) => {
const db = req.result;
const oldVer = e.oldVersion;
if (oldVer < 1) {
const store = db.createObjectStore('notes', { keyPath: 'id' });
store.createIndex('by_updated', 'updatedAt');
}
if (oldVer < 2) {
// 增量迁移:不要删库重建,已有数据要保留
const store = req.transaction.objectStore('notes');
store.createIndex('by_tag', 'tag', { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
// 被其他标签页的升级阻塞时触发,别静默吞掉
req.onblocked = () => reject(new Error('数据库升级被其他标签页阻塞'));
});
}
三个细节:版本号只能递增,传一个比当前小的值会直接抛 VersionError;onupgradeneeded 里要用 req.transaction.objectStore() 拿 store,而不是另开事务;onblocked 表示别的标签页还开着旧版本连接,必须显式处理,否则升级会一直挂着,界面看起来像卡死。
二、把 request 包装成 Promise
原生 API 全部基于 onsuccess / onerror,嵌套几层就没法读了。先做两个最小的包装:一个把单个 request 转 Promise,另一个等待整个事务提交。
function reqToPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// 事务完成才算真正落盘,只等 request.onsuccess 是不够的
function txDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error || new Error('事务被中止'));
tx.onerror = () => reject(tx.error);
});
}
这里是最容易被忽略的一点:request.onsuccess 不代表数据已经落盘。事务要等 oncomplete 才真正提交,中途任何一步失败都会 onabort 回滚整个事务。只等 request 就返回结果,写操作可能随后被回滚,出现「界面显示成功但刷新后没了」的诡异问题。所以写操作要 await txDone(tx)。
三、增删改查与索引范围查询
有了包装之后,CRUD 就清晰了。put 同时承担新增与覆盖更新,getAll 配合索引和 IDBKeyRange 可以做范围检索,避免全表扫描。
async function putNote(db, note) {
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const done = txDone(tx);
store.put({ ...note, updatedAt: Date.now() });
await done; // 等事务提交,而不是等 put 的 request
}
async function getNote(db, id) {
const tx = db.transaction('notes', 'readonly');
const v = await reqToPromise(tx.objectStore('notes').get(id));
return v;
}
async function listByTag(db, tag) {
const tx = db.transaction('notes', 'readonly');
const idx = tx.objectStore('notes').index('by_tag');
// IDBKeyRange 支持 only / bound / lowerBound 等范围查询
return reqToPromise(idx.getAll(IDBKeyRange.only(tag)));
}
注意 db.transaction() 的第一个参数可以是数组,一次性声明多个 store;同一事务内跨 store 的操作同样具备原子性。索引要在 onupgradeneeded 里预先建好,运行时是加不了的。unique: false 允许重复值,主键默认由 keyPath 指定,也可以用 autoIncrement: true 让浏览器生成。
四、最大的坑:事务会自动提交
IndexedDB 的事务只在同一个任务循环内保持活跃:一旦当前宏任务里没有待处理的 IDB request,事务就自动提交。这意味着不能在事务中间 await 一个 fetch、定时器或其他非 IDB 的异步操作,之后再回头写库——那时事务已经关闭,会抛 TransactionInactiveError。
// 错误示范:事务里 await 一个非 IndexedDB 的异步操作
async function bad(db) {
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const row = await reqToPromise(store.get(1));
await fetch('/audit', { method: 'POST' }); // 事务在这里已经自动提交了
store.put(row); // TransactionInactiveError
}
// 正确:先把需要的异步数据准备好,再开事务,事务内只做 IDB 操作
async function good(db) {
const res = await fetch('/audit', { method: 'POST' });
const tx = db.transaction('notes', 'readwrite');
const done = txDone(tx);
const row = await reqToPromise(tx.objectStore('notes').get(1));
tx.objectStore('notes').put({ ...row, audited: true });
await done;
}
正确姿势是「先异步、后事务」:把网络请求、计算等耗时逻辑全部放在开事务之前,事务体内只连续发起 IDB 操作。异步迭代游标时也要注意,cursor.continue() 之后的回调里事务仍然有效,但中间插一个 await fetch() 就断了。
五、超时、重试与配额
生产环境还得兜住三类异常:操作挂起不返回、临时性失败、以及配额耗尽。用 Promise.race 加超时,区分错误类型决定是否重试。
function withTimeout(p, ms) {
return Promise.race([
p,
new Promise((_, rej) =>
setTimeout(() => rej(new Error('操作超时')), ms))
]);
}
async function safePut(db, note, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
await withTimeout(putNote(db, note), 3000);
return;
} catch (err) {
// QuotaExceededError 重试没意义,直接上报
if (err.name === 'QuotaExceededError') throw err;
if (i === retries) throw err;
await new Promise(r => setTimeout(r, 200 * (i + 1)));
}
}
}
QuotaExceededError 重试没有意义,应直接提示用户清理或走 navigator.storage.persist() 申请持久化存储;TransactionInactiveError 说明代码结构有问题,重试只会重复失败;只有偶发的 AbortError / 暂时性锁冲突才值得退避重试。另外别忘了 Safari 隐私模式下 IndexedDB 可能整体不可用,封装层最好在最外层做一次能力探测并降级到内存 Map。
六、多标签页与连接生命周期
最后一个实战要点是连接管理。多个标签页同时打开时,某页升级版本会触发其他页的 versionchange;如果不主动 close(),升级就会被阻塞。页面卸载时也该关掉连接。
const db = await openDB('my-app', 2);
// 多标签页同时升级时,其他页会收到 versionchange
db.onversionchange = () => {
db.close(); // 立即关闭,别阻塞别人的升级
location.reload();
};
window.addEventListener('pagehide', () => db.close());
// 存储配额估算(Chromium)
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate();
console.log('已用', usage, '配额', quota);
}
把这几段合起来,就是一个约百行、零依赖、可用于缓存接口数据、离线草稿、大列表本地检索的存储层。其余常见坑集中列一下:
- 别存不可结构化克隆的值:函数、DOM 节点、
class实例的方法会丢,Date/Map/Blob可以; - 版本号只增不减,且每次 schema 变更都要写对应的
oldVersion分支; - 写操作等
oncomplete,不要只等onsuccess; - 事务内不 await 非 IDB 异步,否则
TransactionInactiveError; - 索引只能升级时建,查询条件要提前规划;
- 大批量写入要分批,单个巨型事务会长时间占用并抬高内存;
- 处理
onblocked与onversionchange,多标签页场景否则会互相卡死; - 做好降级:隐私模式或配额为 0 时回退到内存,别让整个页面崩掉。
小结
IndexedDB 的难点从来不是增删改查的写法,而是事务边界与生命周期管理:什么时候算真正提交、事务在哪一刻失效、版本升级时其他标签页怎么办。把 request 包成 Promise、把等提交和等 request 分开、坚持增量迁移、按错误类型决定是否重试,这四点做到之后,原生 API 就足够撑起一个可靠的本地存储层,未必需要引入第三方库。
本文为原创实践记录,示例代码均可直接运行,欢迎交流更稳妥的离线存储与多标签页同步方案。
- 点赞
- 收藏
- 关注作者
评论(0)