sql.js WASM 实战:浏览器里跑 SQLite

本文面向:想了解 ChatCrystal 数据层实现或评估 sql.js 方案的开发者。 预计阅读时间:12 分钟


传统 SQLite 需要原生 C 编译,无法直接在浏览器或纯 JS 环境中运行。sql.js 通过 WebAssembly 解决了这个问题------把 SQLite 编译成 WASM,让同一份 SQL 引擎跑在 Node.js、浏览器、Electron 等任何支持 WASM 的环境里,零原生依赖。

ChatCrystal 的整个数据层就建立在 sql.js 之上。本文以 ChatCrystal 的实际代码为例,从初始化到事务、从 Schema 设计到自动保存,完整走一遍 sql.js 的生产级用法。

sql.js 是什么

sql.js 是 SQLite 的 WebAssembly 移植版本。它把 SQLite 的 C 源码通过 Emscripten 编译为 .wasm 文件,再用一层薄 JS wrapper 暴露 API。核心特点:

  • 零原生编译:不需要 node-gyp、不需要系统装 SQLite、不需要 C 编译器
  • 内存数据库:整个数据库加载到内存中操作,读写极快
  • 手动持久化:不像原生 SQLite 直接读写文件,需要你显式 export/import 二进制数据
  • 跨平台:同一份代码在 Windows、macOS、Linux、浏览器里行为一致

为什么 ChatCrystal 选 sql.js

ChatCrystal 是一个 Electron + Node.js 的桌面应用。选 sql.js 而不是 better-sqlite3 的原因:

  1. Electron 打包简单 :better-sqlite3 需要为每个目标平台编译原生 addon,Electron 版本升级时经常出问题。sql.js 只需打包一个 .wasm 文件
  2. 跨环境一致性:同一个数据库层在 Node.js 开发环境和 Electron 生产环境完全相同
  3. 零安装摩擦 :用户 npm install 即可使用,不需要系统预装任何东西

安装和初始化

bash 复制代码
npm install sql.js

初始化数据库是整个流程的第一步。ChatCrystal 的 initDatabase() 做了四件事:加载 WASM、创建/恢复数据库、设置 PRAGMA、执行 Schema 迁移:

ts 复制代码
import initSqlJs, { type Database } from 'sql.js';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';

export async function initDatabase(): Promise<Database> {
  // 1. 加载 WASM 引擎
  const SQL = await initSqlJs();

  // 2. 从磁盘恢复或新建数据库
  if (existsSync(DB_PATH)) {
    const buffer = readFileSync(DB_PATH);
    db = new SQL.Database(buffer);
  } else {
    db = new SQL.Database();
  }

  // 3. 设置关键 PRAGMA
  db.run('PRAGMA journal_mode = WAL;');
  db.run('PRAGMA foreign_keys = ON;');

  // 4. 执行 Schema 迁移
  applySchemaMigrations(db);

  saveDatabase();
  return db;
}

两个 PRAGMA 很关键:foreign_keys = ON 让外键约束生效(SQLite 默认关闭),journal_mode = WAL 启用预写日志模式。

Electron 打包后,WASM 文件位置不同,需要通过 locateFile 指定路径:

ts 复制代码
const sqlJsOptions = process.env.ELECTRON_PACKAGED
  ? { locateFile: () => join(resourcesPath, 'sql-wasm.wasm') }
  : undefined;
const SQL = await initSqlJs(sqlJsOptions);

核心 API:exec / run / getRowsModified

sql.js 的 API 表面积很小,核心就三个方法。

db.exec() ------ 查询数据

exec 执行 SELECT 查询,返回一个数组,每个元素包含 columns(列名数组)和 values(二维数组):

ts 复制代码
const result = db.exec(
  'SELECT file_size, file_mtime FROM conversations WHERE id = ? AND source = ?',
  [meta.id, meta.source],
);

// result 的结构:
// [{ columns: ['file_size', 'file_mtime'], values: [[12345, '2026-05-18']] }]

这个返回格式虽然精确,但用起来不太方便------你得记住列的顺序去 values 里取值。ChatCrystal 写了一个 resultToObjects() 工具函数来解决这个问题。

db.run() ------ 写入数据

run 用于 INSERT、UPDATE、DELETE 等不返回结果集的操作:

ts 复制代码
db.run(
  'INSERT INTO tags (name) VALUES (?)',
  ['sql.js']
);

run 不返回结果,需要配合 db.getRowsModified() 确认影响行数。

db.getRowsModified() ------ 检查影响行数

ts 复制代码
db.run('UPDATE conversations SET status = ? WHERE id = ?', ['filtered', convId]);
const affected = db.getRowsModified();
console.log(`更新了 ${affected} 条记录`);

resultToObjects:让结果更好用

sql.js 的 exec 返回 {columns, values} 格式,日常开发中我们更习惯对象数组。ChatCrystal 的 resultToObjects 就做这一件事:

