桌面应用装好之后,用户的行为基本是固定的:从桌面或开始菜单双击图标,不管前面是不是已经开着一个。这时候正确的结果是「把已有的窗口叫到前面来」,不是「再开一个」,也不是「什么也不发生」。
我那个应用有一段时间就是第三种。双击之后鼠标沙漏转一下就没了,任务栏没变化,窗口也没出来。任务管理器里看进程数,两个。过一会儿又变回一个。
现象:双击图标,什么也没发生
两个进程是对的,问题出在第二个进程的收尾。它的流程是这样:起来、发现已经有实例在跑、拿不到锁、于是退出。前面那个实例该被通知一声,然后把窗口亮出来 —— 但这一步我没写。
所以用户看到的是「什么都没发生」,实际上发生了四件事,只是全在屏幕外。
锁是怎么判的:它不看 exe,看目录
拿锁就一句:
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', (event, argv, workingDirectory) => {
// 这里必须写点什么,否则用户看到的就是没反应
});
app.whenReady().then(createWindow);
}
这段要在 ready 之前执行。放到 whenReady 里面就晚了,那时候窗口已经在建了。
容易搞错的地方是这把锁的身份到底是什么。它不是「这个 exe 只能跑一个」,判断依据是 userData 目录。Electron 在这个目录上做标记,第二个进程起来只要发现这个标记在,就判定已有实例。
这个细节能解释很多看起来不相关的现象:同一个 exe,换个 userData 目录就能再开一个;不同版本的两份 exe,只要 userData 目录是同一个,就只有一个能起来。锁跟着数据目录走,不跟着程序走。
second-instance 里必须做的事
这个事件就是「第二个进程临死前给你带的话」。不带任何处理的话,那句 app.quit() 就是纯粹的一条命。
最少要做的三件事,顺序不能换:
let mainWin = null;
app.on('second-instance', (event, argv, workingDirectory) => {
if (!mainWin) return;
if (mainWin.isMinimized()) mainWin.restore();
if (!mainWin.isVisible()) mainWin.show();
mainWin.focus();
const file = pickFileArg(argv, workingDirectory);
if (file) openFile(file);
});
为什么三个都要写:窗口最小化的时候,focus() 不激活它,得先 restore();窗口被 hide() 到托盘的时候,isVisible() 是 false,focus() 同样没用,得 show()。只写一个 focus() 的话,在「窗口没最小化也没隐藏」这一种情况下好用,其余情况还是没反应 —— 而这种时灵时不灵的 bug 最难查,因为你自己测试的时候窗口恰好是正常状态。
另外两件容易漏的:
1. argv 在 Windows 上第一个元素是 exe 路径,不是业务参数。用户把文件拖到图标上、或者右键「用我的应用打开」,文件路径在第二个位置。开发模式跑 electron . 的时候还会多一个 .,所以解析的时候要看这个参数是不是以 - 开头、是不是存在,别硬取 argv[1]。
2. 相对路径要用 workingDirectory 拼。第二个进程的当前工作目录是它自己被启动的地方,跟第一个进程的 cwd 没关系。直接拿 argv[1] 去 fs.readFile,在第一个实例里会解析成别的路径。
还有个第四参数 additionalData,可以传一个对象。用途是第二个进程在退出前把「用户想干什么」告诉第一个实例,比解析 argv 靠得住。缺点是只有你自己启动的第二个进程能带,用户双击图标不带。
开发时改代码,窗口不出来了
这个坑我踩了不止一次,表现是:改了主进程代码,终端里热重启的日志刷完了,窗口没出来。
原因是旧进程没退干净。新进程起来拿锁,发现标记还在,判定「已有实例」,直接 quit。日志上你不会看到报错,最多看到一行退出。这个标记不是靠进程活着维持的,进程崩了、被杀掉,标记也可能还在。
几个让旧进程退不干净的原因,都见过:
1. before-quit 里挂了异步清理(关数据库、flush 日志、等 utilityProcess 退出),没有 event.preventDefault() 之后手动退出的闭环,退出流程就卡在那儿。
2. utilityProcess 没 kill()。主进程退了,子进程还挂着,父进程得等它。
3. 有没清的 setInterval、没关的 watcher、没 destroy() 的窗口。
怎么确认是锁的问题而不是新代码的问题:给它换一个 userData 目录再启动。命令行加一个 --user-data-dir 就行:
# 打包好的 exe
"哇哦.exe" --user-data-dir="D:\tmp\wow-debug"
# 开发模式
npx electron . --user-data-dir="D:\tmp\wow-debug"
换了目录窗口就出来了,说明代码没问题,是被上一把锁挡住了。这一步能把排查范围砍掉一半,比在进程列表里翻快得多。
治本还是得让退出真的退出。我的做法是在 before-quit 里只做同步的收尾,异步的放到 will-quit 里并设一个超时兜底,超时就 app.exit(0)。宁可让用户丢一条正在写的日志,也不要留一个僵尸锁。
用独立 userData 目录把两个实例隔开
上面那个技巧反过来用,就是一个正经的工作方式:调试实例和生产实例各用一个目录,并排跑。
换 userData 目录换来的是彻底的隔离,不只是锁。一共三样东西会分开:
1. 单实例锁。两把不同的锁,互不干扰。
2. Chromium 那一套。缓存、Cookie、localStorage、IndexedDB 都在 userData 下面,分开之后调试实例是干净的,你清缓存也不会影响自己天天在用的那份配置。
3. 你自己的数据。数据库、日志、上传文件如果都放在 app.getPath('userData') 下面,换目录就是换了一整套数据。
第三点是我最需要的场景。改表结构之前想拿真实数据的副本试迁移,绝对不能拿正在用的那个库当试验品;把 userData 整个拷一份到临时目录,用 --user-data-dir 指过去,随便折腾,跑崩了删掉重来。这比「先备份再改」安心得多,因为备份你还得记得恢复,而目录隔离天然就是两份。
第二种场景:应用我自己天天在用,改完想看效果又不想关掉手头这个。开着两个实例看同一份界面比对着看,比来回切换直观。
有个前提要注意:两个实例不能共享同一个数据库文件、不能抢同一个本地端口。目录隔离解决了数据库(路径跟着 userData 走),端口要自己处理 —— 我那个应用里的几个本地 HTTP 服务是懒启动的,端口从固定的基址往上找第一个空的,所以第二个实例会自己挪到下一个端口,不冲突。如果你的端口是写死的,并排跑的时候第二个实例会起不来。
打包之后锁对不上的两种情形
userData 目录的默认位置由应用名决定。开发时取 package.json 里的 name,打包之后取 productName(或者安装包配置里指定的那个)。这两个不一致的时候,就是两个目录、两把锁 —— 开发实例和安装版的实例能同时开。这其实挺方便,知道原因就行,别当成 bug 查半天。
真正是 bug 的是便携版。绿色免安装的包每次运行都解压到不同的临时目录,如果 userData 跟着 exe 走,那每次启动都是全新的目录、全新的锁,单实例等于没做。用户双击五次就有五个进程,五个都在往各自的目录里写数据。
修法是把 userData 固定到 exe 同级的一个固定位置,并且这行要在 ready 之前执行:
if (isPortable) {
app.setPath('userData', path.join(path.dirname(app.getPath('exe')), 'data'));
}
注意 app.getPath('exe') 拿到的是真实 exe 路径,在便携版解压出来的临时目录里。如果你要固定到「用户把 exe 放的那个目录」,那得用 process.env.PORTABLE_EXECUTABLE_DIR,electron-builder 的 portable 目标会注入这个变量。
macOS 上走的是另一条路
macOS 系统层面就保证从 Finder 再点一次不会起新进程,它只会把已有 app 激活。所以 second-instance 在 macOS 上基本不会触发,你写了也不算错,只是用不上。
macOS 上对应的是两个事件:
1. activate:点 Dock 图标、或者从 Finder 再点一次。窗口全关了之后要在这里重新建窗口,这也是 macOS 上「关掉所有窗口应用不退出」的实现位置。
2. open-file:把文件拖到 Dock 图标上。这个事件可能在 ready 之前就触发 —— 用户拖文件的时候应用还没启动。所以监听要写在最外层,收到的路径先缓存起来,等窗口建好再处理。写成 whenReady().then(() => app.on('open-file', ...)) 就会丢掉第一次拖进来的文件。
这两条跟单实例是同一个需求的两个入口:都是「用户在应用已经在跑的时候又点了一次」。
最后一句,单实例锁的目的从来不是「防止开两个」。它是「保证第二次启动能通知到第一次」,让用户那一下点击有回音。锁本身只是一句判断,回音得自己写。