# Electron 是什么：运行环境与进程关系

## 从你熟悉的网页开始，而不是从进程名词开始

你已经能写 Vue 页面，但浏览器里的页面不能直接随意读取硬盘、创建系统菜单或决定整个桌面应用何时退出。Electron 的用途，是把 Web 界面与桌面运行能力组合成一个可分发的应用。你的 HTML、CSS、事件处理和组件思维仍然有用；新增知识在于谁启动页面、谁拥有系统能力，以及页面如何提出一个经过检查的请求。本章先建立这个因果链，后面才写文件读取和 IPC。

目标是能够面对一份 Electron 工程，说清每个文件在哪个环境运行，知道为什么 renderer 中没有 require，也知道 preload 为什么不能被叫作“第三个进程”。前置仅是 JavaScript 和终端的基本使用。文中遇到 Node 的新概念会就地解释，不要求你已经写过服务器，更不要求先背下操作系统教材。

一个 Electron 应用可以没有远程服务器。它可以从本地加载 HTML，在本地管理笔记，并调用操作系统对话框。反过来，应用也可以请求 HTTP API。桌面程序、后端服务和网页是三种不同角色，不要因为主进程里有 Node 就把主进程理解成必须监听端口的服务器。Node 提供运行时与系统接口，是否建立网络服务由程序决定。

## Electron 带了什么，系统里安装的 Node 又负责什么

Chromium 负责页面解析、布局、绘制以及浏览器标准 API；V8 执行 JavaScript；Electron 在此基础上加入窗口生命周期、系统集成与通信接口，并在主进程中嵌入 Node.js。安装后的 Electron 应用通常携带所需运行组件，因此用户不必先安装与你开发机相同的 Chrome 或 Node。应用体积较大和资源占用较高，也与这些组件有关，不能只拿一个 HTML 文件的大小比较。

开发时运行 npm，需要开发机的 Node。执行 npm start 后，真正启动的是项目依赖中的 Electron 可执行程序；主进程使用 Electron 所嵌入的 Node，而不是简单借用你终端里 node 命令的版本。因此 node --version 与主进程 process.versions.node 可以不同。升级开发机 Node 不会自动升级已打包应用的 Chromium，也不会给已安装用户自动补上 Electron 安全修复。

npm 包的安装还包含下载桌面二进制这一步。包元数据下载成功，不代表 Electron 可执行程序一定下载成功；网络、代理或平台不匹配可能让安装在后续阶段失败。不要看到 node_modules 中有 electron 目录就认为环境完整。本文固定 Electron 44.3.0，首次安装需要网络，安装完成后的本地示例不访问网络、不调用模型。

## 先区分进程、线程和 JavaScript 上下文

进程是操作系统管理程序执行与资源的一种单位，各进程通常有独立的地址空间。线程是进程内部的执行单元，一个进程可以有多个线程。JavaScript 上下文则是语言运行环境中的全局对象和内建对象等状态。它们不是同一层概念：两个上下文可能处于同一个进程，一个进程也可能包含负责 JavaScript、绘制或其他工作的多个线程。

因此“JavaScript 通常在一个线程上顺序执行”不等于“Electron 整个应用只有一个线程”。同样，“preload 与网页的 window 不同”也不等于“它们在两个进程”。如果把这些概念混在一起，就容易错误地认为加了 preload 文件就能把耗时计算搬离界面线程，或者认为一个模块变量能被所有窗口直接共享。

隔离的目的也有层次。分进程有助于分离资源与故障范围；上下文隔离让网页脚本无法直接改写 preload 使用的全局对象；沙箱进一步限制渲染进程直接触及系统资源的能力。这些机制互相配合，但任何一个都不能替代业务参数校验。主进程若接受“任意路径读取”请求，安全配置不会替你决定这个路径该不该读。

## 实验一：同一进程里的两个上下文

