17、Web Storage 与 IndexedDB:LocalStorage/SessionStorage 实现、IndexedDB 架构、事务与游标、存储配额管理

聊到浏览器本地存储,很多人第一反应就是 localStorage。确实,它简单好用,几行代码就能存个用户偏好。但如果你做过稍微复杂点的离线应用,比如笔记工具、邮件客户端,你就会发现 localStorage 根本扛不住。这时候,IndexedDB 就登场了。

今天这一章,我带你从底层看看这两兄弟到底怎么工作的。先说简单的,再说复杂的。

LocalStorage 与 SessionStorage:同步的键值仓库

这两个 API 本质上是一样的,区别只有一个:生命周期。

  • localStorage:数据持久化存储,除非手动删除,否则永远在。
  • sessionStorage:数据只在当前会话有效。关掉标签页,数据就没了。

它们的实现其实非常朴素。在 WebKit 中,每个源(origin)对应一个独立的存储区域。底层用的是 SQLite 数据库,但对外暴露的是同步接口。你调用 setItem 时,WebKit 会直接往 SQLite 里写一条记录。

核心要点:Storage 的读写是同步阻塞的。这意味着如果你在主线程里存一个很大的字符串,UI 会卡住。我见过有人把整个 JSON 序列化后的配置对象(几百 KB)往 localStorage 里塞,结果页面切换时明显掉帧。

看看它的基本用法:

// 存储
localStorage.setItem('theme', 'dark');
localStorage.setItem('fontSize', '16');

// 读取
const theme = localStorage.getItem('theme');

// 删除
localStorage.removeItem('theme');

// 清空
localStorage.clear();

嗯,就这么简单。但简单背后有坑。比如 setItem 在存储空间不足时会抛出 QuotaExceededError。我曾经在一个项目中,用户上传图片前先存到 localStorage 做缓存预览,结果图片稍微大点就报错。后来才意识到,localStorage 的配额通常只有 5MB 左右。

注意:localStorage 只能存字符串。存对象时记得 JSON.stringify,取出来再 JSON.parse。这个步骤很多人会忘,导致取出来的是 "[object Object]"。

IndexedDB:真正的客户端数据库

如果说 localStorage 是个小抽屉,那 IndexedDB 就是个文件柜。它支持索引、事务、游标、大容量存储。说白了,它就是个跑在浏览器里的 NoSQL 数据库。

我第一次接触 IndexedDB 时觉得 API 太啰嗦了。打开数据库要监听事件,创建对象仓库要指定版本号,读写还要开事务。但用久了你会发现,这种设计是为了保证数据的一致性和并发安全。

架构概览

IndexedDB 的架构分三层:

  • 数据库(Database):每个源可以有多个数据库。数据库有版本号,升级时触发 onupgradeneeded 事件。
  • 对象仓库(Object Store):类似关系数据库的表。每个对象仓库可以存任意 JavaScript 对象。
  • 索引(Index):对对象仓库中的某个字段建立索引,加速查询。

下面这张图帮你理清它们的关系:

Database(数据库) Object Store(对象仓库) 例如:users、orders、products Index(索引) 记录 { id:1, name:"张三" } 记录 { id:2, name:"李四" } 记录 { id:3, name:"王五" }

事务与游标

IndexedDB 的事务分为三种模式:readonlyreadwriteversionchange。前两种好理解,versionchange 只在修改数据库结构时使用。

事务的特点是:要么全部成功,要么全部回滚。这在多步操作中特别重要。比如你要同时更新用户信息和订单状态,如果第二步失败,第一步的修改也会撤销。

游标(Cursor)则是用来遍历数据的。它不像 SQL 的 SELECT * 一次返回所有结果,而是一条一条地取。这在处理大量数据时能节省内存。

看个实际例子:

// 打开数据库
const request = indexedDB.open('MyAppDB', 2);

request.onupgradeneeded = function(event) {
  const db = event.target.result;
  
  // 创建对象仓库,指定主键
  if (!db.objectStoreNames.contains('users')) {
    const store = db.createObjectStore('users', { keyPath: 'id' });
    // 创建索引
    store.createIndex('nameIndex', 'name', { unique: false });
    store.createIndex('emailIndex', 'email', { unique: true });
  }
};

request.onsuccess = function(event) {
  const db = event.target.result;
  
  // 开启读写事务
  const transaction = db.transaction(['users'], 'readwrite');
  const store = transaction.objectStore('users');
  
  // 添加数据
  store.add({ id: 1, name: '张三', email: 'zhangsan@example.com' });
  store.add({ id: 2, name: '李四', email: 'lisi@example.com' });
  
  // 使用游标遍历
  const cursorRequest = store.openCursor();
  cursorRequest.onsuccess = function(e) {
    const cursor = e.target.result;
    if (cursor) {
      console.log('当前记录:', cursor.value);
      cursor.continue(); // 移动到下一条
    }
  };
  
  transaction.oncomplete = function() {
    console.log('事务完成');
  };
};

个人经验:我习惯在 onupgradeneeded 里做所有结构变更。因为版本号一旦提交,就不能回退。如果你要降版本,只能删库重建。所以升级脚本一定要写健壮,加个 try-catch 是基本操作。

游标的方向与范围

游标可以向前或向后遍历,也可以限定范围。比如只查名字以"张"开头的用户:

const range = IDBKeyRange.bound('张', '张\uFFFF');
const cursorRequest = store.openCursor(range, 'next');

cursorRequest.onsuccess = function(e) {
  const cursor = e.target.result;
  if (cursor) {
    console.log('匹配用户:', cursor.value.name);
    cursor.continue();
  }
};

这里 IDBKeyRange.bound 创建了一个范围,next 表示正向遍历。你也可以用 prev 反向遍历。

存储配额管理

这是个大话题。浏览器给每个源分配的存储空间是有限的。不同浏览器策略不同:

浏览器 默认配额 备注
Chrome 可用磁盘空间的 60% 每个源最多占 1/3
Firefox 2GB(桌面)/ 50% 可用空间(移动) 可配置
Safari 1GB(桌面)/ 500MB(移动) iOS 上更严格

在 WebKit 中,存储配额由 StorageManager 管理。你可以通过 navigator.storage.estimate() 查询当前使用情况:

if (navigator.storage && navigator.storage.estimate) {
  const estimate = await navigator.storage.estimate();
  console.log('已使用:', estimate.usage);
  console.log('总配额:', estimate.quota);
  console.log('剩余百分比:', ((estimate.quota - estimate.usage) / estimate.quota * 100).toFixed(2) + '%');
}

避坑指南:我曾经在一个离线地图项目中,把瓦片数据全存 IndexedDB,结果用户手机剩余空间不足时,浏览器直接清空了整个数据库。后来我加了个容量检测逻辑,存之前先算一下剩余空间,不够就提示用户清理。

另外,IndexedDB 的数据不会随用户清除浏览器缓存而消失。它属于"站点数据",需要在浏览器设置里单独清除。这一点很多用户不知道,导致他们清缓存后发现登录状态还在,以为出 bug 了。

总结一下

localStorage 适合存少量配置、主题、简单的用户偏好。IndexedDB 适合存结构化数据、离线缓存、大文件分片。选型时别纠结,看数据量和查询复杂度就行。

嗯,这一章的内容就到这里。记住:事务要开对模式,游标用完要 close,配额要提前检查。这些细节,都是线上事故换来的教训。


公众号:蓝海资料掘金营,微信 deep3321