# 从空目录创建第一个窗口

## 要先跑通哪一条链

这一章的目标是从一个空目录启动窗口，并且知道每一个文件为什么会被执行。你会写一个计数小应用，界面使用原生 HTML，避免在理解 Electron 启动过程之前同时承担构建工具的复杂度。前置是上一章的运行环境关系；对 npm、入口、路径和 Promise 加载过程的解释都在本章内，不要求已经掌握 Node 项目组织。

“窗口出现”只是第一层成功。真正达标还包括：你能区分 Electron 没有启动、窗口没有创建、HTML 加载失败、renderer 脚本报错和 preload 没有执行。它们会产生相似的“什么也没显示”现象，却由不同位置负责。先建立最短可观察链，再接入 Vue，之后出现白屏才有一条可复用的定位路线。

## 一个目录如何成为可启动的应用

package.json 是一份 JSON 配置，不是会自行执行的脚本。main 字段告诉 Electron 应用入口在哪里，scripts.start 则告诉 npm 执行 npm start 时要运行哪条命令。electron . 中的点表示当前应用目录；Electron 从这个目录读取配置，再加载 main.cjs。这里存在两次选择：终端选择项目目录，Electron 选择入口文件，不能把它们混成一个路径。

npm install 安装目录内声明的依赖，通常创建 node_modules 和 package-lock.json。Electron 的 npm 包还会下载与操作系统和架构对应的桌面二进制，因此首次安装需要网络、磁盘空间以及可访问的下载源。安装成功后，本章应用运行不需要网络。不要把安装过程中的下载失败归因于窗口代码，也不要为了安装本地依赖修改全局 Node。

本章固定 Electron 44.3.0。开发机可使用课程统一的 Node 版本；实验已在本机 Node 22.22.0 下执行纯 Node 部分。Electron 运行时自带自己的 Node 和 Chromium，npm start 后主进程的版本并不要求与终端 node -v 完全相同。升级 Electron 时应重看迁移说明与安全默认值，而不是机械地相信旧模板。

CommonJS 是 Node 的模块形式之一：require 加载模块，module.exports 导出值；本章没有导出需求，所以只用 require。显式 .cjs 表示此文件按 CommonJS 解释，避免目录中未来出现 type: module 时改变入口含义。HTML 引入的 renderer.js 则由浏览器解释；扩展名相近不表示模块加载方式相同。

## 实验一：离线重现“太早创建窗口”

这个实验是教学模拟器，没有调用 Electron，也不会打开窗口。它把“ready 以前禁止创建”写成可运行的合同，让你先看清顺序。保存为 bootstrap.cjs，直接执行 node bootstrap.cjs，无需安装依赖。

```js bootstrap.cjs
'use strict';
const assert = require('node:assert/strict');
// 模拟器只保留就绪状态和创建次数，不冒充真实窗口。
function makeRuntime() {
  let ready = false;
  let created = 0;
  return {
    async whenReady() {
      await Promise.resolve(); // 模拟异步初始化的一个让出点
      ready = true;
    },
    createWindow() {
      if (!ready) throw new Error('NOT_READY');
      created += 1;
      return { title: '计数窗口', created };
    }
  };
}
async function main() {
  const runtime = makeRuntime();
  assert.throws(() => runtime.createWindow(), /NOT_READY/);
  await runtime.whenReady();
  assert.deepEqual(runtime.createWindow(), { title: '计数窗口', created: 1 });
  console.log('通过：先等待 ready，再创建窗口');
}
main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});
```

第一条断言故意先调用 createWindow，并要求出现 NOT_READY。第二段等待初始化后创建，才获得窗口描述。assert 是 Node 自带的断言模块：条件不满足时抛错，进程最后以非零退出码结束。实验不是为了记住这个错误字符串，而是让启动顺序变成可以失败的事实。

真实 app.whenReady() 返回一个 Promise，在 Electron 初始化完成后兑现。它也适用于调用时已经 ready 的情况，因此比在多个地方猜测时机更清楚。不要把 whenReady 放进 renderer，也不要写成 await app.on('ready', callback)：事件注册方法返回的不是你所期待的就绪 Promise。事件与 Promise 都处理异步，但合同不同。

