编译为 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-origin 和 Cross-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() 来列出并导出这些数据库。
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