桌面应用与网页相比,最大的区别之一是用户对窗口行为的期望完全不同。网页关闭就结束了,但桌面应用常常需要“后台运行”、“托盘驻留”或者“多窗口切换”。这一节将讨论三个实际项目中几乎必会遇到的窗口交互规范,并给出可以直接使用的代码方案。
29.1.1 关闭到托盘:让关闭按钮变成“隐藏”
很多需要长期运行的应用(如聊天软件、下载工具、笔记同步)都不希望用户点击关闭按钮后直接退出程序。理想的行为是:点击 × 时应用隐藏到系统托盘,后台继续工作;用户可以通过托盘图标重新打开窗口或彻底退出。
实现这个行为的关键在于拦截窗口的 close 事件,阻止默认的销毁行为,并改为隐藏窗口。
// main.js 主进程
const { app, BrowserWindow, Tray, Menu } = require('electron');
const path = require('path');
let mainWindow = null;
let tray = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 960,
height: 680,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
mainWindow.loadFile('index.html');
// 拦截关闭事件,不让窗口真的关闭
mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault(); // 阻止默认关闭行为
mainWindow.hide(); // 隐藏窗口
// 可以在这里保存窗口状态,如窗口位置、大小
// 避免用户误以为应用已退出
}
// 如果 app.isQuitting 为 true,则正常关闭
});
}
function createTray() {
tray = new Tray(path.join(__dirname, 'icon.png'));
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
click: () => {
mainWindow.show();
mainWindow.focus();
},
},
{
label: '退出',
click: () => {
app.isQuitting = true; // 设置退出标志
app.quit(); // 触发正常退出流程
},
},
]);
tray.setToolTip('我的应用');
tray.setContextMenu(contextMenu);
// 点击托盘图标也显示窗口(可选)
tray.on('click', () => {
mainWindow.show();
mainWindow.focus();
});
}
app.on('ready', () => {
createWindow();
createTray();
});
// 处理 macOS 的 Cmd+Q 与全部窗口关闭后的退出
app.on('before-quit', () => {
app.isQuitting = true;
});
要点说明:
app.isQuitting是我们自定义的变量,用于区分“用户点击关闭按钮”和“真正的退出操作”。当托盘菜单点击退出或接收到系统退出信号时,才将该标志设为true,这时close事件不会阻止,窗口可以正常销毁。- macOS 的默认行为是关闭所有窗口后应用仍驻留在 Dock,上面的代码也能适配。在 macOS 上如果想在点击 Dock 图标时重新创建窗口,可以在
app.on('activate')中调用mainWindow.show()。 - 实际开发中,建议在隐藏窗口时保存当前的窗口大小和位置,下次显示时恢复,这样用户体验更好。
29.1.2 最小化逻辑:区分“隐藏”与“最小化”
窗口的最小化按钮通常会将窗口缩小到任务栏(Windows)或程序坞(macOS)。但有些应用需要进一步定制这个行为,比如:
- 点击最小化时直接隐藏到托盘,而不是保留任务栏占位;
- 最小化后需要系统托盘图标闪烁提示(类似 QQ 的新消息提醒);
- 在特定场景下禁止最小化(例如临时模态对话框)。
1. 最小化到托盘
如果你希望点击最小化按钮就直接隐藏窗口,可以监听 minimize 事件并立即隐藏:
mainWindow.on('minimize', (event) => {
event.preventDefault(); // 阻止默认最小化行为
mainWindow.hide(); // 直接隐藏窗口
});
这样,任务栏上就不会出现最小化的条目,窗口直接从桌面消失,但进程仍在后台运行。结合托盘菜单显示窗口,体验就和许多即时通讯软件一致。
2. 最小化时的状态提示
如果需要提示用户应用仍在运行,可以在最小化时修改托盘图标或显示气泡通知(Windows)。例如用闪烁图标表示有新消息:
let blinkInterval = null;
const normalIcon = path.join(__dirname, 'icon.png');
const newMsgIcon = path.join(__dirname, 'icon-new.png');
mainWindow.on('minimize', () => {
// 有新消息时图标交替闪烁
let isBlink = false;
blinkInterval = setInterval(() => {
tray.setImage(isBlink ? normalIcon : newMsgIcon);
isBlink = !isBlink;
}, 500);
});
// 当用户恢复窗口时清除闪烁
mainWindow.on('restore', () => {
if (blinkInterval) {
clearInterval(blinkInterval);
blinkInterval = null;
tray.setImage(normalIcon);
}
});
这在聊天、邮件、监控类应用中非常实用。
3. 有条件的最小化
有时我们希望某个临时窗口(比如设置窗口、预览窗口)强制在最前,不允许最小化。可以通过设置窗口属性实现:
const modalWindow = new BrowserWindow({
parent: mainWindow, // 指定父窗口
modal: true, // 模态
minimizable: false, // 禁止最小化按钮
title: '设置',
});
当该窗口打开时,用户必须处理完该窗口才能回到父窗口操作,符合桌面用户的操作习惯。
29.1.3 多窗口协作:管理、通信与聚焦
复杂的桌面应用很少只有一个窗口。比如 VS Code 可以同时打开多个编辑器窗口,浏览器有多个独立窗口,笔记应用可能有“主界面 + 独立笔记窗口”。这些窗口之间需要协作,主要涉及三个问题:窗口管理、窗口间通信和焦点控制。
1. 窗口管理
管理多个窗口最简单的做法是在主进程维护一个 Map 记录所有窗口实例:
const windows = new Map(); // key: 窗口ID, value: BrowserWindow实例
function createNewWindow(id, options) {
const win = new BrowserWindow(options);
windows.set(id, win);
win.on('closed', () => {
windows.delete(id);
});
return win;
}
这样你可以随时根据业务需要获取某个窗口、向特定窗口发送消息,或者列出所有窗口进行统一操作(如应用退出时全部关闭)。
更实际的做法是为不同用途的窗口创建工厂函数。例如一个文本编辑器可能有“主窗口”、“关于窗口”、“设置窗口”:
function createMainWindow() {
const win = new BrowserWindow({ width: 1200, height: 800 });
win.loadFile('main.html');
return win;
}
function createSettingsWindow(parent) {
const win = new BrowserWindow({
width: 600,
height: 400,
parent, // 关联父窗口,保持层级关系
modal: true, // 模态,阻塞父窗口交互
});
win.loadFile('settings.html');
return win;
}
使用 parent 选项可以让子窗口始终在父窗口之上,并且当父窗口关闭时子窗口自动关闭,避免出现孤儿窗口。
2. 窗口间通信
在多窗口应用中,经常需要主窗口设置改变后通知其他窗口刷新数据,或者在一个窗口中进行操作后更新另一个窗口的视图。主进程作为所有窗口的创建者,天然可以担任消息中转站。
架构模式:渲染进程 → 主进程 → 其他渲染进程
- 渲染进程 A 通过 IPC 将消息发送给主进程。
- 主进程接收到后,根据业务逻辑将消息转发给一个或多个目标窗口。
示例:主窗口更改了用户主题配色,需要通知所有其他窗口同步。
渲染进程A(发起方):
// preload 暴露的 api
window.api.send('theme-changed', { theme: 'dark' });
主进程(中转):
ipcMain.on('theme-changed', (event, payload) => {
// 遍历所有窗口,广播消息
windows.forEach((win) => {
if (win.webContents !== event.sender) {
win.webContents.send('update-theme', payload);
}
});
});
渲染进程B(接收方):
window.api.receive('update-theme', (data) => {
applyTheme(data.theme);
});
这种类似“消息总线”的模式简单可靠,也便于调试和维护。注意应避免在不需要的窗口之间传递大量数据,做好消息类型管理和权限控制。
3. 焦点控制与窗口激活
当用户在某个窗口触发“显示另一个窗口”操作时,需要保证该窗口能正确获取焦点。Electron 提供了 win.show()、win.focus() 和 win.restore()(如果窗口被最小化了)等方法。
常用的组合是:
function showAndFocusWindow(win) {
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
}
在多窗口应用中,还需要注意窗口间的层级关系。例如通过全局快捷键呼出搜索框时,希望搜索框出现在所有应用的最前面,可以设置:
searchWindow.setAlwaysOnTop(true, 'screen-saver'); // 保持在所有窗口前,包括全屏应用
searchWindow.show();
searchWindow.focus();
完成后可以根据需要取消置顶:searchWindow.setAlwaysOnTop(false)。
另一种情况是“单实例应用”:当用户再次双击应用图标时,我们不希望创建第二个主窗口,而是将已有的主窗口激活并置前。这需要使用 app.requestSingleInstanceLock() 和 second-instance 事件:
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
// 有人尝试启动第二个实例,聚焦我们的主窗口
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
这样,无论用户从桌面、开始菜单还是任务栏启动应用,窗口行为都符合预期,不会出现一堆重复进程。
本节所涉及的关闭到托盘、最小化逻辑和多窗口协作,是构建专业桌面应用的基石。它们并不需要深奥的算法,而是需要准确理解 Electron 的事件生命周期和窗口 API。只要掌握了本节提到的拦截模式与消息传递思路,你就能轻松实现像 Slack、网易云音乐、VS Code 那样的窗口交互体验。