12k
All articles

在浏览器中通过 OPFS 运行 SQLite

在浏览器中使用SQLite与OPFS:配置Worker,选择opfs或opfs-sahpool,并避免静默回退到内存数据库。

OpenReplay Team
OpenReplay Team
在浏览器中通过 OPFS 运行 SQLite

编译为 WebAssembly 的 SQLite 默认完全运行在内存中,因此除非数据库由持久化 VFS 支撑,否则你写入的每一行数据都会在页面刷新时消失,而 Origin Private File System(OPFS)正是官方构建中提供这种持久化能力的机制。

要实现这一点,需要的不仅仅是一次 import 和一条查询。本文将基于官方 @sqlite.org/sqlite-wasm 包,梳理实际的接入成本:在 Worker 中打开持久化数据库、将其与 UI 连通、初次实现时最容易踩的两个坑,以及如何在两种可用于生产的 VFS 之间做出选择。

关键要点

  • 官方构建以 @sqlite.org/sqlite-wasm 的名称发布在 npm 上,与 sql.js 不同,它由 SQLite 项目自身维护,并内置了 OPFS 持久化能力。
  • OPFS 同步访问句柄只存在于 Worker 线程中,因此基于 OPFS 的数据库绝不可能在主线程上打开。
  • 默认的 “opfs” VFS 依赖 SharedArrayBuffer,因此需要 COOP 和 COEP 响应头;而 “opfs-sahpool” VFS 完全不需要任何响应头。
  • opfs-sahpool 是批量作业场景下最快的 OPFS 选项,但只允许一个连接处于打开状态,因此第二个标签页打开同一数据库时会失败。
  • 当 OPFS 不可用时,切勿静默回退到内存数据库;这会让应用看似正常运行,实则丢弃用户保存的所有内容。

OPFS 为浏览器中的 SQLite 带来了什么?

OPFS 是一个沙箱化、以源(origin)为作用域的文件系统,它恰好提供了 SQLite 真正需要的东西:可在页面重载后保留的同步、字节级文件访问能力。没有它,Wasm 构建只能把数据库保存在内存中,一次刷新就会全部清空。有了它,你就在客户端拥有了一个真正持久的 SQL 数据库,而这正是让现代 SQLite 特性在浏览器环境中真正可用的关键一环。

请使用官方包。社区里存在一些较早的 Wasm 移植版本,但 @sqlite.org/sqlite-wasm 是 SQLite 项目自己的 Wasm 构建,只是重新以 ES module 形式发布。在其之上额外附加的内容仅有一套 TypeScript 类型定义。持久化文档描述了多种存储后端;实践中真正重要的是 “opfs” VFS 和 “opfs-sahpool” VFS。此外还有其他方案(基于 localStorage 的 kvvfs、“opfs-wl” 变体、配合排他锁的 WAL 模式),同一份文档中均有涵盖。

在 Worker 内打开数据库

安装该包,然后把所有数据库操作都放在一个专用 Worker 中完成。下面的示例使用 “opfs-sahpool” VFS,它必须通过 await sqlite3.installOpfsSAHPoolVfs() 显式安装。该 VFS 会把数据库名称规范化为绝对路径,因此请始终一致地使用前置斜杠:

npm install @sqlite.org/sqlite-wasm
// worker.js
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';

let db;

async function init() {
  const sqlite3 = await sqlite3InitModule();
  const poolUtil = await sqlite3.installOpfsSAHPoolVfs();
  db = new poolUtil.OpfsSAHPoolDb('/app.sqlite3'); // names are normalised to an absolute path
  db.exec('CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)');
}

init()
  .then(() => postMessage({ type: 'ready' }))
  .catch((err) => postMessage({ type: 'init-error', message: err.message }));

请注意这段代码没有做的事:在 OPFS 不可用时回退到 new sqlite3.oo1.DB(...)。这种写法出现在大量示例代码中,包括官方 README 的 worker 示例,而它本质上是一个伪装起来的数据丢失 bug。应用会继续在一个临时的内存数据库上运行,用户继续保存数据,然后一次刷新就摧毁一切。如果持久化初始化失败,应当把错误暴露到 UI 上,让用户知晓。

