前端 · 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.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一贯的作风,只能等了。