在浏览器端做复杂数据管理时,开发者常面临一个选择:到底用SQLite还是IndexedDB。实际上这两种技术并不互斥,通过合理的架构设计,它们可以在同一个前端项目中承担不同职责,形成互补的存储层。

为什么需要SQLite与IndexedDB共存
SQLite通过WebAssembly移植到浏览器后,具备了执行标准SQL、联表查询、事务回滚的能力,非常适合处理结构化业务数据,比如订单、账目、配置表等。相比之下,IndexedDB是浏览器原生的非关系型存储,支持存储File、Blob、ArrayBuffer等大对象,且写入不阻塞主线程,更适合做离线资源缓存与用户草稿保存。
如果只使用其中一种,往往会顾此失彼。例如纯SQLite方案在存用户上传的几百兆视频时会撑爆WASM内存;纯IndexedDB方案在做月度报表统计时要写大量游标遍历代码,既慢又难维护。因此让两者共存,由SQLite管“小且规整”的数据,IndexedDB管“大且松散”的数据,是更务实的做法。
整体架构设计
我们引入一个统一的存储管理器(StorageManager),对外暴露高层API,内部根据数据类型自动路由。核心原则是:所有结构化记录走SQLite,所有二进制附件与临时草稿走IndexedDB。SQLite运行在Web Worker中,避免查询拖慢界面;IndexedDB因自身异步特性,可在主线程或独立Worker访问。
下表列出了两者的职责划分:
| 存储引擎 | 适用数据 | 访问方式 | 注意事项 |
|---|---|---|---|
| SQLite(WASM) | 用户表、订单、统计结果 | Worker内同步SQL | 内存受限,需控制单表行数 |
| IndexedDB | 图片、视频、草稿JSON | 异步事务 | 不能联表,避免存小字段 |
存储管理器伪代码
下面是一段StorageManager的简化实现,展示如何根据数据特征做路由:
class StorageManager {
constructor() {
this.db = null; // IndexedDB实例
this.sqlWorker = new Worker('sqlite-worker.js');
}
// 保存结构化记录
saveRecord(table, row) {
return new Promise((resolve) => {
this.sqlWorker.postMessage({ type: 'insert', table, row });
this.sqlWorker.onmessage = (e) => resolve(e.data);
});
}
// 保存大文件到IndexedDB
saveFile(key, blob) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction('files', 'readwrite');
tx.objectStore('files').put(blob, key);
tx.oncomplete = () => resolve(key);
tx.onerror = () => reject(tx.error);
});
}
}
SQLite的Worker化集成
将SQLite编译为WASM后,直接在主线程初始化会导致大量CPU计算卡住渲染。正确做法是在Worker里加载sql.js之类的库,主线程通过postMessage下发SQL语句,Worker执行后回传结果。这样即使做千万级数据聚合,页面也不会失去响应。
Worker内部代码大致如下,注意所有特殊字符都已转义:
importScripts('https://ipipp.com/sql-wasm.js');
let db = null;
initSqlJs().then((SQL) => {
db = new SQL.Database();
self.postMessage({ type: 'ready' });
});
self.onmessage = (e) => {
const { sql, params } = e.data;
try {
const stmt = db.prepare(sql);
stmt.bind(params || []);
const rows = [];
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
stmt.free();
self.postMessage({ type: 'result', rows });
} catch (err) {
self.postMessage({ type: 'error', message: err.message });
}
};
IndexedDB的异步封装
原生IndexedDB API基于事件回调,写起来繁琐。我们可以用Promise包装一层,并在对象仓库设计上遵循“大块存、少查询”的原则。例如把用户编辑的富文本草稿整体序列化为一个Blob,用用户ID加时间戳做键,避免频繁更新小字段。
以下代码演示了打开数据库与存放草稿的封装:
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('appStore', 1);
req.onupgradeneeded = () => {
req.result.createObjectStore('files');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function putDraft(id, content) {
return openDB().then((db) => {
return new Promise((res, rej) => {
const tx = db.transaction('files', 'readwrite');
tx.objectStore('files').put(new Blob([content]), 'draft_' + id);
tx.oncomplete = () => res(true);
tx.onerror = () => rej(tx.error);
});
});
}
数据一致性与同步策略
当SQLite里的业务记录引用了IndexedDB里的文件时,需要保证删除记录同时清理附件。由于两者事务机制不同,无法用单一事务覆盖,我们采用“最终一致”方案:SQLite删除行时,把待删文件Key写入一张垃圾表,由后台任务轮询垃圾表并清理IndexedDB,失败则重试。
另一个常见问题是初次加载时SQLite数据库文件本身也可以放在IndexedDB里。我们把导出的SQLite二进制文件作为Blob存进IndexedDB,启动时读取并喂给WASM,这样用户刷新页面后数据不丢,也无需每次从网络拉取。
启动恢复流程示例
async function boot() {
const dbFile = await getFromIDB('sqlite_main');
if (dbFile) {
const buf = await dbFile.arrayBuffer();
sqlWorker.postMessage({ type: 'load', buffer: buf });
} else {
sqlWorker.postMessage({ type: 'init' });
}
}
性能与坑点总结
实测中,SQLite在Worker内执行千条联表查询约耗时二十毫秒,而同样逻辑用IndexedDB游标遍历需两百毫秒以上。但IndexedDB写入五十兆Blob几乎不占主线程时间,SQLite若尝试把同样Blob转成BLOB字段存入,WASM内存会迅速报警。因此职责切分是性能关键。
需要留意的坑包括:WASM内存上限导致SQLite无法无限扩容,应定期归档冷数据到IndexedDB;IndexedDB在隐私模式或部分浏览器中会抛出异常,要有降级到内存态的逻辑;Worker与主线程消息序列化也有开销,批量SQL应合并发送而非逐条调用。
小结
SQLite与IndexedDB共存不是炫技,而是贴合浏览器环境的工程取舍。用SQLite承接它最擅长的结构化与查询,用IndexedDB弥补它不擅长的大对象与离线缓存,再通过统一管理器与Worker隔离,就能在单个前端项目里构建出既强大又流畅的本地存储体系。