浏览器里做在线工具,早晚会碰到"用户处理到一半刷新了页面,东西全没了"这个问题。localStorage 存不下------它只能放字符串,容量通常 5MB 上下,一张手机照片就超了。于是就轮到 IndexedDB。
IndexedDB 的 API 长得比较劝退,网上教程大多停在"怎么开一个库、怎么 put 一条数据"。真正麻烦的不是这些,是下面这几件事。
一、它不是"存字符串"的,直接存 Blob 就行
第一个认知偏差:很多人下意识把文件转成 base64 再存。
js
// 别这么干
const reader = new FileReader()
reader.onload = () => store.put({ id, data: reader.result }) // data:image/png;base64,...
reader.readAsDataURL(file)
base64 会让体积涨大约 33%,而且编解码要占一遍内存。IndexedDB 用的是结构化克隆算法,Blob、File、ArrayBuffer、Map、Set、Date 都能直接存:
js
store.put({ id, file, createdAt: new Date() }) // file 是一个 File 对象,直接放
取出来还是 File,能直接 URL.createObjectURL() 用。
有一个例外要注意:Safari 在某些版本上存 Blob 有历史遗留问题 ,曾经需要转 ArrayBuffer 绕过。现在基本都修了,但如果你的用户里有比较老的 iOS,稳妥做法是存 ArrayBuffer 加一个 type 字段,读的时候自己拼回 Blob:
js
async function toRecord(file) {
return { buf: await file.arrayBuffer(), type: file.type, name: file.name }
}
function fromRecord(r) {
return new File([r.buf], r.name, { type: r.type })
}
代价是每次读写多一次内存拷贝。按你的用户构成决定要不要付这个代价。
二、事务会"自己结束",await 一个非 IDB 的 Promise 就废了
这是最容易写出诡异 bug 的地方。
IndexedDB 的事务是自动提交 的:当事件循环中没有待处理的 IDB 请求时,事务就结束了。所以下面这段几乎必然报 TransactionInactiveError:
js
const tx = db.transaction('files', 'readwrite')
const store = tx.objectStore('files')
const buf = await file.arrayBuffer() // ← 这里 await 了一个非 IDB 的 Promise
store.put({ id, buf }) // ← 事务已经结束了
file.arrayBuffer() 是一个普通 Promise,它 resolve 的时机在下一个宏任务,那时事务早就提交了。
正确写法是所有异步准备工作在开事务之前做完:
js
const buf = await file.arrayBuffer() // 先准备好数据
const tx = db.transaction('files', 'readwrite') // 再开事务
tx.objectStore('files').put({ id, buf })
await txDone(tx)
配一个把事务包成 Promise 的小工具:
js
function txDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(tx.error || new Error('transaction aborted'))
})
}
在一个事务里连续发多个 IDB 请求是安全的------因为每个请求都会"续上"事务:
js
const tx = db.transaction('files', 'readwrite')
const store = tx.objectStore('files')
for (const rec of records) store.put(rec) // 连续 put,没问题
await txDone(tx)
三、版本升级:onupgradeneeded 里不能做异步的事
建表、建索引只能在 onupgradeneeded 回调里做,而这个回调运行在一个特殊的 versionchange 事务里,规则和上面一样------不能 await 外部的 Promise。
js
function openDB(name, version) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version)
req.onupgradeneeded = (e) => {
const db = req.result
const old = e.oldVersion // 0 表示全新创建
if (old < 1) {
const store = db.createObjectStore('files', { keyPath: 'id' })
store.createIndex('byCreatedAt', 'createdAt')
}
if (old < 2) {
// 第二次迭代加的索引,注意用 req.transaction 拿到当前事务
const store = req.transaction.objectStore('files')
store.createIndex('byType', 'type')
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
req.onblocked = () => reject(new Error('another tab is holding an old version'))
})
}
两个细节:
- 按
oldVersion逐级升级 ,用if (old < n)而不是switch,这样从任意旧版本升上来都能补齐中间所有变更。 onblocked一定要处理。 用户开了两个标签页,老标签页还占着旧版本,新标签页的升级会一直挂着。正确做法是在旧标签页里监听db.onversionchange并主动关掉连接:
js
db.onversionchange = () => {
db.close()
// 提示用户:页面已在其他标签页更新,请刷新
}
不处理的话,用户的表现是"页面转圈不动",而且很难复现。
四、配额:你能存多少,答案是"不确定"
浏览器给的是一个动态配额,通常是磁盘剩余空间的某个比例,各家规则不一样,而且会随可用空间变化。能查,但只是估算:
js
const est = await navigator.storage.estimate()
console.log(est.usage, est.quota) // 字节;quota 只是当前估计值
更麻烦的是数据可能被清掉。默认的存储是 "best-effort",磁盘吃紧时浏览器会按 LRU 回收站点数据。想要更稳,可以申请持久化:
js
const persisted = await navigator.storage.persist() // 返回 true/false
这个申请不一定给。Chrome 的策略大致是看站点参与度(是否被安装为 PWA、是否有高互动、是否被加书签),Firefox 会弹窗问用户。不要假设它一定成功,代码里得能接受"数据没了"这件事。
超配额的报错是 QuotaExceededError,务必捕获并给用户一个明确提示,而不是静默失败:
js
try {
await putFile(rec)
} catch (e) {
if (e.name === 'QuotaExceededError') {
// 提示用户清理,或者只保留最近 N 条
} else throw e
}
五、隐私模式、多标签页、以及"根本用不了"
隐私/无痕模式下 IndexedDB 的行为各家不一。 有的给一个内存实现(关掉窗口就没),有的直接抛错。Safari 历史上在无痕模式里 indexedDB.open 会直接失败。
iOS 上还有个更特别的问题 :Safari 会在站点长时间不被访问后清理数据(曾经是 7 天)。所以把 IndexedDB 当"长期存储"是不成立的,它更合适的定位是一个大号的、能存二进制的缓存。
所以任何依赖它的功能,都得能优雅降级:
js
async function canUseIDB() {
if (!('indexedDB' in window)) return false
try {
const db = await openDB('__probe__', 1)
db.close()
indexedDB.deleteDatabase('__probe__')
return true
} catch { return false }
}
用不了的时候,退回到"处理完直接下载,不做草稿保存",比弹一个红色报错强。
六、封装:不用上 Dexie 也能写得干净
大部分场景不需要引入完整的 ORM。把请求包成 Promise,再加一个事务辅助函数,基本就够了:
js
function reqDone(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
async function withStore(db, name, mode, fn) {
const tx = db.transaction(name, mode)
const result = await fn(tx.objectStore(name)) // fn 内部只发 IDB 请求
await txDone(tx)
return result
}
// 用起来:
const rec = await withStore(db, 'files', 'readonly', s => reqDone(s.get(id)))
注意 fn 里面只能发 IDB 请求,一旦 await 了别的东西,还是第二条那个坑。
需要游标遍历时:
js
function eachCursor(req, onItem) {
return new Promise((resolve, reject) => {
req.onsuccess = () => {
const cur = req.result
if (!cur) return resolve()
onItem(cur.value)
cur.continue()
}
req.onerror = () => reject(req.error)
})
}
await withStore(db, 'files', 'readonly', s =>
eachCursor(s.index('byCreatedAt').openCursor(null, 'prev'), v => list.push(v))
)
真需要复杂查询、schema 迁移、跨表事务,再上 Dexie 或 idb 也不迟。idb 那个库本质上就是把上面这些包了一层,很薄。
七、说点它做不到的
它不是数据库,别指望复杂查询。 只有单字段索引和复合索引,没有 JOIN,没有聚合,没有全文检索。范围查询靠 IDBKeyRange,再复杂就得自己在内存里过滤------数据量大的时候这就是个性能问题。
它不能跨源共享。 同源策略同样适用,https://a.com 和 https://b.com 各存各的,子域名也算不同源。
它不适合放特别大的单条记录。 一条几百 MB 的 Blob,读出来就是几百 MB 的内存占用,移动端很容易直接崩。大文件应该切片存,用的时候按需取。
它不保证同步。 多标签页同时写同一条记录,IndexedDB 的事务能保证单次操作的原子性,但保证不了你的业务语义。需要跨标签页协调的话,用 BroadcastChannel 或者 Web Locks API 自己做。
性能不如你想的快。 每次事务都有固定开销,循环里开 1000 个事务写 1000 条,比开 1 个事务写 1000 条慢一两个数量级。批量操作一定要合并到同一个事务里。
最后
总结成几条能直接用的:
- 直接存
Blob/File/ArrayBuffer,别转 base64。 - 所有非 IDB 的异步操作放在开事务之前。
onupgradeneeded里按oldVersion逐级升级,处理onblocked和onversionchange。- 把它当缓存,不当数据库------随时可能被清掉。
- 批量写合并到一个事务。
我在 forxi.cn 上做那些图片、PDF 的在线处理时,用它来存用户的中间结果和最近处理过的文件,上面这些基本都撞过一遍。写出来的这套是收敛之后的通用做法,不涉及具体产品实现,拿去改改就能用。