Electron 是什么:运行环境与进程关系
从已有 Web 经验建立 Electron、嵌入 Node、主进程、渲染进程和 preload 的准确关系。
本页内容
从你熟悉的网页开始,而不是从进程名词开始#
你已经能写 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 只用于演示概念,不能当作执行恶意代码的安全沙箱。
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 为运行应用创建的全部进程”。官方进程模型
实验二:一个能显示运行环境的完整桌面应用#
新建空目录 mental-lab,按下面内容保存全部五个文件。终端进入该目录,先执行 npm install,再执行 npm start;Windows 可以使用 npm.cmd install 与 npm.cmd start。package.json 的 main 指向主入口,scripts.start 调用项目中的 Electron。不要使用 node main.cjs 代替启动命令,那不会创建 Electron 桌面运行环境。
{
"name": "electron-mental-lab",
"version": "1.0.0",
"private": true,
"main": "main.cjs",
"scripts": { "start": "electron ." },
"devDependencies": { "electron": "44.3.0" }
}
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();
});
const { contextBridge } = require('electron');
// 只公开非敏感的诊断快照;不公开 process 对象或任意系统接口。
contextBridge.exposeInMainWorld('runtimeInfo', {
preloadPid: process.pid,
processType: process.type,
isolated: process.contextIsolated,
sandboxed: process.sandboxed,
electronVersion: process.versions.electron
});
<!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>
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 契约
为什么文件名与运行命令很重要#
.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”,应说明为什么不能直接共享模块变量,以及主进程为什么仍要检查请求。
完整参考实验与答案
保存为 ownership.cjs,执行 node ownership.cjs。这是消息边界的 Node 模拟,不调用 Electron;输入来自页面候选,主进程式处理只接受固定动作。
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 或身份检查。
先沿着一个按钮追踪责任,而不是先背 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 的全部能力?没有,桥只公开明确列出的数据。
官方来源与验证边界#
继续阅读 进程模型、第一个应用、进程属性、沙箱 与 Node vm。官方 API 在二〇二六年九月核对;固定版本用于复现,不代表永远不应升级。Node 概念实验可离线运行;Electron 文件仅做语法与契约检查,窗口和系统行为需要本机实际启动验收。