用 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 用的是结构化克隆算法,BlobFileArrayBufferMapSetDate 都能直接存:

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.comhttps://b.com 各存各的,子域名也算不同源。

它不适合放特别大的单条记录。 一条几百 MB 的 Blob,读出来就是几百 MB 的内存占用,移动端很容易直接崩。大文件应该切片存,用的时候按需取。

它不保证同步。 多标签页同时写同一条记录,IndexedDB 的事务能保证单次操作的原子性,但保证不了你的业务语义。需要跨标签页协调的话,用 BroadcastChannel 或者 Web Locks API 自己做。

性能不如你想的快。 每次事务都有固定开销,循环里开 1000 个事务写 1000 条,比开 1 个事务写 1000 条慢一两个数量级。批量操作一定要合并到同一个事务里。

最后

总结成几条能直接用的:

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

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

相关推荐
闪耀之光M781 小时前
前端依赖自动导入:unplugin-auto-import
前端
Ricon组态薄荷糖1 小时前
Ricon组态系统:工业组件开发指南与实践
前端·后端·物联网
flash俊杰2 小时前
Electron 桌面应用的进程模型:为什么 fork Next.js standalone
前端
沙蒿同学2 小时前
我把架构约定编译成了会变红的测试:Wails v2 + Go + Vue3 桌面脚手架实战
前端·后端·github
cpolar技术支持2 小时前
本地 Playwright 测试报告怎么远程复盘?Trace Viewer 跑起来后,用 cpolar 分享失败现场
前端·自动化测试·测试工具·cpolar·playwright
wordbaby2 小时前
企业级后台管理系统路由设计与最佳实践指南
前端
胡志辉的博客2 小时前
【完全开源】IP 纯净度检测 可一键部署到自己的CF
前端·javascript·chrome·ip·chromium
Hilaku2 小时前
作为面试官,我最怕遇到什么样的候选人?
前端·javascript·程序员
TiDi3 小时前
Pinia优化重复请求
前端