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):对对象仓库中的某个字段建立索引,加速查询。
下面这张图帮你理清它们的关系:
事务与游标
IndexedDB 的事务分为三种模式:readonly、readwrite、versionchange。前两种好理解,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,配额要提前检查。这些细节,都是线上事故换来的教训。