## 实验二：完整的计数窗口

建立 first-window 目录，把下面六个文件直接放在目录内。文件名与大小写按示例保存；不要把扩展名隐藏后误存为 main.cjs.txt。这里没有 Vite、没有服务器、没有远程脚本，加载关系可以直接从源码追踪。

```json package.json
{
  "name": "electron-first-window",
  "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 } = require('electron');
const path = require('node:path');
let mainWindow = null;

async function createWindow() {
  // 明确窗口与页面的安全配置，不依赖旧模板的历史默认值。
  const win = new BrowserWindow({
    width: 760, height: 520, minWidth: 360, minHeight: 320,
    backgroundColor: '#ffffff',
    webPreferences: {
      preload: path.join(__dirname, 'preload.cjs'),
      contextIsolation: true, sandbox: true, nodeIntegration: false
    }
  });
  mainWindow = win;
  win.on('closed', () => {
    if (mainWindow === win) mainWindow = null;
  });
  // 此应用只有固定本地入口，页面无权导航或新开窗口。
  win.webContents.on('will-navigate', event => event.preventDefault());
  win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
  try {
    await win.loadFile(path.join(__dirname, 'index.html'));
    console.log('主页面加载完成');
  } catch (error) {
    console.error('主页面加载失败：', error.message);
    app.quit();
  }
}
app.whenReady().then(async () => {
  await createWindow();
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) void createWindow();
  });
}).catch(error => {
  console.error('应用初始化失败：', error.message);
  app.quit();
});
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});
```

```js preload.cjs
'use strict';
const { contextBridge } = require('electron');
// 只公开展示所需的静态信息，不公开 process、require 或 IPC。
contextBridge.exposeInMainWorld('applicationInfo', {
  name: '我的第一个桌面窗口',
  electron: process.versions.electron
});
```

```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 'self'; style-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'; connect-src 'none'">
  <title>计数窗口</title>
  <link rel="stylesheet" href="./style.css">
  <script src="./renderer.js" defer></script>
</head>
<body>
  <main>
    <h1>计数窗口</h1>
    <p id="environment"></p>
    <p aria-live="polite">当前计数：<output id="count">0</output></p>
    <button id="increase" type="button">增加一次</button>
    <button id="reset" type="button">清零</button>
  </main>
</body>
</html>
```

```css style.css
/* 不依赖网络字体，小窗口中允许内容自然换行。 */
body { margin: 0; font: 16px/1.7 system-ui, sans-serif; color: #17212b; }
main { max-width: 42rem; padding: 24px; margin: auto; }
button { font: inherit; padding: 8px 16px; margin: 4px; }
button:focus-visible { outline: 3px solid #175cd3; outline-offset: 3px; }
```

```js renderer.js
'use strict';
const info = window.applicationInfo;
const environment = document.querySelector('#environment');
const output = document.querySelector('#count');
const increase = document.querySelector('#increase');
const reset = document.querySelector('#reset');
let count = 0;
if (!info) {
  environment.textContent = '桥没有加载，请检查 preload 路径与错误日志。';
  increase.disabled = true;
  reset.disabled = true;
} else {
  environment.textContent = info.name + ' · Electron ' + info.electron;
}
function render() {
  output.textContent = String(count); // 数据按文本显示，避免 HTML 解释
}
increase.addEventListener('click', () => { count += 1; render(); });
reset.addEventListener('click', () => { count = 0; render(); });
render();
```

在该目录执行以下命令。首次安装产生锁文件后，应保留锁文件；未来已有锁文件的干净安装可使用 npm ci。不要同时修改依赖版本再要求 npm ci 自动替你更新锁文件，它用于按现有锁定结果安装。

```sh
npm install
npm start
```

预期终端出现“主页面加载完成”，窗口出现标题、版本与两个按钮，增加后计数递增，清零恢复零。关闭后重新打开，计数回到零，因为变量只在当前页面内存中，本例没有存储代码。这里描述的是读者应执行的 GUI 验收步骤；本教材构建期间没有启动此窗口，不把静态检查当成界面已通过。