从 UI 与 Worker 通信

该库仍然导出了用于主线程访问的 promiser API,但包 README 在一条日期为 2026-04-15 的通告中将 Worker1 和 Promiser1 API 标记为已弃用。它们仍保留在包中,但不会再有后续开发,维护者也建议大家不要使用。官方推荐的路径是在 Worker 内部使用 sqlite3InitModule 加上 oo1 API,并自行编写一层轻量的 postMessage 桥接:

// worker.js (continued)
onmessage = ({ data }) => {
  const { id, sql, bind } = data;
  try {
    const rows = db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' });
    postMessage({ id, result: rows });
  } catch (err) {
    postMessage({ id, error: err.message });
  }
};
// db-client.js (main thread)
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
let nextId = 1;
const pending = new Map();

worker.onmessage = ({ data }) => {
  const entry = pending.get(data.id);
  if (!entry) return;
  pending.delete(data.id);
  data.error ? entry.reject(new Error(data.error)) : entry.resolve(data.result);
};

export function query(sql, bind = []) {
  return new Promise((resolve, reject) => {
    const id = nextId++;
    pending.set(id, { resolve, reject });
    worker.postMessage({ id, sql, bind });
  });
}

四十行桥接代码就是全部成本,而且消息格式完全由你掌控。

坑一:基于 OPFS 的 SQLite 必须运行在 Worker 中

基于 OPFS 的 SQLite 无法在主线程上运行,没有例外。SQLite 是一个同步引擎,它所需的同步文件访问来自 FileSystemSyncAccessHandle,而平台之所以只在专用 Web Worker 内暴露该接口,正是因为同步 I/O 会阻塞执行它的那个线程。

当有人绕过而非遵守这条约束时,会话回放会让这种故障暴露无遗:回放中可以看到点击和按键都被记录下来,但在整个查询期间界面毫无重绘,这正是同步 OPFS I/O 阻塞 UI 线程的典型视觉特征。把引擎留在 Worker 里,主线程就永远不会直接接触查询。

坑二:COOP/COEP 响应头要求

默认的 “opfs” VFS 使用 SharedArrayBuffer 在其同步前端与后方的异步 worker 之间传递消息,因此服务器必须发送 Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp,否则该 VFS 无法加载。对于 Vite,官方 README 给出了如下配置,其中包含必需的 optimizeDeps 排除项:

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  optimizeDeps: {
    exclude: ['@sqlite.org/sqlite-wasm'],
  },
});

生产服务器同样需要这两个响应头。但如果你无法设置响应头(静态托管、会被 COEP 破坏的第三方嵌入内容),你并不需要借助 service worker 之类的技巧:“opfs-sahpool” VFS 完全不需要 COOP/COEP 响应头,这也正是上文代码采用它的原因。

在两种 OPFS VFS 之间做选择

当多个标签页必须共享同一个数据库且你能控制响应头时,选择 “opfs”;当你追求最快速度、不希望有响应头要求,并且能够接受单一连接时,选择 “opfs-sahpool”。SQLite 官方的持久化文档将 sahpool 评为其所涵盖的 OPFS 后端中最快的一种。保存单条记录时你感受不到差别,但在批量作业中差异会非常明显。

“opfs""opfs-sahpool”
COOP/COEP 响应头必需不需要
多连接/多标签页支持,需处理 SQLITE_BUSY不支持,同一时间仅一个
性能良好据 SQLite 文档,批量作业最快
注册方式支持时自动注册显式调用 installOpfsSAHPoolVfs()
Safari 16.4 至 16.x因 WebKit 子 worker bug 而不可用可用

即便使用 “opfs”,多标签页也并非零代价。获取同步访问句柄会对文件加排他锁,读取同样需要该锁,因此第二个标签页打开同一数据库时会遇到锁冲突错误,表现为 SQLITE_BUSY 或一个通用 I/O 错误。应当处理它,而不是把它当作致命错误:

async function withRetry(fn, attempts = 5, delayMs = 100) {
  for (let i = 0; i < attempts; i++) {
    try {
      return fn();
    } catch (err) {
      if (!/SQLITE_BUSY/.test(String(err.message)) || i === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
}

保持事务简短、及时重置语句,中等程度的跨标签页并发是可以正常工作的。而在 sahpool 上,第二个标签页调用 installOpfsSAHPoolVfs() 会直接失败:连接池会独占数据库锁,因此单连接就是上限。请检测这种情况,并让第二个标签页通过第一个标签页来转发操作。SQLite 3.50 新增了 pauseVfs()unpauseVfs(),正是为这类协作式移交而设计的。

什么时候应该选择 SQLite 而不是 IndexedDB?

当你的数据是关系型的时候,就应该选择基于 OPFS 的 SQLite:跨实体连接查询、聚合运算、临时性筛选、完整的 SQL 索引,或者把一份预构建数据集作为单个数据库文件一次性导入。在这些工作负载下,IndexedDB 会迫使你在应用代码中重新实现一个查询引擎。

而对于键值状态、小型缓存或几百条记录来说,它就有些杀鸡用牛刀了。这类工作不值得引入一个 Wasm 二进制文件、一个 Worker 和一套消息桥接;localStorage 或原生 IndexedDB 才是合适的量级。

总结

接入成本确实存在,但是有限的:一个 Worker、一套消息桥接,以及一个取决于「是否需要多标签页访问」或「是否需要免响应头部署」的 VFS 选型决策。对于单文档的 local-first 应用,从 “opfs-sahpool” 起步;当标签页之间必须共享数据时,转向 “opfs” 并处理 SQLITE_BUSY;并且永远不要让 OPFS 初始化失败悄无声息地降级为内存数据库。

常见问题

哪些浏览器支持带 OPFS 持久化的 SQLite Wasm?

OPFS 同步访问句柄自 Chromium 108、Firefox 111 和 Safari 16.4 起可用。有一点需要注意:低于 17 的 Safari 版本存在一个 WebKit 子 worker bug,会导致默认的 'opfs' VFS 无法工作,SQLite 文档指出 'opfs-sahpool' 是在这些版本上仍然可用的选项。Safari 17 及以后版本两种 VFS 都能正常运行。

我可以分发一个预构建的 SQLite 数据库文件并把它加载到 OPFS 中吗?

可以。使用 'opfs-sahpool' VFS 时,将 .db 文件以 ArrayBuffer 形式获取,然后传给 installOpfsSAHPoolVfs() 解析出的 PoolUtil 对象上的 importDb(),之后正常打开数据库即可。请在两次调用中传入完全相同的名称字符串:importDb() 会原样存储你给出的名称,而打开数据库时会将其规范化为绝对路径,因此若导入的是 'data.db' 而打开的是 '/data.db',你得到的将是一个空数据库。PoolUtil 还提供了 exportFile() 用于导出数据库以做备份,以及 getFileNames() 用于列出连接池中保存的内容。这一方案适合以单个文件形式分发参考数据集的应用。

基于 OPFS 的 SQLite 数据库能存储多少数据?

没有固定上限。OPFS 存储受浏览器管理的配额约束,配额通常比较宽松,但会因浏览器、设备和可用磁盘空间而异,因此应在运行时调用 navigator.storage.estimate() 进行检查,而不要假定某个具体数值。隐私模式和无痕窗口可能会降低甚至完全取消持久化能力,而清除站点数据会连同该源的其他存储一起删除数据库。

调试时如何查看 SQLite 创建的 OPFS 文件?

浏览器 DevTools 原生并不显示 OPFS 内容。适用于 Chrome DevTools 的 OPFS Explorer 扩展可以展示当前源的 OPFS 文件层级,并允许下载单个文件。请注意,'opfs-sahpool' 会按照自己的虚拟名称映射把数据库存放在不透明的池文件内部,因此你传入的文件名不会直接出现;请改用 PoolUtil 的 getFileNames() 和 exportFile() 来列出并导出这些数据库。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.