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