OPFS 源私有文件系统:浏览器里跑 SQLite 之前要搞懂的存储模型

手上这个离线优先的文档编辑器要做本地持久化,我最先想到的还是 IndexedDB——毕竟这些年但凡浏览器端要存点结构化数据,几乎没有别的选项。但真动手写的时候不太顺手。编辑器里一段文档可能被拆成几百个小的操作日志条目,用户敲字的过程中要频繁往库里写,IndexedDB 的事务模型决定了每次写入都要经过一整套异步流程:开事务、拿 object store、发起 put,然后等 onsuccess 或者包一层 Promise 去 await。单次操作看起来不重,但连续密集写入的时候,光是事务调度的开销就能在 Chrome 的 Performance 面板里看到一截一截的间隙。

更别扭的是它的编程模型本身。IndexedDB 的原生 API 是基于事件的,不是 Promise 原生支持,写一个稍微复杂点的读写流程,经常要么引入 idb 这类封装库,要么自己包一层 Promise 化的样板代码。而且它本质上是一个键值/对象存储,不是文件系统——如果我想模拟"打开一个文件、在中间某个偏移量写入一段字节、再读出来"这种操作,IndexedDB 完全没有对应的操作方式,只能自己在对象里维护偏移量和分片逻辑,等于在数据库上面又搭了一层文件抽象。

上个月我在看 SQLite 官方的 Wasm 构建文档时才注意到,这套东西现在有专门给它用的存储后端了——Origin Private File System,简称 OPFS。它不是全新的 API,规范定型是这两年的事,但今年浏览器端跑 SQLite、跑本地向量索引这类需求变多之后,它才算真正进入我的视野。花了几天把编辑器的持久层从 IndexedDB 挪过去做了个对照,记录一下这中间的模型差异和几个容易踩的地方。

OPFS 是什么样的存储

OPFS 的全称是 Origin Private File System,名字已经把它的范围说清楚了:这是一块属于当前源(origin)私有的文件系统空间,规范定义在 File System Access API 里,但它跟"用户主动选一个本地文件夹给网页读写"的那套弹窗式 API 是两回事。普通的 showDirectoryPicker() 需要用户点确认、指向的是用户磁盘上真实可见的目录;OPFS 完全不经过这套用户交互,它是浏览器在自己的存储分区里为每个源单独划出来的一块空间,用户在系统文件管理器里根本看不到这些文件,也没有任何弹窗打断。

这个权限模型跟 IndexedDB、Cache Storage 是同一挂的——都归到同源存储(origin storage)名下,受同样的配额和清除策略约束,跨源互不可见,用户清浏览器数据的时候,OPFS 里的文件会跟着 IndexedDB 一起被清掉。区别在于访问方式:OPFS 暴露的是一套接近真实文件系统的层级结构,有目录、有文件句柄,可以按路径创建文件、创建子目录、移动、删除,读写方式也更贴近"打开文件、定位、读写字节"这种传统文件操作,而不是 IndexedDB 那种整条记录存取的对象存储。

拿到根目录句柄是所有操作的起点:

1async function getRoot() {
2  const root = await navigator.storage.getDirectory()
3  return root
4}

这一步不需要用户授权,任何跑在该源下的脚本(包括 Worker)都能直接拿到根目录句柄,因为它访问的从来不是用户磁盘。有了根目录句柄,创建文件和子目录都是常规的异步操作:

1async function ensureDocFile(docId) {
2  const root = await navigator.storage.getDirectory()
3  const docsDir = await root.getDirectoryHandle('docs', { create: true })
4  const fileHandle = await docsDir.getFileHandle(`${docId}.bin`, { create: true })
5  return fileHandle
6}

读文件走 getFile() 拿到一个标准的 File 对象,可以直接用 text()arrayBuffer() 这些熟悉的方法:

1async function readDoc(docId) {
2  const fileHandle = await ensureDocFile(docId)
3  const file = await fileHandle.getFile()
4  return new Uint8Array(await file.arrayBuffer())
5}

写文件要先拿一个可写流,这套 API 跟主线程上操作 File System Access 的普通文件是共用的:

1async function writeDocAsync(docId, bytes) {
2  const fileHandle = await ensureDocFile(docId)
3  const writable = await fileHandle.createWritable()
4  await writable.write(bytes)
5  await writable.close()
6}

