# 窗口、进程与资源的生命周期

## 从关闭按钮的问题进入生命周期

网页开发中，离开页面往往意味着页面工作结束。桌面应用不同：关闭一个窗口、隐藏一个窗口、退出整个应用，是三种不同意图。聊天工具可以关闭窗口后继续接收消息，文档工具可能最后一个窗口关闭后仍保留菜单，简单计算器则可能直接退出。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，也不模拟操作系统事件顺序。我们要验证的是自己选择的行为是否前后一致。

```js lifecycle-model.cjs
'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。实际产品若长期采用此策略，应提供用户可发现的恢复方式，而不能让用户以为程序已经退出。

```json package.json
{
  "name": "electron-lifecycle-lab",
  "version": "1.0.0",
  "private": true,
  "main": "main.cjs",
  "scripts": { "start": "electron ." },
  "devDependencies": { "electron": "44.3.0" }
}
```

```js main.cjs
'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();
  });
}
```

```html index.html
<!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>
```

```sh
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 不得再次释放；已经释放后新增资源，应立即清理。提示是把“已经关闭”作为资源包状态，并在执行前从待清理集合中移除函数。这里不需要真的创建定时器，就能验证生命周期规则。

<details><summary>参考答案：完整资源包与失败回归</summary>

保存 owner.cjs，执行 node owner.cjs。预期输出“通过：清理逆序、幂等、迟到资源立即释放”。

```js 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('通过：清理逆序、幂等、迟到资源立即释放');
```

</details>

逆序清理适合后建立的资源依赖先建立资源的情况，例如先创建连接再订阅消息，应先取消订阅再关连接。但不是所有业务都天然符合栈顺序，复杂依赖需要明确安排。这个工具只管理同步清理函数；不要把异步保存传进去，再误以为 dispose 返回时保存已经完成。若需要异步关闭，应设计单独可等待且有超时的合同。

## 未保存内容为什么不能只靠关闭确认

一个真实编辑器可能在关闭时询问“是否放弃修改”。这时主进程不能同步等一个页面 Promise，也不能只弹框而不记录正在确认的状态，否则连续点击关闭会弹出多个对话框。通常需要先取消本次关闭，完成一次确认后设置允许关闭标记，再重新触发关闭；取消确认则恢复原状态。这个模式与本章 quitting 标记有相似结构，但还需要文档保存状态参与判断。

关闭确认也不能取代自动保存。用户断电时不会先触发确认，写入磁盘也可能失败，因此持久化章节必须建立可恢复的写入格式和明确的成功确认。这里提出这些边界，是为了防止把生命周期钩子理解成可靠事务系统；本章程序只计数，没有需要持久化的用户内容，故无需假装实现一个不完整保存方案。

平台测试应验证具体场景，而不只是“启动过一次”。至少包括普通关闭、明确退出、隐藏后再次启动、最小化后再次启动、窗口重建与进程退出。若准备后台常驻，还要检查任务栏或托盘入口、系统注销、应用更新和异常终止后重启。只有实际支持的平台需要验收，不能根据 Windows 的结果宣布 macOS 聚焦与菜单行为也已通过。

## 多个异步事件靠近时，如何避免重复创建

用户再次启动应用、点击 Dock 图标、某个菜单请求显示窗口，这些事件可能在很短时间内连续到达。JavaScript 回调本身按事件循环执行，但回调发起的异步加载可以重叠。如果你只在 loadFile 完成后才把窗口赋给全局引用，那么加载期间第二次显示请求会以为没有窗口，又创建一个。本例在创建 BrowserWindow 后立即保存引用，因此窗口虽然还在加载，所有显示请求已经指向同一个对象。

这说明“单线程”不意味着没有竞态。竞态并不一定来自两条指令同时执行，也可能来自两个异步流程对同一状态作出过时判断。一个流程先检查为空，开始等待；另一个流程修改状态；第一个流程恢复后继续使用旧判断，就会出现错误。解决方式不是到处加延时，而是在决定所有权时立即记录状态，并在异步结果返回后确认它仍然属于当前对象。