## 沿着启动过程逐段读代码

mainWindow 是主进程持有的窗口引用，保存它便于之后操作现有窗口。closed 事件表示对象对应的窗口已经关闭，此时清除引用可以防止后续误用。局部 win 被事件闭包捕获，检查 mainWindow === win 是为了避免旧窗口的关闭事件清掉后来创建的新窗口引用。这种资源归属问题在多窗口和热重载下尤其容易暴露。

BrowserWindow 的 width 与 height 表达窗口尺寸，不是 CSS 容器宽高。示例主动指定尺寸和最小尺寸，不依赖平台边框恰好占多少像素；若以后使用 useContentSize 等选项，应阅读对应尺寸合同。创建对象与加载网页也是两件事：new BrowserWindow 得到宿主窗口，loadFile 才把本地文档交给它显示。

__dirname 是当前 CommonJS 文件所在目录，path.join 按操作系统规则组合路径。它与 process.cwd() 不同，后者是进程的当前工作目录，会受启动方式影响。用入口所在位置定位紧邻的 HTML 和 preload，能避免从别的目录启动时找错文件。preload 配置要求绝对路径，path.join(__dirname, ...) 在这里满足这个条件；不要手工拼反斜杠或把 URL 当成本地路径。

loadFile 返回 `Promise<void>`：成功没有业务数据，表示页面完成相应的加载阶段；失败会拒绝。它不是“所有业务都成功”的保证。页面脚本可以在文档成功加载后抛错，某个按钮也可能绑定了错误事件，因此终端成功日志不等价于 UI 功能验收。另一方面，catch 中若吞掉错误，用户只看到空白窗口而终端没有原因，定位会更困难。

preload 在页面脚本之前执行，桥把明确的静态值放到页面可见的 applicationInfo。renderer 的 defer 表示脚本等待文档解析完成后执行，因此查询这些 DOM 节点时结构已经存在。两者的“先后”来自不同机制：preload 是 Electron 配置的生命周期，defer 是 HTML 脚本加载规则。不能用 defer 修复不存在的 preload 文件，也不能用 preload 为页面缺失的节点兜底。

CSP 限制页面能从哪些来源加载资源和执行脚本。示例把 JavaScript、CSS 放成外部文件，因此无需 unsafe-inline。CSP 不能替你修正业务逻辑，却能减少意外引入远程脚本和内联注入的执行机会。标题与计数通过 textContent 输出，字符串不会被当成标签解释；后续显示导入笔记时也沿用这个边界。

## 为什么暂时不用隐藏窗口等待 ready-to-show

有些模板设置 show: false，然后在 ready-to-show 中显示窗口。这个事件与页面第一次渲染有关，不等于 app 已 ready，也不等于服务器数据已经全部请求结束。本章选择直接展示窗口并设置背景色，让失败状态保持可见，避免第一步就把“是否创建”和“是否显示”绑在多个条件上。

如果之后采用隐藏后显示的方式，应保留加载失败处理，并知道 paintWhenInitiallyHidden: false 会使 ready-to-show 不发生。只复制 show: false 而漏掉 show()，表现出来就是应用进程存在却没有可见窗口。把这类现象解释为“Electron 启动不了”会让排查方向偏离实际原因。

窗口生命周期也不等于页面生命周期。刷新页面重新运行 renderer，计数自然归零；主进程可能从未退出。关闭窗口后 macOS 示例保留应用，Dock 激活时再创建；Windows 和 Linux 示例选择在最后窗口关闭时退出。下一章会详细讲这是一项应用策略，而不是一段可以随意删掉的脚手架装饰。

## 实验三与练习：让路径错误有可诊断的结果

现在练习一个更贴近工程的动作：在创建窗口前验证一组必要文件，并报告全部缺失文件，不要只报遇到的第一个。提示是把“根据入口目录得到路径”与“文件是否存在”分开；测试时注入存在性函数，便不需要真的创建或删除文件。这个离线合同不会替代 loadFile 的错误处理，因为检查后文件仍可能变化。

