最近在做一个笔记工具的原型,需求很简单:离线能用、数据不丢、不依赖后端。听起来IndexedDB就能搞定,但实际用下来发现IndexedDB在复杂查询场景下简直是灾难——没有JOIN、没有聚合函数、连个像样的索引都要自己维护。折腾了两天之后换了思路:直接在浏览器里跑SQLite。
为什么是SQLite WASM
浏览器里跑SQLite这事其实不新鲜,但之前一直有个致命问题:数据持久化。早期方案要么把整个数据库序列化成blob塞进IndexedDB(慢),要么用localStorage(4MB限制,别想了)。2023年底Chrome率先支持了Origin Private File System(OPFS),这才真正打通了最后一环。OPFS提供了一个沙盒文件系统,SQLite可以像在本地一样直接读写文件,性能接近原生。现在主流浏览器支持情况:
| 浏览器 | OPFS | OPFS + Access Handle(同步API) |
|---|---|---|
| Chrome 109+ | ✅ | ✅ |
| Edge 109+ | ✅ | ✅ |
| Firefox 111+ | ✅ | ✅ |
| Safari 17+ | ✅ | ⚠️ 部分支持 |
Safari的同步Access Handle支持还有些问题,后面会说怎么处理。
技术选型:三个SQLite WASM方案对比
市面上能用的方案主要三个,我都试了一遍:
| 方案 | 包体积 | OPFS支持 | API风格 | 维护状态 |
|---|---|---|---|---|
| sql.js | ~1MB | ❌ 需要手动桥接 | 同步 | 社区维护,更新慢 |
| SQLite官方WASM | ~800KB | ✅ 原生支持 | C风格回调 | 官方维护 |
| wa-sqlite | ~400KB | ✅ 多种VFS | 异步Promise | 活跃 |
sql.js是最老牌的,但它把整个数据库放内存里,持久化要自己搞,大文件直接爆内存。pass。SQLite官方WASM(sqlite.org出品)功能最全,OPFS支持最好,但API是C语言风格的回调地狱,在现代前端项目里写起来很痛苦。最后选了wa-sqlite。包体积最小,API是async/await风格,OPFS支持通过可插拔的VFS(Virtual File System)实现,而且作者一直在更新。
环境搭建
npm install wa-sqlite# 如果用Vite,还需要配置WASM加载npm install vite-plugin-wasm -D
Vite配置:
// vite.config.jsimport wasm from 'vite-plugin-wasm'export default { plugins: [wasm()], optimizeDeps: { exclude: ['wa-sqlite'] }, worker: { plugins: () => [wasm()] }}
需要注意:OPFS的同步Access Handle只能在Web Worker里用。这意味着所有数据库操作必须放到Worker线程。主线程通过postMessage跟Worker通信。
核心代码:Worker里初始化SQLite
// db.worker.jsimport SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs'import { IDBBatchAtomicVFS } from 'wa-sqlite/src/examples/IDBBatchAtomicVFS.js'import * as SQLite from 'wa-sqlite'let db = nulllet sqlite3 = nullasync function initDB() { const module = await SQLiteESMFactory() sqlite3 = SQLite.Factory(module) // 用IDBBatchAtomicVFS作为持久化层 // 它底层用IndexedDB存储,但提供了原子写入保证 const vfs = await IDBBatchAtomicVFS.create('myapp', module) sqlite3.vfs_register(vfs, true) db = await sqlite3.open_v2('myapp.db') // 开启WAL模式,提升并发读写性能 await exec('PRAGMA journal_mode=WAL') await exec('PRAGMA synchronous=NORMAL') // 建表 await exec(` CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, created_at INTEGER DEFAULT (unixepoch()), updated_at INTEGER DEFAULT (unixepoch()), is_deleted INTEGER DEFAULT 0 ) `) await exec(`CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at)`)}async function exec(sql, params = []) { const results = [] await sqlite3.exec(db, sql, (row, columns) => { if (row) { const obj = {} columns.forEach((col, i) => obj[col] = row[i]) results.push(obj) } }, params) return results}// 监听主线程消息self.onmessage = async (e) => { const { id, action, payload } = e.data try { let result switch (action) { case 'init': await initDB() result = { ok: true } break case 'exec': result = await exec(payload.sql, payload.params) break case 'addNote': result = await exec( 'INSERT INTO notes (id, title, content) VALUES (?, ?, ?)', [payload.id, payload.title, payload.content] ) break case 'getNotes': result = await exec( 'SELECT * FROM notes WHERE is_deleted = 0 ORDER BY updated_at DESC LIMIT ? OFFSET ?', [payload.limit || 50, payload.offset || 0] ) break case 'search': result = await exec( 'SELECT * FROM notes WHERE is_deleted = 0 AND (title LIKE ? OR content LIKE ?) ORDER BY updated_at DESC', [`%${payload.query}%`, `%${payload.query}%`] ) break } self.postMessage({ id, result }) } catch (error) { self.postMessage({ id, error: error.message }) }}
主线程封装:让调用体验像普通异步函数
Worker通信的postMessage/onmessage模式写多了很烦,封装一下:
// db.jsclass LocalDB { constructor() { this.worker = new Worker( new URL('./db.worker.js', import.meta.url), { type: 'module' } ) this.pending = new Map() this.nextId = 0 this.worker.onmessage = (e) => { const { id, result, error } = e.data const { resolve, reject } = this.pending.get(id) this.pending.delete(id) error ? reject(new Error(error)) : resolve(result) } } _call(action, payload) { return new Promise((resolve, reject) => { const id = this.nextId++ this.pending.set(id, { resolve, reject }) this.worker.postMessage({ id, action, payload }) }) } init() { return this._call('init') } addNote(note) { return this._call('addNote', note) } getNotes(opts) { return this._call('getNotes', opts) } search(query) { return this._call('search', { query }) } exec(sql, params) { return this._call('exec', { sql, params }) }}export const db = new LocalDB()
用起来就是正常的async/await:
import { db } from './db'await db.init()await db.addNote({ id: crypto.randomUUID(), title: '第一条笔记', content: '这条数据完全存在浏览器本地'})const notes = await db.getNotes({ limit: 20 })console.log(notes) // 完整的SQL查询结果
性能实测
在M1 MacBook Pro的Chrome 122上测了一组数据,数据库预填充10000条笔记记录:
| 操作 | IndexedDB | SQLite WASM (IDBBatchAtomicVFS) |
|---|---|---|
| 插入1000条 | 320ms | 180ms(开事务) |
| 按条件查询 | 45ms(手动遍历过滤) | 3ms(索引命中) |
| 全文搜索(LIKE) | 需要自建倒排索引 | 12ms |
| JOIN两张表 | 不支持,要手动拼 | 5ms |
| 聚合统计(COUNT/GROUP BY) | 要遍历全表 | 2ms |
查询场景SQLite完胜,尤其是有索引的情况下差距是数量级的。插入性能SQLite也更好,前提是用事务批量插入:
// 批量插入一定要用事务,不然每条都是一次fsync,慢到怀疑人生await db.exec('BEGIN TRANSACTION')for (const note of batchNotes) { await db.exec( 'INSERT INTO notes (id, title, content) VALUES (?, ?, ?)', [note.id, note.title, note.content] )}await db.exec('COMMIT')
不开事务插入1000条要3.2秒,开了事务180ms。这个差距务必注意。
踩坑记录
坑1:Safari的OPFS Access HandleSafari 17虽然支持OPFS,但createSyncAccessHandle()的行为跟Chrome不一样。在Safari里,同一个文件同时只能有一个SyncAccessHandle,如果Worker里开了多个数据库连接会直接报错。解决办法:用IDBBatchAtomicVFS替代OPFSAccessHandlePoolVFS。IDBBatchAtomicVFS底层用IndexedDB做持久化,不依赖同步API,跨浏览器兼容性最好。性能比纯OPFS方案差大约20-30%,但对于大多数应用够用了。坑2:SharedArrayBuffer和COOP/COEP如果你想用OPFS的同步方案(性能最好),需要在Worker里用SharedArrayBuffer,这要求页面设置以下HTTP头:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp
这两个头一加,页面里所有跨域资源(图片、字体、第三方脚本)都要带crossorigin属性或者对端返回CORS头。如果你的页面引用了大量第三方资源,这基本是灾难。所以我最终选择了不依赖SharedArrayBuffer的IDBBatchAtomicVFS方案。实际性能差距在可接受范围内。坑3:数据库文件大小OPFS没有明确的存储配额,但浏览器会根据磁盘剩余空间动态分配。Chrome大约给每个origin 60%的可用空间,但如果用户磁盘快满了就会被回收。关键:一定要做导出功能。用户的数据不能只存在浏览器里:
// 导出数据库为文件async function exportDB() { const data = await db.exec('SELECT * FROM notes WHERE is_deleted = 0') const json = JSON.stringify(data, null, 2) const blob = new Blob([json], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `notes-backup-${Date.now()}.json` a.click() URL.revokeObjectURL(url)}
坑4:Vite开发服务器的Worker加载Vite的dev server默认不处理Worker里的WASM导入,会报”cannot import a .wasm module from a web worker”。必须在vite.config.js的worker.plugins里也加上wasm插件(上面的配置已经包含了)。另外,开发时Chrome的DevTools -> Application -> Storage里看不到OPFS的文件。要用navigator.storage.getDirectory()手动遍历才能看到数据库文件。调试体验确实不如IndexedDB。
数据同步:CRDTs还是自己搞
Local-First不代表永远离线,大多数场景还是需要多设备同步。这里有两条路:方案A:CRDT(推荐研究,暂不推荐生产)Automerge、Yjs这些CRDT库能实现无冲突的多端同步,但把SQLite和CRDT结合起来目前还没有成熟方案。cr-sqlite(SQLite的CRDT扩展)在WASM环境下还不稳定。方案B:基于时间戳的Last-Write-Wins(实用)对于大多数CRUD应用,用updated_at字段做最后写入胜出就够了:
// 同步逻辑伪代码async function sync(remoteAPI) { // 1. 拉取远端更新 const lastSync = localStorage.getItem('last_sync') || 0 const remoteChanges = await remoteAPI.getChanges(lastSync) // 2. 合并:比较updated_at,大的赢 await db.exec('BEGIN TRANSACTION') for (const remote of remoteChanges) { const local = await db.exec( 'SELECT * FROM notes WHERE id = ?', [remote.id] ) if (!local.length || remote.updated_at > local[0].updated_at) { await db.exec(` INSERT OR REPLACE INTO notes (id, title, content, updated_at, is_deleted) VALUES (?, ?, ?, ?, ?) `, [remote.id, remote.title, remote.content, remote.updated_at, remote.is_deleted]) } } await db.exec('COMMIT') // 3. 推送本地更新 const localChanges = await db.exec( 'SELECT * FROM notes WHERE updated_at > ?', [lastSync] ) await remoteAPI.pushChanges(localChanges) localStorage.setItem('last_sync', Date.now())}
软删除(is_deleted标记)而不是真删除,这样才能正确同步删除操作到其他设备。
适用场景判断
适合用SQLite WASM的场景:
- 数据结构复杂,需要JOIN和聚合查询
- 离线优先的工具类应用(笔记、TODO、个人知识库)
- 数据敏感,用户不想上传到服务器
- PWA应用,需要完整的离线体验
不适合的场景:
- 数据量超过500MB(浏览器存储有限)
- 需要多用户实时协作(CRDT方案还不成熟)
- SEO重要的内容型网站(数据在客户端,爬虫看不到)
- 对Safari兼容性要求极高的项目
总结一下技术栈
| 层 | 选择 | 原因 |
|---|---|---|
| SQLite WASM | wa-sqlite | 包小、API现代、VFS可插拔 |
| 持久化VFS | IDBBatchAtomicVFS | 跨浏览器兼容最好 |
| 线程模型 | Web Worker | 不阻塞主线程 |
| 同步策略 | Last-Write-Wins | 简单可靠,覆盖80%场景 |
| 数据安全 | JSON导出 + 可选云同步 | 用户数据不能只存浏览器 |
Local-First不是什么新概念,但SQLite WASM + OPFS让它第一次在Web端变得真正可用。对于工具类应用,完全可以做到”打开即用、离线不断、数据在手”。该方案的核心优势在于你能用完整的SQL能力处理客户端数据,而不是跟IndexedDB的cursor API搏斗。项目代码在实际使用中跑了两个月,10000+条记录没出过数据丢失。唯一的遗憾是Safari的支持还差点意思,但考虑到Apple一贯的作风,只能等了。