前端 · 2026年3月1日

Local-First实战:SQLite WASM + OPFS让Web应用彻底摆脱后端

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