<details><summary>参考答案：完整的启动文件检查程序</summary>

保存为 files-check.cjs，执行 node files-check.cjs。该程序不读取真实磁盘，只验证路径和错误汇总规则。

```js files-check.cjs
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
function checkFiles(baseDirectory, exists) {
  const required = ['index.html', 'preload.cjs', 'renderer.js', 'style.css'];
  const missing = required
    .map(name => path.join(baseDirectory, name))
    .filter(file => !exists(file));
  return missing.length === 0
    ? { ok: true }
    : { ok: false, code: 'MISSING_FILES', files: missing };
}
const base = path.resolve('demo-app');
const available = new Set([
  path.join(base, 'index.html'), path.join(base, 'style.css')
]);
const result = checkFiles(base, file => available.has(file));
assert.equal(result.ok, false);
assert.deepEqual(result.files, [
  path.join(base, 'preload.cjs'), path.join(base, 'renderer.js')
]);
assert.deepEqual(checkFiles(base, () => true), { ok: true });
console.log('通过：缺失文件全部报告，路径以入口目录为基准');
```

</details>

这个答案把文件系统读取作为参数，是为了只测试我们写的规则；它没有声称创建了安全沙箱。真实集成时可以由主进程提供 existsSync 做小规模启动检查，但不要在页面暴露 exists(path)。前者是应用检查自己的固定资源，后者会让页面探测任意磁盘路径，权限范围已经发生变化。

## 对启动错误建立一个可重复的观察顺序

先确认终端命令是否还在运行，再确认有没有窗口，最后观察文档与脚本。命令立即退出且没有应用日志时，故障通常还在入口或运行环境一层；窗口存在但出现资源加载错误时，先检查路径；内容存在而交互失败时，再看页面控制台。这个顺序不是绝对诊断结论，而是用最少观察逐步缩小范围，避免对着一个空白页面同时改五项安全配置。

还要区分“记录错误”和“处理错误”。console.error 只负责把原因留下，app.quit 才是本例选择的失败策略。对于真实笔记应用，也可以显示可信本地错误页并允许重试，但错误页本身不能拼接原始异常为 HTML，也不能把任意路径开放给页面。第一章的小程序选择退出，是因为它没有已打开文档或未保存操作，不会因此丢失业务状态。

页面的内存变量也不是桌面持久化方案。开发者常看到窗口刷新就丢状态，便把所有数据挂到主进程全局变量上；这样只改变数据丢失的时机，进程退出后仍然没有保存。真正的持久化需要另行规定写入位置、格式、失败处理和权限。本章刻意让计数保持临时性质，帮助你先观察页面重载与进程退出的不同。

## 理解三个容易混淆的目录

项目目录是 package.json 所在的位置，模块目录是某个代码文件所在的位置，进程工作目录则是程序启动时继承或改变的当前位置。在最小实验里它们恰好相同，因此使用相对路径也常常成功；一旦你在父目录通过脚本启动应用，或者入口移动到子目录，这个偶然重合就消失了。先解释清楚三个名词，可以避免把路径错误误认成操作系统兼容问题。

本例把资源和主入口并列，使用入口目录作为基准，所以路径关系非常直接。以后打包时，某些文件会被放入应用归档或资源目录，此时不能继续假设开发时的目录布局。应该由构建和打包配置明确哪些文件属于应用、哪些资源放在外部，再选择对应的定位接口。初学阶段不要把用户文档写进安装目录：安装目录可能只读，也可能在升级时被替换，真正的数据目录会在后续持久化章节说明。

绝对路径解决“从哪里找”的问题，不自动解决“允许找什么”的问题。一个由 renderer 传入的绝对路径仍然可能指向不应读取的文件；一个由主进程根据固定入口组成的路径则来自可信配置。两者看起来都是字符串，但信任来源不同。本章只加载项目自有资源，下一步设计文件能力时不能把这种固定资源定位写法直接变成通用 readFile 接口。

## 入口和事件为什么只注册一次