ts 复制代码
export function resultToObjects(
  result: { columns: string[]; values: unknown[][] }[],
): Record<string, unknown>[] {
  if (!result.length) return [];
  const { columns, values } = result[0];
  return values.map((row) => {
    const obj: Record<string, unknown> = {};
    columns.forEach((col, i) => {
      obj[col] = row[i];
    });
    return obj;
  });
}

使用前后对比:

ts 复制代码
// 原始格式
const raw = db.exec('SELECT id, title FROM notes LIMIT 3');
// [{ columns: ['id', 'title'], values: [[1, 'Fastify 入门'], [2, 'WASM 实战']] }]

// 转换后
const notes = resultToObjects(raw);
// [{ id: 1, title: 'Fastify 入门' }, { id: 2, title: 'WASM 实战' }]

这个函数虽然只有十几行,但在整个项目中被大量使用,是 sql.js 和应用代码之间的桥梁。

Schema 设计:外键与索引

ChatCrystal 的数据库有 14 张表,定义在 SCHEMA_SQL 常量中。以下是几张核心表的设计:

sql 复制代码
-- 对话表:记录每条导入的会话
CREATE TABLE IF NOT EXISTS conversations (
  id TEXT PRIMARY KEY,
  source TEXT NOT NULL DEFAULT 'claude-code',
  project_dir TEXT NOT NULL,
  project_name TEXT NOT NULL,
  message_count INTEGER DEFAULT 0,
  first_message_at TEXT NOT NULL,
  last_message_at TEXT NOT NULL,
  file_path TEXT NOT NULL,
  status TEXT DEFAULT 'imported',
  created_at TEXT DEFAULT (datetime('now'))
);

-- 消息表:通过外键关联到对话
CREATE TABLE IF NOT EXISTS messages (
  id TEXT PRIMARY KEY,
  conversation_id TEXT NOT NULL,
  type TEXT NOT NULL,
  role TEXT,
  content TEXT NOT NULL,
  timestamp TEXT NOT NULL,
  sort_order INTEGER NOT NULL,
  FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);

