如果只是把SQLite当作服务端嵌入式数据库来用,确实很难和Service Worker产生交集。但在PWA离线场景中,Service Worker可以拦截请求、提供后台计算能力,而SQLite通过WebAssembly编译后能在Worker环境运行。将两者结合,页面即使完全断网,也能执行复杂SQL查询,不再受限于IndexedDB简单的键值读取。本文将围绕注册环节展开,说明从页面注册Service Worker到在Worker内部初始化SQLite并持久化的完整路径。

Service Worker注册与SQLite运行边界
注册代码放在页面主线程中,核心是 navigator.serviceWorker.register()。很多项目习惯将sw.js放在站点根目录,再把scope设为 / 或 /app/。作用域决定Service Worker能拦截哪些请求,也直接影响后续importScripts加载SQLite相关脚本时是否会遇到路径越界问题。例如sw.js位于 /static/sw.js,但scope设置成 / ,浏览器会直接报错,因为脚本必须位于作用域范围内。注册时最好使用相对路径,并让Service Worker文件与后续要加载的wasm文件处于同一目录或子目录。
Service Worker运行在独立的Worker线程中,没有window、document和localStorage等主线程API。它可以使用IndexedDB、CacheStorage和fetch,也支持WebAssembly。这意味着SQLite Wasm库可以在这里加载,但必须通过importScripts或者动态import方式引入。importScripts在Service Worker全局作用域下同步执行,适合加载sql-wasm.js这类脚本。要特别注意,importScripts的路径受Service Worker作用域限制,不能加载作用域外的资源,否则会抛出异常。
另一个容易忽略的点是注册后的激活时机。默认情况下,新Service Worker会等待旧版本完全释放页面控制权,这会导致第一次访问时代码没有立即生效。为了让数据库初始化尽快完成,通常会在install阶段调用self.skipWaiting(),在activate阶段调用clients.claim()。这一系列动作是把SQLite嵌入Service Worker的前提,也直接影响后续消息通道是否能立刻使用。
在Service Worker内部初始化SQLite并写入IndexedDB
sql.js是SQLite编译到WebAssembly的常用实现,初始化时需要调用initSqlJs函数。该函数通常在主线程通过import加载,但在Service Worker里可以使用importScripts引入全局脚本。initSqlJs接受locateFile回调,用来告诉它wasm文件的真实路径。因为Service Worker的作用域限制,建议将sql-wasm.js和sql-wasm.wasm放在同一目录,并用相对路径返回。
importScripts('/lib/sql-wasm.js');
let db;
let SQL;
self.addEventListener('install', event => {
self.skipWaiting();
event.waitUntil(initDatabase());
});
self.addEventListener('activate', event => {
event.waitUntil(clients.claim());
});
async function initDatabase() {
const locateFile = file => `/lib/${file}`;
SQL = await initSqlJs({ locateFile });
const saved = await loadFromIndexedDB();
db = saved ? new SQL.Database(saved) : new SQL.Database();
}
初始化完成后得到的是一个数据库实例,它完全存在于内存中。如果直接刷新页面或Service Worker被终止,内存数据库就没了。因此需要把内存中的数据库二进制导出,并写入IndexedDB。sql.js的Database对象提供export方法,返回Uint8Array。IndexedDB可以存储二进制数据,但不同类型的浏览器对Uint8Array的处理略有差异,稳妥做法是先转换为ArrayBuffer再写入。
const DB_NAME = 'sqlite-store';
const STORE_NAME = 'files';
function openIDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function saveToIndexedDB(buffer) {
const idb = await openIDB();
const tx = idb.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(buffer, 'main');
return new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
async function loadFromIndexedDB() {
const idb = await openIDB();
const tx = idb.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get('main');
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function persistDatabase() {
const binary = db.export();
return saveToIndexedDB(binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength));
}
这里需要注意write事务不能跨异步操作,否则事务会自动提交或报错。通常把put或get放在事务创建后的同一任务中执行,再用transaction的oncomplete或请求的onsuccess拿到结果。在Service Worker里IndexedDB操作又会受到事件循环影响,如果初始化逻辑放在install阶段,最好用event.waitUntil包住整个Promise链,避免Service Worker在写入完成前被终止。
除了sql.js,官方提供的@sqlite.org/sqlite-wasm也支持在Worker中使用,并可以结合OPFS获得更接近原生文件的持久化。不过OPFS在Service Worker中的可用性需要实测,不同浏览器表现不一致。如果目标是兼容更多环境,IndexedDB方案仍然是最稳妥的选择。
建立页面与Service Worker的消息通道
数据库初始化之后,页面不能直接访问Service Worker内部的db变量,必须通过postMessage发送查询指令。Service Worker通过message事件监听,读取event.data里的SQL语句,执行db.exec后把结果用MessageChannel回传。这里的关键是Service Worker只能给发送消息时携带的MessagePort回复,直接用event.source.postMessage也可以,但MessageChannel更适合请求和响应一一对应的场景。
self.addEventListener('message', async event => {
const data = event.data;
if (data.type === 'query') {
try {
const result = db.exec(data.sql);
event.ports[0].postMessage({ ok: true, result });
} catch (err) {
event.ports[0].postMessage({ ok: false, error: err.message });
}
}
if (data.type === 'write') {
db.run(data.sql);
await persistDatabase();
event.ports[0].postMessage({ ok: true });
}
});
页面侧发送查询的代码相对简单,先确认navigator.serviceWorker.controller已经存在,也就是Service Worker已经控制当前页面。然后创建MessageChannel,把port2转移到Worker,同时保留port1监听回复。如果用户刷新页面后立即查询,controller可能还是null,这时需要等navigator.serviceWorker.ready或监听controllerchange事件。
async function queryFromSW(sql) {
const reg = await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => {
navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true });
});
}
const channel = new MessageChannel();
return new Promise((resolve, reject) => {
channel.port1.onmessage = event => {
event.data.ok ? resolve(event.data.result) : reject(new Error(event.data.error));
};
navigator.serviceWorker.controller.postMessage(
{ type: 'query', sql },
[channel.port2]
);
});
}
在Service Worker中执行SQL时,db.exec会返回一个数组,每条SELECT语句对应一个对象,包含columns和values两个字段。如果查询结果很大,直接通过postMessage传回可能造成内存压力,建议在Worker侧先做分页或聚合,或者只返回必要的字段。对于写操作,需要每次执行后调用db.export并写入IndexedDB,否则数据只存在于内存,一旦Worker被回收就丢失。
缓存优先与版本升级的实践建议
把SQLite放进Service Worker后,页面可以不再依赖网络请求获取数据,但缓存优先策略仍要谨慎设计。对于查询类操作,可以先查本地SQLite;对于数据更新操作,如果在线则把新数据写入SQLite并同步到服务端,如果离线则先记录操作日志,待网络恢复后再重放。缓存优先不是所有请求都从缓存返回,而是区分资源类型和数据内容。静态资源可以用CacheStorage缓存,结构化数据交给SQLite处理。
版本升级时常见的问题是旧数据库结构没有迁移。可以在IndexedDB里单独存一个schema_version值,当Service Worker更新到新版本时,读取这个版本号,和当前代码中的版本对比。如果不一致,就执行ALTER TABLE语句或创建新表,再更新版本号。SQLite支持ALTER TABLE ADD COLUMN,但不支持直接修改或删除列,所以最好在设计初期就预留扩展字段,避免迁移成本过高。
还需要注意Service Worker文件的更新机制。浏览器会逐字节对比sw.js,如果内容发生变化就触发新的安装流程。如果只是修改了SQLite初始化脚本或wasm文件,而sw.js本身没有变化,浏览器不会重新执行install,旧逻辑仍然生效。因此只要调整了数据库初始化相关代码,尽量在sw.js中修改一行注释或版本号,强制触发更新。配合skipWaiting和clients.claim,可以尽快让新逻辑接管页面。
总结来说,SQLite与Service Worker注册并不是两个孤立步骤。注册时作用域设计会影响后续脚本加载,初始化时持久化策略决定离线数据是否可靠,消息通道则负责把数据库能力暴露给页面。把这三层理顺,才能构建一个真正可用的离线数据应用。
SQLiteService Worker离线存储修改时间:2026-09-26 06:30:39