main.cjs 在应用主进程启动时执行，顶层注册的 window-all-closed 处理器属于整个应用。createWindow 则可能被多次调用，每次只负责那一个窗口。把应用级事件注册放进 createWindow，会随着窗口重建积累重复处理器；一次关闭可能执行多次回调，日志重复只是表象，若回调中有保存或网络请求就会产生实际副作用。

相反，closed 监听器应该跟随窗口创建，因为每个窗口都有自己的关闭事件和引用。这里的划分与 Vue 中组件作用域很相似：属于页面实例的订阅应随着实例创建和销毁，属于全局服务的监听不应每挂载一个组件就注册一次。相似处是资源归属，差异是 Electron 还同时存在主进程和多个页面，不能只看文件是否被 import 过来判断生命周期。

本例用异步函数接住 loadFile 的拒绝，并在调用入口再接住初始化错误。void createWindow() 只表达调用处不等待它，不会自动吞掉错误；因为 createWindow 内部已经处理主要加载失败路径，这样写才有明确依据。若你把函数改成会继续抛错的版本，也必须相应调整调用处。单独在 Promise 前加 void 不是错误处理策略，这是从前端迁移到桌面时尤其值得保留的习惯。

## 成功输出应该如何验证

不要只拍一张窗口截图就宣布实验完成。先点击两次增加，观察计数变为二；再点击清零，观察恢复零；然后用键盘 Tab 移动焦点并按空格触发按钮，确认不是只有鼠标能操作。缩窄窗口时内容应仍可阅读。虽然我们暂时没有复杂样式，基本交互和语义也应从第一份程序建立。

再做一次可控失败：把 HTML 中 renderer.js 路径临时改错，窗口可能仍正常打开，但按钮不会更新；恢复路径后再运行应恢复交互。然后把 main 中 index.html 改成不存在的文件，预期是主进程记录加载失败并退出。实验结束应恢复原文件名。这两步验证的价值在于你能预测差异，而不是随机删配置寻找一个能工作的组合。

最后确认断网时已安装依赖的应用仍可启动。这里页面没有远程字体、脚本或接口，因此离线是由资源组成支持的行为，而不是产品文案。将来接入远程服务之后，启动本地界面与请求远程数据也应分离：服务器暂时不可用时仍然可以展示明确错误，而不应该让整个窗口无条件空白。这条设计原则在后续 AI 桌面应用中尤其有用。

## 故障推演与掌握标准

npm start 提示缺少脚本时，先看当前目录和 scripts 字段；提示找不到入口时，看 main 与真实文件名；Electron 下载失败时，查看安装日志而不是改 BrowserWindow；窗口显示但计数按钮无效时，检查 renderer.js 是否加载、CSP 是否拦截及浏览器控制台错误。终端日志和页面日志属于不同执行环境，学会看对位置比先安装更多调试插件有效。

掌握标准是独立建立这六个文件并讲清每条加载边，能故意制造一次 HTML 路径错误和一次 renderer 脚本错误，再解释为什么两种错误的观察点不同。你还应知道此应用没有持久化、没有网络依赖、没有向页面开放系统操作，计数归零符合当前设计。

自测一：node main.cjs 与 electron . 是否等价？不等价，前者使用普通 Node 启动脚本，后者使用 Electron 宿主读取应用入口。自测二：loadFile 成功能证明所有按钮正常吗？不能，它不验收后续业务交互。自测三：为什么不用 process.cwd() 定位紧邻入口的 preload？工作目录受启动位置影响，而 __dirname 绑定当前模块。

## 官方来源与验证边界

核对资料包括 [首个应用](https://www.electronjs.org/docs/latest/tutorial/tutorial-first-app)、[BrowserWindow](https://www.electronjs.org/docs/latest/api/browser-window)、[app](https://www.electronjs.org/docs/latest/api/app)、[窗口配置](https://www.electronjs.org/docs/latest/api/structures/web-preferences) 与 [上下文隔离](https://www.electronjs.org/docs/latest/tutorial/context-isolation)。合同按二〇二六年九月官方文档核对。离线启动与文件检查程序实际执行，Electron 文件进行 JavaScript 语法检查；没有启动桌面 GUI，也没有将系统表现伪装成已验证结果。