-- 标签关联表:多对多关系
CREATE TABLE IF NOT EXISTS note_tags (
  note_id INTEGER NOT NULL,
  tag_id INTEGER NOT NULL,
  PRIMARY KEY (note_id, tag_id),
  FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
  FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

外键的 ON DELETE CASCADE 保证删除一条对话时,其下所有消息和关联数据自动清理。

索引对查询性能至关重要。ChatCrystal 在常用的查询字段上都建了索引:

sql 复制代码
CREATE INDEX IF NOT EXISTS idx_conversations_project ON conversations(project_dir);
CREATE INDEX IF NOT EXISTS idx_conversations_source ON conversations(source);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_embeddings_note ON embeddings(note_id);

Schema 迁移通过 applySchemaMigrations 实现。它用 PRAGMA table_info 检查列是否存在,不存在才 ALTER TABLE,做到幂等迁移:

ts 复制代码
function ensureColumn(db: Database, table: string, column: string, sql: string) {
  const info = db.exec(`PRAGMA table_info(${table})`);
  const columns = info[0]?.values.map((row) => String(row[1])) ?? [];
  if (!columns.includes(column)) {
    db.run(sql);
  }
}

// 使用
ensureColumn(db, 'notes', 'embedding_status',
  "ALTER TABLE notes ADD COLUMN embedding_status TEXT DEFAULT 'pending'");

事务处理:withTransaction

sql.js 支持标准的 BEGIN / COMMIT / ROLLBACK 事务。ChatCrystal 封装了一个 withTransaction 函数,额外支持嵌套事务(通过 SAVEPOINT):

ts 复制代码
export function withTransaction<T>(db: Database, fn: () => T): T {
  const depth = depthMap.get(db) ?? 0;
  const isNested = depth > 0;
  const savepointName = `sp_${depth}`;

  if (isNested) {
    db.run(`SAVEPOINT ${savepointName}`);
  } else {
    db.run('BEGIN');
  }

  setDepth(db, depth + 1);

  try {
    const result = fn();
    if (isNested) {
      db.run(`RELEASE ${savepointName}`);
    } else {
      db.run('COMMIT');
    }
    setDepth(db, depth);
    return result;
  } catch (error) {
    try {
      if (isNested) {
        db.run(`ROLLBACK TO ${savepointName}`);
        db.run(`RELEASE ${savepointName}`);
      } else {
        db.run('ROLLBACK');
      }
    } finally {
      setDepth(db, depth);
    }
    throw error;
  }
}

核心思路:用 WeakMap<Database, number> 追踪每个数据库实例的事务嵌套深度。顶层用 BEGIN/COMMIT,嵌套层用 SAVEPOINT/RELEASE。任何层级出错都能精确回滚到对应的保存点。

使用方式非常简洁:

ts 复制代码
withTransaction(db, () => {
  db.run('INSERT INTO notes (...) VALUES (...)', [...]);
  db.run('INSERT INTO note_tags (...) VALUES (...)', [...]);
  // 任何一步失败,整个事务回滚
});

导入对话时,ChatCrystal 就用 withTransaction 把对话和消息的插入包在一起,保证原子性。

自动保存机制

sql.js 是内存数据库,所有修改只存在于内存中。如果不手动保存,进程退出后数据就丢了。

ChatCrystal 用 30 秒间隔的定时器做自动保存:

ts 复制代码
let saveInterval: ReturnType<typeof setInterval> | null = null;

export function startAutoSave(intervalMs = 30_000): void {
  if (saveInterval) return;
  saveInterval = setInterval(() => saveDatabase(), intervalMs);
}

export function saveDatabase(): void {
  if (!db) return;
  const data = exportDatabasePreservingForeignKeys(db);  // 导出为 Uint8Array,保持 foreign_keys = ON
  const buffer = Buffer.from(data);
  writeFileSync(DB_PATH, buffer);      // 写入磁盘
}

db.export() 把整个数据库序列化为二进制数组,然后写入文件。关闭数据库时也会触发一次保存:

ts 复制代码
export function closeDatabase(): void {
  stopAutoSave();
  if (db) {
    saveDatabase();   // 最后一次保存
    db.close();
    db = null;
  }
}

30 秒是一个折中:太频繁会增加 I/O 开销,太长则丢失数据的风险更大。对于 ChatCrystal 这种桌面应用,30 秒足够安全。

需要注意:db.export() 会重置连接级别的 PRAGMA 设置。ChatCrystal 的 exportDatabasePreservingForeignKeys 在 export 后重新设置 foreign_keys = ON

ts 复制代码
export function exportDatabasePreservingForeignKeys(activeDb: Database): Uint8Array {
  try {
    return activeDb.export();
  } finally {
    activeDb.run('PRAGMA foreign_keys = ON;');
  }
}

性能特点

sql.js 的性能特征和原生 SQLite 有明显区别:

优势:

  • 单条 SQL 执行很快,因为是内存操作,没有磁盘 I/O 延迟
  • 批量插入在事务中表现良好
  • 没有原生模块加载开销

劣势:

  • WASM 调用有固定的桥接开销,高频小查询比原生 SQLite 慢
  • 整个数据库必须加载到内存,不适合 GB 级数据
  • db.export() 对大数据库有明显的序列化成本

实际感受: ChatCrystal 的数据库通常在几 MB 到几十 MB 级别(几万条消息),在这个规模下 sql.js 完全够用。导入 1000 条对话、执行几十次查询,总耗时在秒级。

sql.js vs better-sqlite3 vs 原生 SQLite

维度 sql.js better-sqlite3 原生 SQLite
原生依赖 需要 C 编译器 需要系统库
Electron 打包 简单(一个 .wasm) 复杂(原生 addon) 不适用
浏览器支持 支持 不支持 不支持
持久化方式 手动 export/import 直接读写文件 直接读写文件
性能 内存操作,WASM 桥接开销 原生性能 原生性能
数据规模限制 受内存限制 受磁盘限制 受磁盘限制
适用场景 跨平台、Electron、浏览器 纯 Node.js 服务端 系统级应用

如果你的应用只跑在 Node.js 服务端且不需要跨平台,better-sqlite3 是更好的选择。但如果需要支持浏览器或 Electron 打包,sql.js 几乎是唯一成熟的方案。

下一步


项目地址:github.com/ZengLiangYi...

相关推荐
先吃饱再说2 小时前
我的第一次「Claude Code」实战:用 AI 敲出一个外卖 App 落地页
ai编程
常威正在打来福2 小时前
frontend-design入门指南:OpenClaw/Claude Code/Codex 三平台安装教程
人工智能·aigc·ai编程
wangruofeng3 小时前
GitHub AI 月榜解读:8 大趋势告诉你该关注什么
github·ai编程
爱吃的小肥羊3 小时前
又上新闻!OpenAI 称推翻困扰数学界近 80 年的「平面单位距离猜想」
aigc·openai·ai编程
视觉&物联智能3 小时前
【杂谈】-企业人工智能超越实验:安全拓展的实践路径
人工智能·安全·aigc·agent·agi
码途漫谈3 小时前
让 AI 编程不断线:9Router 的本地模型路由与 Token 节流术
人工智能·ai·开源·ai编程
人月神话-Lee4 小时前
【图像处理】饱和度——颜色的浓淡与灰度化
图像处理·人工智能·ios·ai编程·swift
孟健4 小时前
光会写提示词,用不好 AI Agent
ai编程
love530love4 小时前
MingLi-Bench 项目部署实录:基于 EPGF 架构的工程化实践
人工智能·windows·python·架构·aigc·epgf·mingli-bench