先做一个不需要 Electron 的小实验，观察“不同全局对象”与“不同进程”不是一回事。环境为 Node.js 22 或更新的受支持版本，无依赖。保存为 `contexts.cjs`，执行 `node contexts.cjs`。require 是 CommonJS 的模块加载函数；node:vm 是 Node 内置模块，创建额外 JavaScript 上下文。这里的 vm 只用于演示概念，不能当作执行恶意代码的安全沙箱。

```js contexts.cjs
const assert = require('node:assert/strict');
const vm = require('node:vm');

// 两个上下文都由当前 Node 进程创建，pid 是明确传入的教学数据。
const first = vm.createContext({ pid: process.pid });
const second = vm.createContext({ pid: process.pid });
vm.runInContext('globalThis.privateValue = 42', first);

assert.equal(vm.runInContext('privateValue', first), 42);
assert.equal(vm.runInContext('typeof privateValue', second), 'undefined');
assert.equal(first.pid, second.pid);

// 显式复制数据可以建立边界；修改副本不改变原值。
const original = { title: '原始草稿' };
const copied = structuredClone(original);
copied.title = '副本';
assert.equal(original.title, '原始草稿');
console.log('同一进程，两个全局环境；显式复制不共享对象。');
```

预期输出为最后一行中文，断言全部通过。第一个上下文存在 privateValue，第二个不知道它；两者记录的进程编号相同。这个结果只证明本实验的语言上下文独立，不证明 vm 与 Electron contextIsolation 的实现和安全保障完全一样。类比只用于拆开概念，真实隔离要由 Electron 创建并配置。

structuredClone 演示了数据副本的效果。跨边界通信应把需要的信息明确表达为数据，而不是依赖对方能访问自己的变量。后续 IPC 会使用类似的序列化概念，但可传类型与 contextBridge 的规则还需要分别阅读，不能因为 Node 的 structuredClone 能复制某个对象，就假设所有 Electron 通道都接受它。

## 主进程、渲染进程与 preload 的关系

Electron 应用的入口脚本运行在主进程，它负责应用级生命周期、创建窗口和受权限保护的桌面能力。你在主进程中创建 BrowserWindow，得到的是控制一个原生窗口的 JavaScript 对象。窗口中的页面由 webContents 管理，在渲染进程中执行。BrowserWindow 不是一个 Vue 组件，webContents 也不是 DOM 元素；它们是主进程操纵窗口和页面的接口。

渲染进程运行 HTML、CSS 和页面 JavaScript，你可以把熟悉的 Vue 应用放在这里。默认安全配置下，页面没有 Node 的 require 和任意文件读取能力。它可以正常处理按钮、输入框、布局和 fetch，但访问桌面能力要经过应用提供的受限接口。这种限制不是 Electron 没安装好，而是避免网页漏洞直接变成系统级操作。

preload 在所属渲染进程中、页面脚本开始运行前执行。开启 contextIsolation 时，它位于与网页不同的 JavaScript 世界，拥有受配置限制的 Electron 接口。它不是第三个独立进程，也不自动提供后台计算线程。它更像一小段由应用作者控制的边界代码，用来向页面提供有限能力，而不是把主进程搬进页面。

