用 IndexedDB 存用户的文件,我踩过的五个坑

浏览器里做在线工具,早晚会碰到"用户处理到一半刷新了页面,东西全没了"这个问题。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 条慢一两个数量级。批量操作一定要合并到同一个事务里。

最后

总结成几条能直接用的:

  1. 直接存 Blob/File/ArrayBuffer,别转 base64。
  2. 所有非 IDB 的异步操作放在开事务之前。
  3. onupgradeneeded 里按 oldVersion 逐级升级,处理 onblocked 和 onversionchange。
  4. 把它当缓存,不当数据库------随时可能被清掉。
  5. 批量写合并到一个事务。

我在 forxi.cn 上做那些图片、PDF 的在线处理时,用它来存用户的中间结果和最近处理过的文件,上面这些基本都撞过一遍。写出来的这套是收敛之后的通用做法,不涉及具体产品实现,拿去改改就能用。

相关推荐
独孤九剑打醒他6 小时前
【原创开源·修订版】源-栅-漏-栅-源横向双栅MOS:从“被误解的短路”到“电流路径多值逻辑与顶层供电架构”
前端·嵌入式硬件·架构·开源·硬件工程
创世虚拟世界7 小时前
我做了一个 3D 虚拟世界基底,基于这个再做 3D 虚拟世界或者元宇宙,会事半功倍
javascript·人工智能·3d·gitee·虚拟现实
阿狗童鞋7 小时前
React实战指南
前端·react.js·前端框架
v:ychya20187 小时前
独立站外贸建站公司怎么选?建站就要建营销型网站
前端·php
春涧草茶7 小时前
慢就是快12-12手动抛出异常
java·linux·前端
三小河7 小时前
从 Markdown 到 Generative UI:AI 如何从“生成答案”进化到“生成界面”?
前端·人工智能·后端
请为小H留灯7 小时前
Windows 本地前端项目一键运行指南:Volta+Node+pnpm + 阿里镜像完整初始化流程
前端·pnpm·node·js
J船长8 小时前
从 App 开发到 Unity 游戏开发:建立认知地图,结合 AI 开始实践
前端
Highcharts.js8 小时前
用Highcharts开发动态主从图表|双图联动的时间序列图表示例
javascript·数据库·信息可视化·highcharts·图表开发·动态图表