这套异步接口已经比 IndexedDB 更贴近"文件"这个概念了,但真正让我决定把编辑器持久层迁过来的,是另一套专门为高频读写设计的接口。

同步访问句柄:为什么必须在 Worker 里

createSyncAccessHandle() 是 OPFS 里最特别的一块,它拿到的句柄支持完全同步的 readwritetruncateflush,调用之后立刻返回结果,不需要 await,也不产生 Promise 排队的开销:

1// worker.js
2async function openSyncHandle(fileHandle) {
3  const accessHandle = await fileHandle.createSyncAccessHandle()
4  return accessHandle
5}
6
7function writeChunk(accessHandle, offset, bytes) {
8  const written = accessHandle.write(bytes, { at: offset })
9  return written
10}

这套同步句柄只能在 Worker(包括专用 Worker 和 Service Worker)里创建,主线程调用会直接抛异常。原因不难理解:主线程如果允许同步的磁盘 I/O,一旦底层存储稍微慢一点,就会整页面卡死——用户点一下按钮,界面直接冻住,这是浏览器绝对不能接受的事。放到 Worker 里,就算某次 write 调用真的花了几毫秒去落盘,挡住的也只是 Worker 自己的执行线程,主线程照样能响应交互、更新界面。

同步句柄快在哪,具体到实现层面是省掉了两层开销。第一层是 Promise 调度本身的成本,异步 API 每次调用都要经过微任务队列,密集写入场景下这个开销会累积得很明显;第二层是同步句柄允许浏览器把文件在打开期间做独占锁定,跳过普通文件操作里为了兼容并发访问而做的额外检查和锁竞争,直接对接底层的文件描述符做读写,这也是为什么它常被拿来当作 SQLite 这类需要频繁随机读写的引擎的存储后端——数据库文件本身就是一个不断被打开、定位、读写小块字节的场景,跟同步句柄的操作模型天然契合。

用完记得 close(),句柄不关闭会一直占着独占锁,同源下其他想打开同一个文件的代码会拿不到句柄,直接抛错:

1async function withSyncHandle(fileHandle, task) {
2  const handle = await fileHandle.createSyncAccessHandle()
3  try {
4    return task(handle)
5  } finally {
6    handle.close()
7  }
8}

落地场景一:浏览器里跑真正的 SQLite

编辑器要支持全文检索和历史版本查询,用一堆自定义索引结构去模拟关系查询太累,我索性试了试官方的 SQLite Wasm 构建。它自带一个基于 OPFS 的 VFS(虚拟文件系统层),跑在 Worker 里,数据库文件就是 OPFS 里的一个普通文件,跟本地跑 SQLite 打开一个 .db 文件几乎是同一套操作路数:

1// db-worker.js
2import sqlite3InitModule from '@sqlite.org/sqlite-wasm'
3
4let db
5
6async function initDb() {
7  const sqlite3 = await sqlite3InitModule()
8  // 'opfs' VFS 要求当前上下文支持 SyncAccessHandle,必须在 Worker 里初始化
9  db = new sqlite3.oo1.OpfsDb('/editor/history.sqlite3')
10  db.exec(`
11    CREATE TABLE IF NOT EXISTS revisions (
12      id INTEGER PRIMARY KEY AUTOINCREMENT,
13      doc_id TEXT NOT NULL,
14      content BLOB NOT NULL,
15      created_at INTEGER NOT NULL
16    )
17  `)
18}
19
20function saveRevision(docId, content) {
21  db.exec({
22    sql: 'INSERT INTO revisions (doc_id, content, created_at) VALUES (?, ?, ?)',
23    bind: [docId, content, Date.now()],
24  })
25}
26
27function listRevisions(docId) {
28  const rows = []
29  db.exec({
30    sql: 'SELECT id, created_at FROM revisions WHERE doc_id = ? ORDER BY created_at DESC',
31    bind: [docId],
32    rowMode: 'object',
33    callback: (row) => rows.push(row),
34  })
35  return rows
36}
37
38self.onmessage = async (event) => {
39  const { type, payload, id } = event.data
40  if (type === 'init') {
41    await initDb()
42    self.postMessage({ id, ok: true })
43    return
44  }
45  if (type === 'saveRevision') {
46    saveRevision(payload.docId, payload.content)
47    self.postMessage({ id, ok: true })
48    return
49  }
50  if (type === 'listRevisions') {
51    const rows = listRevisions(payload.docId)
52    self.postMessage({ id, ok: true, rows })
53  }
54}