实际任务管理器还可能看到 GPU、网络或其他辅助进程，因此不能把应用的总进程数固定说成“一个 main 加一个 renderer”。初学阶段掌握自己写的代码属于哪里即可；观察系统进程时，要区分“开发者主要编写的两类进程代码”与“Chromium 为运行应用创建的全部进程”。[官方进程模型](https://www.electronjs.org/docs/latest/tutorial/process-model)

## 实验二：一个能显示运行环境的完整桌面应用

新建空目录 mental-lab，按下面内容保存全部五个文件。终端进入该目录，先执行 `npm install`，再执行 `npm start`；Windows 可以使用 `npm.cmd install` 与 `npm.cmd start`。package.json 的 main 指向主入口，scripts.start 调用项目中的 Electron。不要使用 node main.cjs 代替启动命令，那不会创建 Electron 桌面运行环境。

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

```js main.cjs
const { app, BrowserWindow } = require('electron');
const path = require('node:path');

function createWindow() {
  const win = new BrowserWindow({
    width: 760, height: 520,
    webPreferences: {
      preload: path.join(__dirname, 'preload.cjs'),
      contextIsolation: true, sandbox: true, nodeIntegration: false
    }
  });
  // 本地教学页面不需要导航到外站，也不需要创建新窗口。
  win.webContents.on('will-navigate', event => event.preventDefault());
  win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
  win.loadFile(path.join(__dirname, 'index.html')).catch(error => {
    console.error('页面加载失败：', error.message);
    app.quit();
  });
  console.log('主进程编号：', process.pid, '嵌入 Node：', process.versions.node);
}
app.whenReady().then(() => {
  createWindow();
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});
```

```js preload.cjs
const { contextBridge } = require('electron');

// 只公开非敏感的诊断快照；不公开 process 对象或任意系统接口。
contextBridge.exposeInMainWorld('runtimeInfo', {
  preloadPid: process.pid,
  processType: process.type,
  isolated: process.contextIsolated,
  sandboxed: process.sandboxed,
  electronVersion: 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'; object-src 'none'; base-uri 'none'; frame-src 'none'; connect-src 'none'">
  <title>认识 Electron 的运行位置</title>
  <script defer src="./renderer.js"></script>
</head>
<body>
  <h1>运行环境观察</h1>
  <p>页面显示受限的诊断数据；终端显示主进程日志。</p>
  <pre id="result"></pre>
</body>
</html>
```

```js renderer.js
const result = document.querySelector('#result');
// textContent 按文本展示 JSON，不把数据当作 HTML 执行。
result.textContent = JSON.stringify({
  rendererRequire: typeof require,
  rendererProcess: typeof process,
  fromPreload: window.runtimeInfo
}, null, 2);
```

预期桌面窗口显示 rendererRequire 与 rendererProcess 都是 undefined，preload 的 processType 是 renderer，isolated 与 sandboxed 为 true，Electron 版本为 44.3.0。终端打印另一个主进程编号。页面显示的 preloadPid 来自 preload 诊断快照，不是网页自己调用了 process，也不能用这一个值推断所有辅助进程的数量。本教材未启动这个 GUI，因此这些是待你本机核对的预期行为，不是截图验收结论。

main.cjs 中的 __dirname 是当前 CommonJS 文件所在目录，path.join 把它与文件名组合，避免终端工作目录变化影响资源定位。preload 要用绝对路径，这是 API 契约。loadFile 加载本地文件并返回 Promise，加载失败进入 catch。whenReady 等待 Electron 完成必要初始化；在它之前创建窗口会违反窗口 API 的使用时机。

preload 通过 contextBridge 公开一个很小的数据对象。公开的数据会被复制并冻结，函数则按桥接机制代理，不能把它理解成共享可变的全局对象。renderer.js 只读取页面可见的快照。以后需要动态信息时，应提供明确方法或事件，而不是不断修改 preload 的原对象并期待页面自动响应。[contextBridge 契约](https://www.electronjs.org/docs/latest/api/context-bridge)

## 为什么文件名与运行命令很重要

.cjs 明确告诉 Node 风格加载器这是 CommonJS 文件，适合本组 main 和 preload 示例。renderer.js 则由 HTML 的 script 标签加载，采用浏览器脚本环境。这三个文件都写 JavaScript，却不能任意交换 require、document 或 Electron API。语法相似并不代表运行能力相同，排错时应先问“这行代码究竟在哪运行”。

项目的 node_modules 只说明依赖安装在磁盘上，不会让普通网页自动理解 npm 包导入。Vue 项目通常由 Vite 解析模块并生成浏览器能运行的产物；本章不用构建工具，是为了把 Electron 入口与浏览器入口先看清。后续换成 Vue，renderer 的组件组织会变化，主进程与 preload 的权限边界不应因此消失。

页面日志通常在窗口开发者工具中，主进程日志在启动终端。若你只盯一个 Console，就可能以为代码没有执行。开发工具能帮助观察运行位置，但看到某个 isolated world 能访问 process，不表示网页主世界也能访问。排查时记录文件、进程类型、上下文和启动方式，比随意切换安全开关更有效。

## 练习：判断数据副本与进程责任

独立完成两项判断：用户点击“选择文件”时，哪一侧负责打开系统对话框，哪一侧负责显示结果；一个 renderer 的草稿对象传给另一侧后，本地修改是否应自动改变另一侧。不要只回答“用 IPC”，应说明为什么不能直接共享模块变量，以及主进程为什么仍要检查请求。

<details><summary>完整参考实验与答案</summary>

保存为 `ownership.cjs`，执行 `node ownership.cjs`。这是消息边界的 Node 模拟，不调用 Electron；输入来自页面候选，主进程式处理只接受固定动作。

```js ownership.cjs
const assert = require('node:assert/strict');
function acceptRequest(request) {
  if (!request || request.action !== 'choose-note') {
    return { ok: false, code: 'ACTION_NOT_ALLOWED' };
  }
  // 模拟系统选择结果，刻意不接受任意路径作为请求参数。
  return { ok: true, name: '学习笔记.txt' };
}
const pageRequest = { action: 'choose-note' };
const mainCopy = structuredClone(pageRequest);
pageRequest.action = 'delete-everything';
assert.equal(acceptRequest(mainCopy).ok, true);
assert.equal(acceptRequest(pageRequest).code, 'ACTION_NOT_ALLOWED');
console.log('主进程执行受限能力，页面只显示结果；副本不自动联动。');
```

对话框应由具备桌面权限的代码执行，页面通过受限桥请求“选择笔记”，收到可序列化结果后更新界面。复制语义避免依赖跨进程共享变量，但不自动保证参数可信：任何能调用桥的页面脚本都可能提出请求，因此真实主进程还要验证发送者与业务条件。这个模拟仅解释责任，不实现真实 IPC 或身份检查。

</details>

## 先沿着一个按钮追踪责任，而不是先背 API

假设页面上有一个“选择笔记”按钮。点击最先成为 DOM 事件，所以 renderer 最适合负责按钮禁用、加载状态和结果展示。它可以调用桥提供的 chooseNote，但不应该把磁盘路径、任意通道名称或者一段待执行的代码作为能力本身交出去。preload 负责把这个固定动作翻译成 IPC 请求，主进程再决定是否允许这个窗口发起操作，然后打开原生选择框。用户取消时，主进程返回取消结果；用户选中文件时，主进程只处理这次选择授权的文件。最后返回文本数据，renderer 再更新 DOM。

这条链里每层做的事情都不复杂，复杂的是不能跳过边界。若按钮直接获得整个文件系统模块，页面中一个注入脚本就可能扫描与任务无关的文件。若主进程把所有 UI 状态都存成全局变量，多窗口时一个窗口的选择又会覆盖另一个窗口的展示。先把能力和界面职责分开，之后讨论消息格式、取消和错误就有清楚的落点。本章只建立方向，后面的完整 IPC 实验才实现发送者检查，不能把概念模拟当作安全验证。

你可能会问：既然都在本机，为什么还需要验证消息？因为同一台电脑不意味着每一段执行中的网页代码都具有同等信任。界面可能展示导入的 Markdown、同步来的文本，或某个第三方库处理过的 HTML。主进程需要相信的是自己定义的操作合同，而不是“发消息的看起来像我的按钮”。用户界面上的按钮可以隐藏，但隐藏按钮不会阻止代码直接调用已经公开的方法。真正的限制必须位于掌握能力的一侧。

## 异步不等于已经离开当前执行位置

从浏览器经验出发，你可能知道 Promise 可以让调用者稍后拿到结果。这里还要补上一层：把循环放进 async 函数并不会自动把循环移到新线程。主进程中的大规模同步计算仍然占用它的 JavaScript 执行线程；renderer 中的同类计算仍然可能拖慢交互。一次 await 只是在等待尚未完成的异步结果时交还执行机会，不会把前面的同步计算自动拆开。

例如读取一个小配置文件与解析一个数百兆字节的文档，虽然都可以叫“导入”，资源特征却不同。前者往往可以交给异步文件接口；后者还包含长时间 CPU 计算和大量内存分配，需要考虑分块、worker 或 utility process 等专门安排。本组基础章不会假装给出通用后台任务框架，但你应当能先识别：卡顿发生在哪个进程，代码是否真有异步等待，资源由谁停止。观察到 preload 中也能使用某些 Node 风格 API，不能推导它是一个免费的后台计算区。

主进程崩溃通常意味着应用核心退出；一个 renderer 出问题则可能表现为某个窗口白屏或无响应。多进程为隔离提供条件，但不是“任何错误都不会影响别处”的保证。所有进程仍然共享机器资源，内存耗尽或者大量后台工作都可能影响整个体验。学习第一阶段不需要实现复杂恢复系统，不过需要避免把错误全部归结为 Vue 响应式失效：先确认窗口是否还存在、页面是否加载成功、preload 是否完成执行，再检查业务状态。

## 如何阅读后面的完整文件

本教材将文件名写在代码围栏旁边，文件名是运行合同的一部分。main.cjs 是由 Electron 加载的 CommonJS 主入口，preload.cjs 由窗口配置指定，renderer.js 是 HTML 的 script 元素加载的浏览器脚本。同样一行 require 放在这三个文件中，含义和可用范围会不同；不要因为扩展名都与 JavaScript 有关，就把它们互相移动来消除报错。

学习时建议先按原样建立独立目录并运行，再修改一个变量观察因果。把桥名称从 runtimeInfo 改成 info，却不改页面访问处，应当得到页面缺少数据的错误；把页面脚本路径改错，应当看到网络或加载错误；把窗口尺寸改动，则不应影响桥的数据。这样能把“宿主负责什么”和“页面负责什么”区分出来。后续练习要求解释失败路径，目的正是让你能从症状定位运行位置，而不是只保存一份碰巧能打开的模板。

## 故障推演与掌握标准

如果窗口有内容但 runtimeInfo 为 undefined，应先检查 preload 的绝对路径、文件名和加载错误，再检查 exposeInMainWorld 的名称；不要直接关闭 contextIsolation。如果 main 中 document 未定义，说明把页面代码放到了主进程；应把 DOM 更新放回 renderer。如果页面 require 未定义而按钮正常，这通常正是本章希望维持的边界。

掌握本章的标准是能从启动命令一路解释到页面：开发机 Node 运行 npm，npm 启动 Electron，Electron 执行主入口，主入口创建窗口，preload 在所属 renderer 的隔离世界运行，网页通过受限桥读取明确数据。还要能说明 preload 不新增第三个独立进程、主进程不等同于后端 HTTP 服务，以及安全选项为什么不应当作“修复报错的开关”。

自测一：把计算放到 preload 就能避免卡住页面吗？不能据此保证，preload 并不是新后台进程。自测二：安装最新系统 Node 会升级已打包应用的 Chromium 吗？不会，运行组件由应用所带 Electron 决定。自测三：页面看到 runtimeInfo，是否拥有 process 的全部能力？没有，桥只公开明确列出的数据。

## 官方来源与验证边界

继续阅读 [进程模型](https://www.electronjs.org/docs/latest/tutorial/process-model)、[第一个应用](https://www.electronjs.org/docs/latest/tutorial/tutorial-first-app)、[进程属性](https://www.electronjs.org/docs/latest/api/process)、[沙箱](https://www.electronjs.org/docs/latest/tutorial/sandbox) 与 [Node vm](https://nodejs.org/api/vm.html)。官方 API 在二〇二六年九月核对；固定版本用于复现，不代表永远不应升级。Node 概念实验可离线运行；Electron 文件仅做语法与契约检查，窗口和系统行为需要本机实际启动验收。
