窗口、进程与资源的生命周期
区分关闭、隐藏与退出,理解平台策略、单实例以及资源清理的归属。
本页内容
从关闭按钮的问题进入生命周期#
网页开发中,离开页面往往意味着页面工作结束。桌面应用不同:关闭一个窗口、隐藏一个窗口、退出整个应用,是三种不同意图。聊天工具可以关闭窗口后继续接收消息,文档工具可能最后一个窗口关闭后仍保留菜单,简单计算器则可能直接退出。Electron 提供机制,具体采用哪种行为必须由应用设计决定。
本章前置是能创建第一个窗口。目标是能说明一个资源由应用、窗口还是页面拥有,能实现单实例与可恢复的隐藏窗口,并能解释为什么退出钩子不能成为唯一的数据保存机会。我们使用只有本地静态页面的应用,不涉及托盘图标、自动更新或后台联网,因此每条生命周期都能直接观察。
先建立三个问题:当前有没有应用主进程?当前有没有窗口对象?窗口现在是否可见?这三个答案并不总是一致。一个隐藏窗口仍然存在,里面的页面也可能继续执行;一个关闭后的窗口对象已经失效,不能继续调用它的方法;主进程则可能在所有窗口关闭后继续运行。代码中应当明确区分这些状态,而不是用一个布尔值叫 isOpen 代表全部。
事件是发生了什么,策略是接下来做什么#
窗口的 close 事件发生在即将关闭时,可以 preventDefault 取消关闭。closed 表示已经关闭,适合清理引用和窗口资源;此时再尝试保存到窗口或调用 show 就太晚了。应用的 before-quit 发生在通常的退出流程开始时,will-quit 表示窗口关闭后即将结束;它们与窗口 close 不在同一层。
app.quit() 发起正常退出流程,窗口可能阻止退出;app.exit() 则是立即退出路径,不会经过同样的退出事件。不能用后者作为“前者退出不了”的通用修复,因为那会跳过你本来依赖的清理。正常工作流应找出是谁取消了关闭,例如为了隐藏窗口注册的 close 监听器,或者页面的 beforeunload。
很多入门示例写 window-all-closed 时在非 macOS 上 quit,这是常见应用策略。监听该事件后,你的处理器决定下一步,不能同时假设 Electron 会替你自动退出。macOS 上常保留应用,通过 activate 在没有窗口时重建;Windows 和 Linux 也能实现后台常驻,只是需要明确入口让用户恢复和退出。平台惯例不是不可修改的系统定律。
退出流程还受系统环境影响。崩溃、强制终止、断电不会耐心等你的 Promise;Windows 关机或注销也有特殊会话事件和限制。把唯一的一次保存放在 will-quit 的异步回调里,会产生“平时测试成功,真实关机丢数据”的问题。可靠数据应在编辑过程中持续保存,退出时只处理有界的收尾,并清楚说明失败状态。
实验一:离线区分隐藏、关闭和退出#
保存 lifecycle-model.cjs,执行 node lifecycle-model.cjs。它是应用策略的纯数据模拟,不依赖 Electron,也不模拟操作系统事件顺序。我们要验证的是自己选择的行为是否前后一致。
'use strict';
const assert = require('node:assert/strict');
function transition(state, action) {
if (!state.running) return state; // 已退出的模型不会自行复活
if (action === 'hide') return { ...state, visible: false };
if (action === 'show') return { ...state, windowExists: true, visible: true };
if (action === 'close') {
return { ...state, windowExists: false, visible: false };
}
if (action === 'quit') {
return { running: false, windowExists: false, visible: false };
}
throw new Error('UNKNOWN_ACTION');
}
let state = { running: true, windowExists: true, visible: true };
state = transition(state, 'hide');
assert.equal(state.windowExists, true);
assert.equal(state.visible, false);
state = transition(state, 'show');
assert.equal(state.visible, true);
state = transition(state, 'close');
assert.equal(state.running, true);
state = transition(state, 'quit');
assert.equal(state.running, false);
assert.deepEqual(transition(state, 'show'), state);
console.log('通过:隐藏保留窗口,关闭移除窗口,退出结束应用');
这里的 close 不自动 quit,因为模拟器刻意把两层状态分开。真实应用可以在窗口全部关闭后选择退出,但那是额外策略。show 从无窗口状态恢复时代表“创建并显示”的业务动作,真实实现需要先判断是否存在,再分别调用创建或显示接口。函数名相同不代表底层只有一个系统调用。
观察最后一条断言:应用已经退出时,原有内存模型不再接受显示请求。重新启动是新的运行实例,需要重新初始化状态。你不能期望一个退出后的 JavaScript 回调继续把窗口打开;这也是“定时器过几分钟重启自己”必须有外部调度或新进程机制的原因,本章不实现这类后台功能。
实验二:单实例、关闭后隐藏、再次启动恢复#
建立 lifecycle-lab 目录,放入下面三个文件。这个实验主动选择“点击关闭隐藏,菜单退出才终止”。为保持完整和容易理解,没有托盘图标;隐藏后的恢复入口是再次执行 npm start。实际产品若长期采用此策略,应提供用户可发现的恢复方式,而不能让用户以为程序已经退出。
{
"name": "electron-lifecycle-lab",
"version": "1.0.0",
"private": true,
"main": "main.cjs",
"scripts": { "start": "electron ." },
"devDependencies": { "electron": "44.3.0" }
}
'use strict';
const { app, BrowserWindow, Menu } = require('electron');
const path = require('node:path');
let windowRef = null;
let quitting = false;
// 锁在创建窗口前申请;失败的实例不继续初始化窗口与定时器。
const hasLock = app.requestSingleInstanceLock();
if (!hasLock) {
app.quit();
} else {
function createWindow() {
const win = new BrowserWindow({
width: 760, height: 480,
webPreferences: {
contextIsolation: true, sandbox: true, nodeIntegration: false
}
});
windowRef = win;
let ticks = 0;
let timer = null;
function stopWork() {
if (timer !== null) {
clearInterval(timer);
timer = null; // 重复清理安全
}
}
function startWork() {
if (timer !== null || win.isDestroyed()) return;
timer = setInterval(() => {
if (!win.isDestroyed()) {
ticks += 1;
win.setTitle('可见期间计时:' + ticks);
}
}, 1000);
}
win.on('show', startWork);
win.on('hide', stopWork);
win.on('close', event => {
if (!quitting) {
event.preventDefault(); // 只取消普通关闭,不阻止明确退出
win.hide();
}
});
win.on('closed', () => {
stopWork();
if (windowRef === win) windowRef = null;
});
win.webContents.on('will-navigate', event => event.preventDefault());
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
void win.loadFile(path.join(__dirname, 'index.html')).catch(error => {
console.error('加载失败:', error.message);
app.quit();
});
// 窗口可能在监听注册前已可见,因此补一次状态检查。
if (win.isVisible()) startWork();
return win;
}
function showWindow() {
if (!windowRef || windowRef.isDestroyed()) {
createWindow();
return;
}
if (windowRef.isMinimized()) windowRef.restore();
windowRef.show();
windowRef.focus();
}
app.on('before-quit', () => { quitting = true; });
app.on('second-instance', () => {
// 不接受第二实例传来的任意路径或命令,只恢复当前窗口。
void app.whenReady().then(showWindow);
});
app.whenReady().then(() => {
const menu = Menu.buildFromTemplate([
{
label: '应用',
submenu: [
{ label: '显示窗口', click: showWindow },
{ type: 'separator' },
{ label: '退出应用', accelerator: 'CmdOrCtrl+Q', click: () => app.quit() }
]
}
]);
Menu.setApplicationMenu(menu);
showWindow();
app.on('activate', showWindow);
}).catch(error => {
console.error('初始化失败:', error.message);
app.quit();
});
app.on('window-all-closed', () => {
// 正常关闭会被隐藏策略拦截;明确退出时不额外取消。
if (!quitting && process.platform !== 'darwin') app.quit();
});
}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-src 'none'; connect-src 'none'">
<title>生命周期实验</title>
</head>
<body>
<main>
<h1>关闭与退出不是同一个动作</h1>
<p>标题计数只在窗口可见期间增加。</p>
<p>点击窗口关闭按钮会隐藏窗口。再次执行 npm start 可以恢复。</p>
<p>要结束进程,请使用“应用 → 退出应用”或菜单中的快捷键。</p>
</main>
</body>
</html>
npm install
npm start
预期窗口标题每秒增加,点击关闭后窗口隐藏,第一次启动的终端仍在运行。在第二个终端进入同一目录再次 npm start,预期恢复原窗口,而不是创建第二套业务进程。选择退出菜单后第一个进程结束。菜单位置、聚焦限制和窗口管理细节受平台影响,必须分别在目标系统验收;本教材未启动该 GUI。
这个应用没有 preload,因为页面只展示说明,不需要访问任何宿主能力。preload 是能力桥接的位置,不是每个 Electron 窗口必须存在的仪式性文件。减少不需要的桥,也减少需要维护的接口。主进程仍明确设置隔离、沙箱和禁止 Node 集成,不能因为页面当前没有脚本就沿用不安全模板。
逐段解释:锁、窗口和资源属于谁#
requestSingleInstanceLock 返回布尔值,表示当前实例是否取得应用单实例锁。失败实例应该尽早退出;否则它仍然可能初始化数据库、注册快捷键或执行任务,即使没有显示第二个窗口也已经发生重复工作。锁解决应用实例竞争,不等价于文件写入事务锁,也不能替代多个不同应用访问同一文件时的并发控制。
second-instance 让持锁实例知道又有人启动了应用。示例只恢复窗口,不解析传入命令行。真实产品若允许双击文件或自定义协议启动,传来的路径和参数属于外部输入,需要重新验证;不能因为它来自同一个可执行文件就直接执行。恢复现有窗口与处理新打开请求是两个业务动作,应分别规定失败结果。
quitting 标记解决关闭隐藏策略与退出意图冲突。普通点击关闭时,close 取消默认行为并隐藏;app.quit 发起退出时,before-quit 先设置标记,随后 close 就放行。若忽略这个区别,菜单“退出”也会被隐藏逻辑拦住,形成应用永远不结束的循环。示例没有自动更新流程,后者的事件顺序存在额外规则,接入时必须按官方合同单独处理。
定时器属于窗口,保存在 createWindow 的闭包内。显示时启动,隐藏时停止,关闭时再次清理。stopWork 允许重复调用,startWork 防止重复创建,这是比“希望事件只来一次”更稳定的设计。隐藏不销毁窗口,所以计数变量保留;定时器被清理后计数暂停,再显示时继续,而不是重新从零开始。
清理引用时使用对象身份比较,是为了应对旧对象事件晚于新对象建立的情况。只写 windowRef = null 在单窗口初期看似足够,一旦存在异步关闭或恢复逻辑,就可能抹掉新窗口引用。资源所有者应清理自己创建的资源,不能根据一个碰巧仍叫同名的全局变量去清理别人后来创建的对象。
隐藏以后到底停止什么#
窗口不可见并不自动意味着业务暂停。页面计时器可能受 Chromium 节流影响,但节流不是业务取消;主进程的任务也不会因为窗口藏起来就自行结束。是否继续下载、继续索引、继续保存,应由产品对任务的定义决定。示例让展示用途的计数暂停,是因为隐藏时继续更新标题没有价值。
如果任务属于用户文档,关闭某个展示窗口不一定应该取消它;如果任务只为当前页面生成临时预览,页面销毁通常就应该撤销。你可以把任务所有者写在创建位置旁边:应用拥有数据库连接,窗口拥有窗口事件与本地展示定时器,某次请求拥有其 AbortController。先说明归属,清理时机才能自然推出。
不要把所有清理都放进 renderer 的 beforeunload。页面崩溃时它可能没有机会执行,主进程持有的资源也不由它自动释放。反过来,仅在 app 退出时清理页面订阅,会让用户反复开关窗口时累计资源。桌面应用经常运行数小时甚至数天,这种积累比短暂网页会话更容易变成实际问题。
实验三与练习:设计一个可以重复清理的资源包#
练习要求创建一个资源包,可以登记清理函数;调用 dispose 时按后注册先清理的顺序释放;重复 dispose 不得再次释放;已经释放后新增资源,应立即清理。提示是把“已经关闭”作为资源包状态,并在执行前从待清理集合中移除函数。这里不需要真的创建定时器,就能验证生命周期规则。
参考答案:完整资源包与失败回归
保存 owner.cjs,执行 node owner.cjs。预期输出“通过:清理逆序、幂等、迟到资源立即释放”。
'use strict';
const assert = require('node:assert/strict');
function createOwner() {
let disposed = false;
const disposers = [];
return {
add(dispose) {
if (typeof dispose !== 'function') throw new TypeError('需要清理函数');
if (disposed) dispose();
else disposers.push(dispose);
},
dispose() {
if (disposed) return;
disposed = true;
const failures = [];
// 一个清理失败不应阻止其他资源释放。
while (disposers.length) {
try { disposers.pop()(); }
catch (error) { failures.push(error); }
}
if (failures.length) throw new AggregateError(failures, '部分清理失败');
}
};
}
const log = [];
const owner = createOwner();
owner.add(() => log.push('定时器'));
owner.add(() => log.push('监听器'));
owner.dispose();
owner.dispose();
owner.add(() => log.push('迟到任务'));
assert.deepEqual(log, ['监听器', '定时器', '迟到任务']);
const failing = createOwner();
let cleaned = false;
failing.add(() => { cleaned = true; });
failing.add(() => { throw new Error('模拟清理失败'); });
assert.throws(() => failing.dispose(), AggregateError);
assert.equal(cleaned, true);
console.log('通过:清理逆序、幂等、迟到资源立即释放');
逆序清理适合后建立的资源依赖先建立资源的情况,例如先创建连接再订阅消息,应先取消订阅再关连接。但不是所有业务都天然符合栈顺序,复杂依赖需要明确安排。这个工具只管理同步清理函数;不要把异步保存传进去,再误以为 dispose 返回时保存已经完成。若需要异步关闭,应设计单独可等待且有超时的合同。
未保存内容为什么不能只靠关闭确认#
一个真实编辑器可能在关闭时询问“是否放弃修改”。这时主进程不能同步等一个页面 Promise,也不能只弹框而不记录正在确认的状态,否则连续点击关闭会弹出多个对话框。通常需要先取消本次关闭,完成一次确认后设置允许关闭标记,再重新触发关闭;取消确认则恢复原状态。这个模式与本章 quitting 标记有相似结构,但还需要文档保存状态参与判断。
关闭确认也不能取代自动保存。用户断电时不会先触发确认,写入磁盘也可能失败,因此持久化章节必须建立可恢复的写入格式和明确的成功确认。这里提出这些边界,是为了防止把生命周期钩子理解成可靠事务系统;本章程序只计数,没有需要持久化的用户内容,故无需假装实现一个不完整保存方案。
平台测试应验证具体场景,而不只是“启动过一次”。至少包括普通关闭、明确退出、隐藏后再次启动、最小化后再次启动、窗口重建与进程退出。若准备后台常驻,还要检查任务栏或托盘入口、系统注销、应用更新和异常终止后重启。只有实际支持的平台需要验收,不能根据 Windows 的结果宣布 macOS 聚焦与菜单行为也已通过。
多个异步事件靠近时,如何避免重复创建#
用户再次启动应用、点击 Dock 图标、某个菜单请求显示窗口,这些事件可能在很短时间内连续到达。JavaScript 回调本身按事件循环执行,但回调发起的异步加载可以重叠。如果你只在 loadFile 完成后才把窗口赋给全局引用,那么加载期间第二次显示请求会以为没有窗口,又创建一个。本例在创建 BrowserWindow 后立即保存引用,因此窗口虽然还在加载,所有显示请求已经指向同一个对象。
这说明“单线程”不意味着没有竞态。竞态并不一定来自两条指令同时执行,也可能来自两个异步流程对同一状态作出过时判断。一个流程先检查为空,开始等待;另一个流程修改状态;第一个流程恢复后继续使用旧判断,就会出现错误。解决方式不是到处加延时,而是在决定所有权时立即记录状态,并在异步结果返回后确认它仍然属于当前对象。
相同道理也适用于退出。菜单发起退出以后,不应再由晚到的异步回调创建新窗口。本实验没有后台加载任务主动建窗,路径相对简单;未来添加登录检查或数据库初始化时,需要在等待结束后检查应用是否正在退出。可以把 quitting 作为应用意图的一部分,但不要把所有失败都压成这个标记:初始化失败、用户取消、正在退出有不同含义,日志与恢复方式也不同。
页面恢复与应用恢复是两种数据需求#
隐藏保留了当前 renderer 和内存变量,因此恢复窗口通常能继续看到原来状态。真正关闭窗口后再创建,页面内存重新初始化;如果希望恢复编辑内容,需要把文档状态放在独立的持久化层,或者由主进程的业务服务保留当前会话。前者能跨进程重启,后者只能跨窗口重建。选择哪种方案取决于用户期待,不应只根据哪种写起来少两行来决定。
浏览器的存储接口可以解决部分页面状态,但它们也有来源、会话分区和清理策略。把页面改成不同协议、不同主机或不同分区后,原来可见的数据可能不再出现在同一位置。因此“关闭后数据还在”不能单独证明已经实现可靠桌面存储;还要明确存储键、来源归属、异常写入与版本迁移。这里先把层次分开,避免初学时把一个变量、浏览器存储和文件数据库统称为缓存。
观察资源泄漏可以从可解释的小信号开始。本例连续隐藏与恢复十次,标题应仍每秒增加一次,而不是越来越快;真正退出后终端应结束,不能剩一个仍在输出的计时器。若速度叠加,通常意味着重复启动而未清理;若恢复后不再增加,可能是停止时没有复位 timer 引用。通过一个简单计时器就能理解后面网络订阅、文件监听和 IPC 监听的同类问题。
什么时候需要主动取消任务#
清理事件监听只是不再接收通知,不一定停止通知背后的工作。比如取消订阅下载进度,不等于下载已经中止;关闭一个导入弹窗,也不等于主进程已经停止解析。资源包可以调用 AbortController.abort,但底层任务必须实际观察这个信号,或者使用支持信号的接口,取消才会产生效果。
因此下一章之后的 IPC 任务会把取消设计成显式合同,给请求编号并验证编号属于当前发送者。本章先掌握“谁启动、谁拥有、谁释放”的基本关系。只要这个关系清楚,就能判断页面卸载时应取消哪些任务、哪些任务应交给应用继续完成,以及结果返回时如何避免更新已经销毁的窗口。
掌握标准与自测#
你应能解释关闭、隐藏、退出的状态差别,指出示例中锁、窗口引用、定时器和退出标记各自属于哪层,并能预测隐藏后计数为何保留而定时器停止。进一步能够把窗口关闭策略改成真正关闭,同时保持应用策略清楚,就是理解了机制,而不是背熟一段模板。
自测一:窗口消失就证明进程退出了吗?不一定,可能只是隐藏。自测二:单实例锁可以保证每次文件写入原子吗?不能,它解决实例竞争,不负责磁盘事务。自测三:在 will-quit 中启动异步保存就能保证保存结束吗?不能,进程退出和异常终止不会自动等待你的任意异步任务。
官方来源与验证边界#
参考 app 生命周期和单实例、BrowserWindow 事件、进程模型、Menu。按当前官方契约核对,固定 Electron 44.3.0;两个 Node 模型实际执行,Electron 脚本只做语法与源代码检查。隐藏恢复、系统聚焦、菜单及退出流程尚未在真实 GUI 中验收。