浏览器存储之 IndexedDB
当 localStorage 的 5MB 上限和同步阻塞成为瓶颈,就该用 IndexedDB。它异步、容量大、能存结构化对象与二进制,但 API 原始且坑多——真正把它用顺手,通常要包一层。
一、什么时候该换它
IndexedDB 是浏览器里唯一能存大量结构化数据的客户端存储:支持对象、数组、二进制(ArrayBuffer、Blob)、甚至文件,且读写是异步的,不会像 localStorage 那样阻塞主线程。容量按磁盘配额计算,不是固定的 5MB——浏览器根据磁盘余量动态给,比 localStorage 宽得多,但也不是无限:在隐私模式或磁盘压力下,浏览器可能驱逐数据。所以代码里要处理「配额不足 / 数据被清」的情况,不能假设它永远在。
容量按磁盘配额算且可能被驱逐,要处理配额不足
二、库、对象仓库与索引
四个概念从上到下是:数据库(按域名隔离)→ object store(类似表,存的是对象不是行)→ 索引(按某个字段加速查询)→ 事务(所有读写都必须在事务里)。和关系型数据库最大的不同是它没有固定 schema,存进去是什么结构就是什么结构,代价是查询能力弱——复杂条件要靠索引组合,做不到就只能在内存里筛。
// 建库与版本升级:结构变更只能在 upgradeneeded 里做
const req = indexedDB.open('uploads', 1)
req.onupgradeneeded = (e) => {
const db = e.target.result
if (!db.objectStoreNames.contains('chunks')) {
db.createObjectStore('chunks', { keyPath: 'id' })
}
}
所以它适合「按键取」和「范围扫描」这类操作,不适合当关系数据库用——没有 join、没有复杂的多表查询。
三、版本升级与事务
三个几乎人人踩过。
第一,结构变更只能在 upgradeneeded 里做,在别处改会报错。正确做法是把建 store、加索引都放进这个回调,版本号升了才触发。
第二,事务会在空闲时自动关闭:如果在事务中间 await 了一个非数据库操作(比如一次网络请求、一个 setTimeout),事务已经结束,后续在事务里操作会直接抛错。解决方法是把异步边界放到事务外,或在一个事务内一次性排好所有操作。
// 坑:事务中间 await 非 IDB 操作,事务已关闭,下面这行会抛错
const tx = db.transaction('chunks', 'readwrite')
const store = tx.objectStore('chunks')
await someNetworkCall() // 事务在此刻已自动提交并关闭
store.put({ id: 1, name: 'x' }) // ❌ 抛错:事务不可用
第三,原生 API 是事件回调风格且错误处理分散,实际项目里基本都会包一层 Promise 或用现成的轻量封装(如 idb)。封装后代码清晰很多:
import { openDB } from 'idb'
const db = await openDB('uploads', 1, {
upgrade(db) {
db.createObjectStore('chunks', { keyPath: 'id' })
},
})
await db.put('chunks', { id: 1, index: 0, uploadedAt: Date.now() })
const done = await db.getAll('chunks')
原生 API 心智负担重,业务里应当包一层再写
四、包一层再用
优先用轻量封装库把回调式 API 变成 async/await,代码可读性直接上一个台阶。但也不要「什么都上 IndexedDB」:如果只是存几个字符串配置(token、主题、用户 id),localStorage 更简单直接;只有数据量大、结构复杂、或需要离线队列时,IndexedDB 才值得。
判断标准很朴素:数据量小且只存字符串 → localStorage;需要结构化、大体积、离线可用 → IndexedDB。
能不能当持久存储用,取决于配额与逐出策略,可以直接问浏览器:
const { usage, quota } = await navigator.storage.estimate()
console.log('已用', (usage / 1024 / 1024).toFixed(1), 'MB / 配额', (quota / 1024 / 1024).toFixed(0), 'MB')
// persisted 为 false 时,存储压力下数据可能被清掉
if (!(await navigator.storage.persisted())) {
await navigator.storage.persist() // 需要用户已与站点有过交互
}
五、和缓存存储怎么分工
两者都存客户端,但职责不同。IndexedDB 存业务数据——离线队列、草稿、已上传分片记录这类结构化内容;Cache Storage 存请求与资源——HTTP 响应、静态资源、模型文件(配合 Service Worker 做离线)。把模型权重、大图片塞进 IndexedDB 也能存,但 Cache Storage 对「请求 → 响应」的语义更贴合,且能被 Service Worker 直接命中。别混用:请求缓存归 Cache Storage,业务状态归 IndexedDB。
一个存业务数据、一个存请求与资源,别混用
索引要在建库时一并声明,事后补索引等于再升一次版本:
const db = await openDB('shop', 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
const store = db.createObjectStore('orders', { keyPath: 'id' })
store.createIndex('by_time', 'createdAt') // 范围扫描靠它
store.createIndex('by_status', ['status', 'createdAt'])
}
if (oldVersion < 2) {
// 补索引必须在新版本里做,且要在 upgrade 事务内完成
transaction.objectStore('orders').createIndex('by_user', 'userId')
}
},
})
六、它不像 localStorage 那么简单
IndexedDB 当不了关系数据库——没有 join、没有复杂查询,它适合的是按键取与范围扫描。
容量也不是无限用:按磁盘配额计算,浏览器还会驱逐数据,配额不足的情况必须处理。
同步 API 更指望不上——规范里曾规划一套只在 Worker 中可用的同步接口,但从未被任何浏览器实现,后来直接从规范中移除。今天能用的只有异步 API。
判断标准很朴素:数据量小且只存字符串,localStorage 就够;需要结构化、大体积、离线可用,才轮到 IndexedDB。
事务的生命周期与自动提交规则见 MDN:IndexedDB API,这一节解释了为什么空闲后事务会失效。