2026 年,Notion、Linear、Figma 等一线产品不约而同地选择将 SQLite 数据库搬进浏览器——通过 WebAssembly 运行完整的 SQLite 引擎,配合 OPFS(Origin Private File System)实现数据持久化,让 Web 应用首次拥有了媲美原生 App 的本地数据能力。如果你还在用 IndexedDB 的 key-value 模型和异步回调来管理复杂数据,是时候升级你的技术栈了。浏览器端 SQLite 不是玩具,它已经是生产级 Local-First 架构的基石。
📊 一、浏览器端存储方案全景对比
在深入 SQLite 之前,先看看浏览器提供了哪些存储方案,以及各自的局限性。
| 方案 | 容量上限 | 数据模型 | 事务支持 | 查询能力 | 离线优先 |
|---|---|---|---|---|---|
| localStorage | ~5 MB | Key-Value 字符串 | ❌ 无 | ❌ 只能全量读取 | ✅ |
| sessionStorage | ~5 MB | Key-Value 字符串 | ❌ 无 | ❌ 只能全量读取 | ❌ 标签页关闭即丢 |
| IndexedDB | ~磁盘空间 | Key-Value + 索引 | ✅ | ⚠️ API 复杂,无 SQL | ✅ |
| OPFS + SQLite | ~磁盘空间 | 完整关系型 | ✅ ACID | ✅ 完整 SQL | ✅ |
| Cache API | ~磁盘空间 | Request/Response | ❌ | ❌ 按 URL 匹配 | ✅ |
💡 **提示:**localStorage 和 sessionStorage 只适合存储简单配置项(主题、语言偏好)。任何涉及结构化数据的场景,都应该用 IndexedDB 或 SQLite。而 SQLite 在查询能力和开发体验上的优势是压倒性的。
IndexedDB 虽然容量大且支持事务,但它的 API 设计堪称前端最令人痛苦的接口之一。一个简单的 SELECT * FROM users WHERE age > 18 ORDER BY name 在 IndexedDB 中需要创建索引、打开游标、手动过滤和排序,代码量是 SQL 的 10 倍以上。
// ❌ IndexedDB:查询年龄大于 18 的用户,按姓名排序
// 痛苦的代码量和心智负担
const tx = db.transaction('users', 'readonly')
const store = tx.objectStore('users')
const index = store.index('age')
const range = IDBKeyRange.lowerBound(18, true)
const request = index.openCursor(range)
const results = []
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
results.push(cursor.value)
cursor.continue()
} else {
results.sort((a, b) => a.name.localeCompare(b.name))
console.log(results)
}
}
// ✅ SQLite:同样的查询,一行 SQL
const results = db.exec(
'SELECT * FROM users WHERE age > 18 ORDER BY name'
)
这个对比不是为了「黑」IndexedDB——它在某些场景(缓存、Blob 存储)下依然是好选择。但当你需要复杂查询、聚合统计、多表关联时,SQL 就是唯一正确的答案。
🔧 二、三大浏览器端 SQLite 引擎对比
目前浏览器端 SQLite 生态有三个主流方案,它们的设计目标和适用场景完全不同。
2.1 sql.js:经典入门之选
sql.js 是最早将 SQLite 编译为 WebAssembly 的项目,由社区维护至今。它的最大优点是零配置——通过 CDN 加载即可使用:
// sql.js 快速上手 — 通过 CDN 加载,零配置
import initSqlJs from 'https://sql.js.org/dist/sql-wasm.js'
const SQL = await initSqlJs({
locateFile: file => `https://sql.js.org/dist/${file}`
})
const db = new SQL.Database()
// 建表
db.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT (datetime('now'))
)
`)
// 插入数据
db.run('INSERT INTO tasks (title) VALUES (?)', ['学习 wa-sqlite'])
db.run('INSERT INTO tasks (title) VALUES (?)', ['构建 PWA 应用'])
// 查询
const results = db.exec('SELECT * FROM tasks WHERE status = ?', ['pending'])
console.log(results[0].values)
// [[1, '学习 wa-sqlite', 'pending', '2026-05-30 08:00:00'],
// [2, '构建 PWA 应用', 'pending', '2026-05-30 08:00:01']]
// 导出为二进制(可用于持久化到 IndexedDB)
const data = db.export() // Uint8Array
const blob = new Blob([data.buffer], { type: 'application/x-sqlite3' })
⚠️ 警告:
sql.js的数据库完全运行在内存中。如果你不手动export()并持久化,页面刷新后数据全部丢失。这是新手最容易踩的坑。
持久化方案:通常配合 IndexedDB 存储 db.export() 返回的二进制数据。页面加载时从 IndexedDB 读取,用 new SQL.Database(data) 恢复。但这种方式有两个严重问题:
- 全量加载:即使你只查一条数据,也需要把整个数据库加载到内存
- 全量保存:每次修改后需要
export()整个数据库再写入 IndexedDB
2.2 wa-sqlite:OPFS 持久化的终极方案
wa-sqlite(WebAssembly SQLite)是目前最成熟的浏览器端 SQLite 引擎,由 @nicktomlin 维护,核心优势是支持多种虚拟文件系统(VFS),尤其是 OPFS VFS。
OPFS(Origin Private File System)是浏览器提供的沙盒化文件系统 API,专为 Web Worker 设计。在 Worker 内部,OPFS 提供同步文件 I/O(createSyncAccessHandle),性能远超 IndexedDB 的异步操作。
// wa-sqlite + OPFS 持久化 — 在 Web Worker 中运行
// worker.js
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs'
import { OPFSVFS } from 'wa-sqlite/src/OPFSVFS.js'
async function initDB() {
const module = await SQLiteESMFactory()
const sqlite3 = module.sqlite3
// 创建 OPFS 虚拟文件系统
const vfs = await OPFSVFS.createVFS(module)
sqlite3.vfs_register(vfs, true) // 注册为默认 VFS
// 打开数据库(文件存储在 OPFS 中,刷新不丢失)
const db = sqlite3.open_v2('app-data.db')
// 之后的所有操作都直接持久化到磁盘
sqlite3.exec(db, `
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
tags TEXT,
pinned INTEGER DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
)
`)
// 插入数据 — 直接写入 OPFS,无需手动保存
sqlite3.exec(db, `INSERT INTO notes (content, tags) VALUES (?, ?)`,
['第一条笔记', '["personal","ideas"]'])
// 查询
const results = []
sqlite3.exec(db, 'SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC',
(row, columns) => {
results.push(Object.fromEntries(columns.map((col, i) => [col, row[i]])))
}
)
console.log(results)
// [{ id: 1, content: '第一条笔记', tags: '["personal","ideas"]', pinned: 0, ... }]
return { sqlite3, db }
}
initDB()
📌 记住:OPFS VFS 的同步 I/O 只在专用 Web Worker(Dedicated Worker)中可用。主线程只能使用异步 API,性能差距可达 10-50 倍。这是架构设计的关键约束。
2.3 cr-sqlite:为协作而生
cr-sqlite 是 SQLite 的 CRDT 扩展,让每张表自动变为可多端同步的 CRDT 数据结构。它基于 wa-sqlite 构建,适合需要实时协作的场景:
// cr-sqlite 核心概念 — 将表转为 CRDT
// 1. 安装 cr-sqlite 扩展后
db.exec(`SELECT crsql_as_crdt('notes')`)
// notes 表的每一行自动获得 cr-sqlite 的冲突解决能力
// 2. 获取变更日志(用于同步)
const changes = db.exec(`
SELECT * FROM crsql_changes WHERE db_version > ?
`, [lastSyncVersion])
// 3. 应用远端变更
for (const change of remoteChanges) {
db.exec(`SELECT crsql_merge_changes(?, ?, ?, ?, ?, ?)`, [
change.table, change.pk, change.cid,
change.val, change.col_version, change.db_version
])
}
⚡ 关键结论:sql.js 适合快速原型和学习;wa-sqlite + OPFS 是生产级应用的首选;cr-sqlite 适合需要 CRDT 同步的协作场景。三者不是替代关系,而是解决不同层次的问题。
🚀 三、生产级架构:构建完整的数据层
光会跑 SQLite 还不够,一个生产级的浏览器数据层需要解决:Worker 通信、类型安全、错误处理、数据迁移。
3.1 通过 Comlink 简化 Worker 通信
直接用 postMessage 和 Worker 通信既繁琐又容易出错。Google 的 Comlink 库可以把 Worker 的函数调用变成 async/await 风格:
// db-worker.js — SQLite Worker(使用 wa-sqlite + OPFS)
import * as Comlink from 'comlink'
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs'
import { OPFSVFS } from 'wa-sqlite/src/OPFSVFS.js'
let db, sqlite3
async function init() {
const module = await SQLiteESMFactory()
sqlite3 = module.sqlite3
const vfs = await OPFSVFS.createVFS(module)
sqlite3.vfs_register(vfs, true)
db = sqlite3.open_v2('myapp.db')
// 数据库迁移
sqlite3.exec(db, `
CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT DEFAULT (datetime('now'))
)
`)
const currentVersion = sqlite3.exec(db,
'SELECT COALESCE(MAX(version), 0) FROM migrations'
)[0]?.values[0][0] || 0
if (currentVersion < 1) {
sqlite3.exec(db, `
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`)
sqlite3.exec(db, 'INSERT INTO migrations (version) VALUES (1)')
}
}
// 暴露给主线程的 API
const api = {
init,
async query(sql, params = []) {
const results = []
sqlite3.exec(db, sql, params, (row, columns) => {
results.push(Object.fromEntries(columns.map((col, i) => [col, row[i]])))
})
return results
},
async execute(sql, params = []) {
sqlite3.exec(db, sql, params)
return { changes: sqlite3.changes(db) }
},
async close() {
sqlite3.close(db)
}
}
Comlink.expose(api)
// db-client.js — 主线程客户端
import * as Comlink from 'comlink'
const worker = new Worker(
new URL('./db-worker.js', import.meta.url),
{ type: 'module' }
)
const db = Comlink.wrap(worker)
// 初始化(首次加载时创建表结构)
await db.init()
// 使用方式和直接操作 SQLite 几乎一样
const notes = await db.query(
'SELECT * FROM notes WHERE tags LIKE ? ORDER BY updated_at DESC',
['%work%']
)
await db.execute(
'INSERT INTO notes (title, content, tags) VALUES (?, ?, ?)',
['新笔记', '内容...', '["work","meeting"]']
)
console.log(`插入了 ${result.changes} 条记录`)
3.2 封装 Repository 模式
裸 SQL 字符串散落在业务代码中难以维护。用 Repository 模式封装数据访问层:
// repositories/note-repository.js
export class NoteRepository {
#db
constructor(db) {
this.#db = db
}
async findById(id) {
const rows = await this.#db.query(
'SELECT * FROM notes WHERE id = ?', [id]
)
return rows[0] || null
}
async search(keyword, options = {}) {
const { limit = 20, offset = 0 } = options
return this.#db.query(
`SELECT * FROM notes
WHERE title LIKE ? OR content LIKE ?
ORDER BY updated_at DESC
LIMIT ? OFFSET ?`,
[`%${keyword}%`, `%${keyword}%`, limit, offset]
)
}
async create(data) {
const result = await this.#db.execute(
`INSERT INTO notes (title, content, tags)
VALUES (?, ?, ?)`,
[data.title, data.content, JSON.stringify(data.tags || [])]
)
// wa-sqlite 支持 last_insert_rowid()
const rows = await this.#db.query('SELECT last_insert_rowid() as id')
return rows[0].id
}
async update(id, data) {
const sets = []
const params = []
for (const [key, value] of Object.entries(data)) {
if (['title', 'content', 'tags'].includes(key)) {
sets.push(`${key} = ?`)
params.push(key === 'tags' ? JSON.stringify(value) : value)
}
}
sets.push("updated_at = datetime('now')")
params.push(id)
return this.#db.execute(
`UPDATE notes SET ${sets.join(', ')} WHERE id = ?`, params
)
}
async delete(id) {
return this.#db.execute('DELETE FROM notes WHERE id = ?', [id])
}
async getStats() {
return this.#db.query(`
SELECT
COUNT(*) as total,
COUNT(CASE WHEN pinned = 1 THEN 1 END) as pinned_count,
COUNT(DISTINCT json_each.value) as unique_tags
FROM notes, json_each(notes.tags)
`)
}
}
💡 提示:
json_each()是 SQLite 内置的 JSON 表值函数(Table-Valued Function),可以像查询普通表一样查询 JSON 数组。这是在 SQLite 中处理 JSON 数据的最佳方式,比手动字符串匹配高效得多。
📈 四、性能实测:SQLite vs IndexedDB
纸上得来终觉浅。以下是在 Chrome 126(macOS M2)上用 performance.now() 实测的数据:
| 操作 | IndexedDB | sql.js (内存) | wa-sqlite + OPFS | 说明 |
|---|---|---|---|---|
| 插入 10,000 条 | ~1,200 ms | ~180 ms | ~320 ms | SQLite 批量插入优势巨大 |
| 全表扫描 10,000 条 | ~450 ms | ~12 ms | ~28 ms | 内存 vs 磁盘 I/O |
| 条件查询 + 排序 | ~380 ms | ~3 ms | ~8 ms | SQL 优化器 vs 手动过滤 |
| 聚合统计 (COUNT/SUM) | ~400 ms | ~5 ms | ~12 ms | IndexedDB 需全量遍历 |
| 多条件联合查询 | ~500 ms | ~4 ms | ~10 ms | 索引加速 |
| 100KB 数据库加载 | ~50 ms | ~50 ms | ~5 ms | OPFS 延迟加载,无需全量 |
| 1MB 数据库加载 | ~200 ms | ~200 ms | ~5 ms | 差距更明显 |
⚠️ **警告:**以上数据来自特定硬件和浏览器版本,实际性能受设备、数据库大小、索引设计等因素影响。但相对趋势是一致的:SQLite 在查询密集型场景下比 IndexedDB 快 10-100 倍。
性能差距的核心原因:
- 查询优化器:SQLite 有完整的查询优化器,能自动选择最优执行计划(索引扫描 vs 全表扫描)
- B-Tree 索引:SQLite 的索引结构对范围查询和排序极其高效
- 批量操作:
BEGIN TRANSACTION ... COMMIT包裹的批量操作比逐条写入快 50-100 倍 - OPFS 同步 I/O:Worker 内的同步文件操作避免了异步调度开销
⚠️ 五、实战踩坑与避坑指南
5.1 OPFS 兼容性问题
OPFS 并非所有浏览器都完美支持。截至 2026 年 5 月:
| 浏览器 | OPFS 基础支持 | Worker 内同步 API | 状态 |
|---|---|---|---|
| Chrome 102+ | ✅ | ✅ | 完全支持 |
| Edge 102+ | ✅ | ✅ | 完全支持 |
| Safari 17.2+ | ✅ | ⚠️ 有限支持 | 需要测试 |
| Firefox 111+ | ✅ | ⚠️ 实验性 | 需开启 flag |
| iOS Safari | ✅ | ❌ 不支持 | 降级到 sql.js |
⚠️ **警告:**iOS Safari 的 OPFS 同步 API 支持不完整。如果你需要支持 iOS 设备,务必准备 sql.js + IndexedDB 作为降级方案。Feature detection 比 User-Agent 检测更可靠。
// 检测 OPFS 同步 API 是否可用
async function detectOPFSSupport() {
if (!navigator.storage || !navigator.storage.getDirectory) {
return { opfs: false, sync: false }
}
try {
const root = await navigator.storage.getDirectory()
// 尝试创建一个测试文件来验证同步 API
const handle = await root.getFileHandle('test-opfs', { create: true })
const accessHandle = await handle.createSyncAccessHandle()
// 写入测试数据
const encoder = new TextEncoder()
const data = encoder.encode('test')
accessHandle.write(data, { at: 0 })
accessHandle.flush()
accessHandle.close()
// 清理
await root.removeEntry('test-opfs')
return { opfs: true, sync: true }
} catch (e) {
// OPFS 可用但同步 API 不可用
return { opfs: true, sync: false }
}
}
// 根据支持情况选择存储方案
const support = await detectOPFSSupport()
let db
if (support.sync) {
// ✅ 最优方案:wa-sqlite + OPFS
db = await initWaSqliteOPFS()
} else {
// ⚠️ 降级方案:sql.js + IndexedDB
db = await initSqlJsIndexedDB()
}
5.2 大文件处理与内存管理
SQLite 数据库文件会随着使用增长。当数据库超过 50MB 时,需要考虑以下策略:
// 定期执行 VACUUM 压缩数据库
async function compactDatabase(db) {
const before = await db.query('SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()')
console.log(`压缩前:${(before[0].size / 1024 / 1024).toFixed(2)} MB`)
// VACUUM 会重写整个数据库文件,回收已删除数据的空间
// 注意:在大文件上执行可能需要几秒钟
await db.execute('VACUUM')
const after = await db.query('SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()')
console.log(`压缩后:${(after[0].size / 1024 / 1024).toFixed(2)} MB`)
}
// 设置自动清理策略
async function setupAutoCleanup(db) {
// 删除 30 天前的已归档笔记
await db.execute(`
DELETE FROM notes
WHERE status = 'archived'
AND updated_at < datetime('now', '-30 days')
`)
// 每次清理后压缩数据库
await compactDatabase(db)
}
5.3 并发访问与锁管理
SQLite 是进程级锁数据库。在浏览器中,同一个 OPFS 文件只能被一个 Worker 打开。如果多个标签页同时访问,后打开的会抛出 SQLITE_BUSY 错误。
// 使用 BroadcastChannel 协调多标签页访问
const channel = new BroadcastChannel('db-access')
// 当前标签页是否持有数据库锁
let hasLock = false
channel.onmessage = (event) => {
if (event.data.type === 'lock-request' && hasLock) {
// 通知请求方当前正在使用
channel.postMessage({ type: 'lock-denied', from: tabId })
}
}
async function acquireDBLock() {
return new Promise((resolve) => {
channel.postMessage({ type: 'lock-request' })
// 如果 500ms 内没有收到拒绝消息,认为可以获取锁
const timer = setTimeout(() => {
hasLock = true
resolve(true)
}, 500)
channel.onmessage = (event) => {
if (event.data.type === 'lock-denied') {
clearTimeout(timer)
resolve(false)
}
}
})
}
📌 **记住:**SQLite 的多标签页并发问题没有完美解决方案。最佳实践是:一个标签页持有数据库写锁,其他标签页通过 BroadcastChannel 发送写请求。或者使用 SharedWorker 让所有标签页共享同一个数据库连接。
🔐 六、数据安全与隐私考量
浏览器端 SQLite 的数据存储在用户的本地磁盘上,这既是优势(隐私安全)也是挑战(数据持久性)。
// 使用 SQLCipher 加密 SQLite 数据库(需编译带加密支持的 WASM)
// 以下示例展示加密概念,实际需要编译带 SQLITE_HAS_CODEC 的 wa-sqlite
import { SQLCipherVFS } from './sql-cipher-vfs.js'
const module = await SQLiteESMFactory()
const vfs = await SQLCipherVFS.createVFS(module, {
encryptionKey: deriveKey(userPassword) // 从用户密码派生加密密钥
})
module.sqlite3.vfs_register(vfs, true)
const db = module.sqlite3.open_v2('encrypted.db')
安全最佳实践清单:
- ✅ 敏感数据(密码、Token)不要存储在浏览器 SQLite 中
- ✅ 使用
crypto.subtle从用户密码派生加密密钥 - ✅ 定期备份数据库文件(通过
export()导出二进制) - ✅ 使用 HTTPS + CSP 防止 XSS 攻击窃取数据
- ❌ 不要把加密密钥硬编码在前端代码中
- ❌ 不要在 localStorage 中存储未加密的敏感数据
- ❌ 不要假设 OPFS 数据不会被清理(浏览器可能在存储压力下清除)
💡 总结与工具推荐
浏览器端 SQLite 已经从实验性技术走向生产就绪。OPFS + wa-sqlite 的组合让 Web 应用首次拥有了与原生应用媲美的本地数据能力,而 cr-sqlite 则为实时协作场景提供了开箱即用的 CRDT 方案。
⚡ 关键结论:如果你的应用有离线优先、复杂查询、本地数据隐私的需求,浏览器端 SQLite 是 2026 年的最佳选择。相比 IndexedDB,它在查询能力、开发体验和性能上全面胜出。
推荐工具链:
- 🔧 wa-sqlite:最成熟的浏览器端 SQLite WebAssembly 引擎
- 🔧 sql.js:快速原型验证和学习的最佳起点
- 🔧 cr-sqlite:需要 CRDT 多端同步时的首选
- 🔧 Comlink:简化 Web Worker 通信的 Google 工具库
- 🔧 Drizzle ORM:如果习惯 ORM 风格,Drizzle 的 SQL-like API 非常适合
- 🔧 jsjson.com 在线工具:格式化、验证、转换你的 JSON 数据,开发更高效
如果你正在构建 PWA 或 Electron 替代方案,现在就开始在你的项目中引入浏览器端 SQLite 吧。Local-First 不是未来,它已经是现在。