主线程这边只管发消息,不碰任何 OPFS 或 SQLite 的 API:

1const worker = new Worker('/db-worker.js', { type: 'module' })
2let seq = 0
3const pending = new Map()
4
5worker.onmessage = (event) => {
6  const { id, ...rest } = event.data
7  pending.get(id)?.(rest)
8  pending.delete(id)
9}
10
11function call(type, payload) {
12  const id = ++seq
13  return new Promise((resolve) => {
14    pending.set(id, resolve)
15    worker.postMessage({ type, payload, id })
16  })
17}
18
19await call('init')
20await call('saveRevision', { docId: 'doc-1', content: encodedBytes })
21const { rows } = await call('listRevisions', { docId: 'doc-1' })

这套结构测下来,同样是保存几百条历史修订记录,SQLite + OPFS 的写入延迟比原来 IndexedDB 版本的实现低了一大截,而且查询能直接用 SQL 表达"某篇文档最近 20 条修订"这种条件,不用再手写游标遍历和内存里过滤。

落地场景二:大文件分片写入

编辑器另一个需求是支持导入较大的附件(几十到上百 MB 的音视频素材),边下载边落盘,不想等整个文件都进内存再一次性写。用同步访问句柄配合 Blob.stream() 逐块写入,效果不错:

1// upload-worker.js
2async function saveLargeFile(fileName, stream) {
3  const root = await navigator.storage.getDirectory()
4  const fileHandle = await root.getFileHandle(fileName, { create: true })
5  const accessHandle = await fileHandle.createSyncAccessHandle()
6
7  let offset = 0
8  const reader = stream.getReader()
9  try {
10    while (true) {
11      const { done, value } = await reader.read()
12      if (done) break
13      const written = accessHandle.write(value, { at: offset })
14      offset += written
15    }
16    accessHandle.flush()
17  } finally {
18    accessHandle.close()
19  }
20  return offset
21}

这里的写入本身是同步调用,真正的异步等待只发生在从网络流里读下一块数据,两者互不干扰。用 IndexedDB 做同样的事,得先把整个 ArrayBuffer 拼好再存成一条记录,或者手动切成多条小记录再自己拼回来,无论哪种都比直接按偏移量写文件绕。

和 IndexedDB、Cache Storage 怎么分工

用了一圈下来,我现在给团队内部的建议大致是按数据的形状来分:结构化的小数据,需要按字段查询、需要索引、事务性要求高的(用户设置、协作光标位置、消息列表这类),还是 IndexedDB 更合适,它的对象存储和游标查询本来就是为这种场景设计的,OPFS 没有原生的查询能力,硬要拿文件模拟索引反而更麻烦。

需要高频随机读写、体积较大、或者本身就适合用"文件"这个抽象来描述的数据——本地数据库文件、富文本编辑器的二进制文档格式、导入导出的大附件——交给 OPFS,尤其是要跑在 Worker 里的场景,同步句柄的性能优势才发挥得出来。

至于 Cache Storage,它的定位始终是请求-响应对的缓存,Request 对象做键、Response 对象做值,天然适合配合 Service Worker 做静态资源和接口响应的缓存策略,不适合拿来存业务生成的二进制数据——虽然技术上你可以把任意字节流塞进一个自造的 Response 对象里存进 Cache Storage,但这么用完全绕开了它的设计初衷,排查问题的时候也很别扭。三者不是互相替代的关系,是分别对应"键值缓存"、"结构化小数据"、"文件"这三种不同的数据形状。

清理旧文件和调试手段

OPFS 里的目录句柄支持异步遍历,entries() 能拿到一个异步迭代器,配合 removeEntry() 可以按条件清掉过期文件,这个在编辑器里用来定期清理超出保留期限的历史数据库快照:

1async function pruneOldSnapshots(maxAgeMs) {
2  const root = await navigator.storage.getDirectory()
3  const snapshotsDir = await root.getDirectoryHandle('snapshots', { create: true })
4  const now = Date.now()
5
6  for await (const [name, handle] of snapshotsDir.entries()) {
7    if (handle.kind !== 'file') continue
8    const file = await handle.getFile()
9    if (now - file.lastModified > maxAgeMs) {
10      await snapshotsDir.removeEntry(name)
11    }
12  }
13}

