IndexedDB 实战:封装一个支持版本迁移、事务与超时重试的本地存储层

举报
yd_246800664 发表于 2026/11/14 00:30:54 2026/11/14
【摘要】 用原生 IndexedDB 封装可靠本地存储层:覆盖 openDB 增量版本迁移、request 与事务 Promise 化、索引范围查询、事务自动提交这个最大坑、超时重试与配额处理,以及多标签页连接管理。

为什么 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 就足够撑起一个可靠的本地存储层,未必需要引入第三方库。

本文为原创实践记录,示例代码均可直接运行,欢迎交流更稳妥的离线存储与多标签页同步方案。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。