相同道理也适用于退出。菜单发起退出以后，不应再由晚到的异步回调创建新窗口。本实验没有后台加载任务主动建窗，路径相对简单；未来添加登录检查或数据库初始化时，需要在等待结束后检查应用是否正在退出。可以把 quitting 作为应用意图的一部分，但不要把所有失败都压成这个标记：初始化失败、用户取消、正在退出有不同含义，日志与恢复方式也不同。

## 页面恢复与应用恢复是两种数据需求

隐藏保留了当前 renderer 和内存变量，因此恢复窗口通常能继续看到原来状态。真正关闭窗口后再创建，页面内存重新初始化；如果希望恢复编辑内容，需要把文档状态放在独立的持久化层，或者由主进程的业务服务保留当前会话。前者能跨进程重启，后者只能跨窗口重建。选择哪种方案取决于用户期待，不应只根据哪种写起来少两行来决定。

浏览器的存储接口可以解决部分页面状态，但它们也有来源、会话分区和清理策略。把页面改成不同协议、不同主机或不同分区后，原来可见的数据可能不再出现在同一位置。因此“关闭后数据还在”不能单独证明已经实现可靠桌面存储；还要明确存储键、来源归属、异常写入与版本迁移。这里先把层次分开，避免初学时把一个变量、浏览器存储和文件数据库统称为缓存。

观察资源泄漏可以从可解释的小信号开始。本例连续隐藏与恢复十次，标题应仍每秒增加一次，而不是越来越快；真正退出后终端应结束，不能剩一个仍在输出的计时器。若速度叠加，通常意味着重复启动而未清理；若恢复后不再增加，可能是停止时没有复位 timer 引用。通过一个简单计时器就能理解后面网络订阅、文件监听和 IPC 监听的同类问题。

## 什么时候需要主动取消任务

清理事件监听只是不再接收通知，不一定停止通知背后的工作。比如取消订阅下载进度，不等于下载已经中止；关闭一个导入弹窗，也不等于主进程已经停止解析。资源包可以调用 AbortController.abort，但底层任务必须实际观察这个信号，或者使用支持信号的接口，取消才会产生效果。

因此下一章之后的 IPC 任务会把取消设计成显式合同，给请求编号并验证编号属于当前发送者。本章先掌握“谁启动、谁拥有、谁释放”的基本关系。只要这个关系清楚，就能判断页面卸载时应取消哪些任务、哪些任务应交给应用继续完成，以及结果返回时如何避免更新已经销毁的窗口。

## 掌握标准与自测

你应能解释关闭、隐藏、退出的状态差别，指出示例中锁、窗口引用、定时器和退出标记各自属于哪层，并能预测隐藏后计数为何保留而定时器停止。进一步能够把窗口关闭策略改成真正关闭，同时保持应用策略清楚，就是理解了机制，而不是背熟一段模板。

自测一：窗口消失就证明进程退出了吗？不一定，可能只是隐藏。自测二：单实例锁可以保证每次文件写入原子吗？不能，它解决实例竞争，不负责磁盘事务。自测三：在 will-quit 中启动异步保存就能保证保存结束吗？不能，进程退出和异常终止不会自动等待你的任意异步任务。

## 官方来源与验证边界

参考 [app 生命周期和单实例](https://www.electronjs.org/docs/latest/api/app)、[BrowserWindow 事件](https://www.electronjs.org/docs/latest/api/browser-window)、[进程模型](https://www.electronjs.org/docs/latest/tutorial/process-model)、[Menu](https://www.electronjs.org/docs/latest/api/menu)。按当前官方契约核对，固定 Electron 44.3.0；两个 Node 模型实际执行，Electron 脚本只做语法与源代码检查。隐藏恢复、系统聚焦、菜单及退出流程尚未在真实 GUI 中验收。