调试的时候,Chrome DevTools 的 Application 面板里有个 Storage → File System 的入口,能直接看到 OPFS 里的目录树和每个文件的大小,比之前只能靠代码里打日志去猜文件有没有写成功方便不少。Safari 目前还没有对应的可视化面板,调试的时候我一般是临时写个小函数,把整棵目录树递归打印出来:

1async function dumpTree(dirHandle, prefix = '') {
2  for await (const [name, handle] of dirHandle.entries()) {
3    if (handle.kind === 'directory') {
4      console.log(`${prefix}${name}/`)
5      await dumpTree(handle, `${prefix}  `)
6    } else {
7      const file = await handle.getFile()
8      console.log(`${prefix}${name} (${file.size} bytes)`)
9    }
10  }
11}

这段代码平时用不上,但排查"数据库文件怎么变得这么大""某次清理是不是漏删了"这类问题时,比隔着一层黑盒猜测直接得多。

兼容现状和容量策略

createSyncAccessHandle 在 Chrome 和基于 Chromium 的浏览器里已经稳定可用了一段时间,Safari 从 16.4 起支持了 OPFS 的核心 API,包括同步访问句柄,这是这套方案能在生产里认真考虑的前提之一——如果只有 Chrome 支持,我大概率还是会先按 IndexedDB 兜一版再说。Firefox 这边目前的支持还在完善中,具体到某个次要方法上偶尔会跟 Chrome 的实现有细节差异,真上生产前建议照着目标浏览器矩阵逐个测一遍,不要只在 Chrome 里跑通就当作done。编辑器这边我暂时的做法是做能力检测,不支持同步句柄的环境自动退回到原来的 IndexedDB 实现,两条路径长期并存了一阵子。

1function supportsSyncAccessHandle() {
2  return typeof FileSystemFileHandle !== 'undefined'
3    && 'createSyncAccessHandle' in FileSystemFileHandle.prototype
4}

容量这块跟 IndexedDB 是同一个配额池,走的都是 navigator.storage.estimate() 能查到的那份同源存储总量,不是单独给 OPFS 开的一块。这意味着如果编辑器同时用 IndexedDB 存元数据、用 OPFS 存历史数据库文件,两边加起来才是真实占用,规划配额的时候得放一起算,不能分开估。

1async function checkStorageBudget() {
2  const { usage, quota } = await navigator.storage.estimate()
3  if (usage / quota > 0.85) {
4    // 提前提示用户清理旧的历史版本或导出归档
5    return 'near-limit'
6  }
7  return 'ok'
8}

用户主动清浏览器数据、或者系统在磁盘紧张时做存储压力驱逐,OPFS 里的文件都会被一并清空,这一点跟 IndexedDB 完全一样,没有任何特殊豁免。所以数据库文件本身只能当作本地缓存来对待,真正重要的内容还是得有一条同步或导出到服务端的路径,不能假设用户本地这份 SQLite 文件会一直存在。编辑器这边的做法是每隔一段时间把增量修订推一份到后端,本地文件丢了也只是重新拉一份基线、丢掉几分钟内还没同步的编辑,不至于整份文档没了。

打开一个已经存在的同步句柄如果中途遇到浏览器崩溃或者标签页被强杀,文件不会像正常关闭那样自动 flush,下次打开时最好做一次数据完整性校验(比如 SQLite 自己的 PRAGMA integrity_check),发现损坏就回退到最近一次成功同步的服务端版本,而不是让用户带着一份坏文件继续往下写。这个校验步骤我目前是放在 Worker 初始化阶段做一次,代价不大,换来的是不至于在用户完全没感知的情况下把新写入都堆到一份已经损坏的文件上。

编辑器这边最终定的方案是:日常编辑操作走 SQLite + OPFS 存历史修订,用户设置和协作状态这类小对象继续留在 IndexedDB,导入的大附件用同步句柄分片写入 OPFS 单独的文件里。跑了几周下来,编辑器在连续输入场景下的卡顿感明显比之前用 IndexedDB 存操作日志的版本轻,查历史版本列表的响应也快了不少。Firefox 那边的兼容性还得再观察一阵,暂时先留着 IndexedDB 的退回路径,等生态再成熟一点再考虑要不要收掉。