做PWA应用的朋友大概率遇到过这样一个场景:用户已经打开了你的应用,又从桌面图标点了一次,浏览器干脆利落地又开了一个新窗口。两个窗口各自为政,一边写入的数据另一边看不见,等用户发现两边内容对不上,体验就直接崩了。解决这个问题需要两件事配合:一是用Launch Handler API把多次启动收敛到同一个会话,二是把数据真正落到一份共享的本地存储里。SQLite作为嵌入式数据库的代表,配合浏览器端的OPFS文件系统,可以很好地承担第二个角色。

Launch Handler到底解决了什么问题
Launch Handler API是Chromium系浏览器提供的能力,它控制的是当用户通过快捷方式、分享目标等方式启动PWA时,浏览器应该开新窗口还是复用已有窗口。核心配置有两处:manifest里的launch_handler字段,以及运行时的window.launchQueue接口。
先看manifest声明,这里的client_mode决定了启动行为:
{
"launch_handler": {
"client_mode": "focus-client"
}
}client_mode的可选值包括focus-existing、focus-client、navigate-existing和navigate-client。focus-existing表示直接聚焦已有窗口不传数据,navigate-client则会在已有窗口上触发导航,并通过launchQueue把启动参数交给你处理。如果你想既复用窗口又能拿到新启动带来的数据,一般用navigate-client,或者写成数组形式让浏览器按顺序选择第一个支持的值。
运行时接收启动事件依赖launchQueue,这个接口的用法如下:
if ('launchQueue' in window) {
launchQueue.setConsumer((launchParams) => {
// targetURL 携带了新启动的目标地址或参数
if (launchParams.targetURL) {
const params = new URL(launchParams.targetURL).searchParams;
const noteId = params.get('id');
if (noteId) {
openNote(noteId); // 打开对应笔记,而不是新开一份空白状态
}
}
});
}需要特别注意的是,setConsumer设置的消费函数只有在启动事件发生时才被调用。如果应用启动时没有携带launch参数,比如用户直接点图标打开,这个回调可能根本不会执行,所以初始化逻辑不能全部塞在里面,要把它当成额外增量来设计。
用SQLite在浏览器端落地数据
原生SQLite是C写的,浏览器里跑不了,但SQLite官方维护的wasm版本解决了这个问题。目前更推荐直接用官方的sqlite3 wasm构建,它支持OPFS(Origin Private File System)作为持久化后端,数据真正写在浏览器提供的私有文件系统里,刷新页面、关掉窗口都不会丢。
先搭一个基础的数据层,创建一张笔记表:
import sqlite3InitModule from './sqlite3-bundler-friendly.mjs';
const sqlite3 = await sqlite3InitModule();
// 使用OPFS持久化模式打开数据库
const db = new sqlite3.oo1.OpfsDb('/myapp/notes.db');
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT DEFAULT '',
updated_at INTEGER DEFAULT (unixepoch())
);
`);
export function openNote(id) {
const rows = db.exec({
sql: 'SELECT id, title, content FROM notes WHERE id = ?',
bind: [id],
rowMode: 'object',
returnValue: 'resultRows'
});
return rows[0] || null;
}
export function appendNote(title, content) {
db.exec({
sql: 'INSERT INTO notes (title, content) VALUES (?, ?)',
bind: [title, content]
});
return db.exec({
sql: 'SELECT last_insert_rowid() AS id',
returnValue: 'resultRows'
})[0].id;
}选OPFS而不用localStorage的理由很直接:localStorage只能存字符串,容量限制在几MB,而且读写是同步阻塞主线程的。SQLite加上OPFS的组合可以处理几十MB甚至更大的数据量,支持事务和复杂查询,数据一致性的保证完全是两个量级。OPFS目前在Chrome和Edge上可用,Safari和Firefox的支持情况需要在发布前确认,做兼容降级时可以退回到基于IndexedDB的sql.js持久化方案。
完整流程:启动事件与数据库的协作
两个模块各自就位后,关键是怎么串起来。整体思路是:页面加载时无条件执行基础初始化,包括打开数据库、渲染默认列表,然后launchQueue的回调里只处理这次启动带来的增量信息。这样无论用户是第一次冷启动,还是从深链第二次唤起,逻辑都不会乱。
async function main() {
await initDatabase(); // 无条件执行的基础初始化
renderNoteList();
if ('launchQueue' in window) {
launchQueue.setConsumer(async (params) => {
const url = params.targetURL ? new URL(params.targetURL) : null;
const id = url ? url.searchParams.get('id') : null;
if (id) {
const note = openNote(Number(id));
if (note) {
renderNoteDetail(note); // 复用已有窗口渲染
} else {
const newId = appendNote('新笔记', '');
location.hash = `#note-${newId}`;
}
}
});
}
}
main();这里有一个容易踩的坑:既然client_mode选择了navigate-client,浏览器会在已有窗口上触发导航,如果处理不当页面会重新加载一遍,内存里的状态全部丢失。解决办法是把深链设计成hash形式,或者在service worker里拦截导航请求直接返回缓存页面,让页面本身不动,只通过launchQueue传参。
数据一致性还能再进一步吗
即便launch_handler收敛到了单窗口,也建议给SQLite写入加上事务。因为launch事件和用户手动操作可能交错发生,比如用户正在编辑笔记A,深链启动要求打开笔记B,两段写入如果没有事务包裹,中途刷新页面可能留下半截数据。把相关的几次写操作包进BEGIN和COMMIT之间,是SQLite场景下成本最低的保障手段。
另一个值得做的优化是Web Locks API与数据库锁的配合。OPFS的同步访问句柄同一时刻只能有一个worker持有,当出现极端的多标签页情况,比如用户用浏览器标签而不是安装版PWA打开,多个页面争抢数据库文件可能报错。用navigator.locks.request给数据库操作加一把命名锁,请求排队执行,能彻底避免并发写冲突。
整体跑通之后,这套架构的价值不只是解决多实例问题:SQLite的SQL能力让后续加搜索、加标签、加全文索引都变得很自然,而Launch Handler保证了所有入口最终汇聚到同一份数据上。对于一个数据驱动型的PWA来说,这两者的组合是相当扎实的基础。
SQLiteLaunch HandlerPWA修改时间:2026-09-07 12:16:56