Skip to content

关闭窗口后,Electron 应用为什么还在运行

Electron 同时维护窗口生命周期和应用生命周期。窗口销毁只表示一个 BrowserWindow 不再存在,不等于 Main Process 已退出。菜单、托盘、文件监听、本地服务和单实例锁都可能继续由 Main 持有。

五个容易混淆的事件

事件含义常见处理
close窗口准备关闭可阻止关闭并改为隐藏
closed原生窗口已经销毁清空窗口引用
window-all-closed所有窗口均已关闭Windows/Linux 常在此退出
before-quit应用开始退出停止接收新任务
will-quit即将结束进程释放快捷键、锁和后台资源

事件日志至少记录事件名、窗口编号、退出意图和单调时钟。只打印“窗口关闭”无法判断应用是在隐藏、正常退出,还是为更新而重启。

平台策略写在 Main 中

js
let mainWindow

function createWindow() {
  mainWindow = new BrowserWindow({ /* ... */ })
  mainWindow.on('closed', () => {
    mainWindow = undefined
  })
}

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

app.on('activate', () => {
  if (!mainWindow) createWindow()
  else mainWindow.show()
})

macOS 应用关闭最后一个窗口后通常仍留在 Dock,用户再次激活时重建窗口。Windows 和 Linux 的常见策略是在最后一个窗口关闭后退出。这个差异来自应用层策略,不是 Renderer 的页面行为。

单实例先于窗口创建

js
const gotLock = app.requestSingleInstanceLock()

if (!gotLock) {
  app.quit()
} else {
  app.on('second-instance', (_event, argv) => {
    const request = parseOpenRequest(argv)
    if (!request) return
    if (mainWindow?.isMinimized()) mainWindow.restore()
    mainWindow?.show()
    mainWindow?.focus()
    dispatchOpenRequest(request)
  })
}

锁应在创建窗口、注册后台服务之前申请。第二个进程拿不到锁就立即退出;旧进程收到 second-instance 后解析文件或协议参数,再唤醒现有窗口。参数仍是不可信输入,需要校验类型、长度、协议版本和目标路径。

自定义协议统一成内部请求

desklab://open?id=demo 有两条入口:应用未运行时来自启动参数,应用运行时来自第二实例事件。两条入口都应转换成同一种内部对象。

js
{
  source: 'cold-start' | 'second-instance',
  action: 'open',
  documentId: 'demo',
  receivedAt: 1720000000000
}

窗口尚未准备完成时,Main 把请求放入队列;Renderer did-finish-load 后再依序派发。这样冷启动与热唤醒共享解析、校验和业务逻辑。

退出意图是一组状态

关闭窗口、退出应用和安装更新后的重启需要不同状态。可以把应用状态定义为 runningquittingrestarting-for-update,窗口状态定义为 visiblehiddendestroyed。事件处理器根据当前意图决定是否阻止 close、是否停止后台任务,以及最终调用 quit 还是更新器的重启方法。

排查生命周期问题时,先画出窗口状态、应用状态和启动入口三条线,再按时间检查 Main 日志。重复托盘、第二个文档打不开、更新后没有重启,通常都能在这三条线上找到冲突。

别急,先让缓存热一下。