最近在做一个笔记工具的原型,需求很简单:离线能用、数据不丢、不依赖后端。听起来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.js
import 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.js
import 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 = null
let sqlite3 = null
async 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.js
class 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-origin Cross-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一贯的作风,只能等了。