首页
/ Electron 进程模型完全指南:主进程、渲染进程与工具进程的架构与实践

Electron 进程模型完全指南:主进程、渲染进程与工具进程的架构与实践

2026-09-07 12:44:04作者:瞿蔚英Wynne

Electron 的多进程架构直接继承自 Chromium,因此每个 Electron 应用本质上就是一台"自带 Node.js 运行时、可访问系统级 API 的微型浏览器"。本篇指南以官方 Process Model 教程 为骨架,结合本仓库源码与类型定义,系统讲解 Electron 为什么采用多进程、主进程/渲染进程/预加载脚本/工具进程各自的职责与边界,以及如何用 IPC 与 contextBridge 让它们安全协作。读完你将能准确回答"代码应该放在哪个进程、为什么"这一 Electron 开发中最核心的问题。

为什么不能只用一个进程?

网页浏览器是极其复杂的应用程序:除了显示网页内容这一核心职责,它还需要管理多个窗口(或标签页)、加载第三方扩展、处理网络请求、维护渲染与合成管线等大量次要任务。

在早期,浏览器通常用单个进程承载所有这些功能。这种模式确实降低了每个标签页的开销,但也带来了致命缺陷:任何一个网页崩溃或卡死,整个浏览器都会遭殃——一个渲染错误、一段恶意脚本或一次死循环,就能让用户丢失全部其他标签页的工作状态。

Electron 之所以继承 Chromium 的多进程架构,正是为规避这一风险:通过进程隔离把"故障域"缩小到单个页面,让有问题的代码无法波及整个应用。

多进程模型:浏览器进程 + 多个渲染进程

Chrome 团队当年的解决方案是:让每个标签页运行在独立进程中,从而把网页上有 Bug 或恶意的代码可能对整个应用造成的危害限制到最小。再由一个**浏览器进程(browser process)**统一控制这些渲染进程以及整个应用的生命周期。

Chrome 的多进程架构示意,每个标签页(渲染进程)由中央浏览器进程统一调度

Electron 应用的结构与之高度相似。作为应用开发者,你可以直接控制两类进程:

在具体代码中,process.type 是区分当前运行环境的最直接方式:值为 browser 时运行在主进程,值为 renderer 时运行在渲染进程。例如 crash-reporter.ts 中的上报逻辑就按 process.type === 'browser' 区分执行环境。

主进程(Main Process):应用入口与窗口管理者

每个 Electron 应用有且仅有一个主进程,它是整个应用的入口点。当你在 package.jsonmain 字段里指定入口脚本时(本仓库的 npm/package.json 即为可执行文件分发的载体),Electron 便从该文件启动主进程。

主进程运行在完整的 Node.js 环境中,因此它可以:

  • 通过 require 加载模块(从 lib/browser/api 目录可以看到,主进程侧全部 Electron API 均由 TypeScript 模块实现);
  • 使用全部 Node.js API(文件系统、网络、子进程等);
  • 访问 Electron 提供的系统级模块。

窗口管理:BrowserWindow 与 webContents

主进程最核心的职责,是使用 BrowserWindow 模块创建并管理应用窗口

每一个 BrowserWindow 实例都会创建一个应用窗口,并在独立的渲染进程中加载一个网页;主进程可以通过窗口的 webContents 对象与该网页内容交互:

const { BrowserWindow } = require('electron')

const win = new BrowserWindow({ width: 800, height: 1500 })
win.loadURL('https://github.com')

const contents = win.webContents
console.log(contents)

几点关键特性值得展开:

  • web embed 同样会创建渲染进程BrowserView网页嵌入模块也会各自生成一个渲染进程,其 webContents 对象同样可从主进程访问。
  • BrowserWindow 是 EventEmitter:你可以为各类用户事件添加处理器(例如最小化、最大化、窗口失焦/聚焦)。在 browser-window.ts 的 TS 包装层中可以看到,它重写了 _init,把底层窗口的 blur/focus 事件同步转发为 app 上的 browser-window-blur/browser-window-focus 事件,甚至把渲染进程的 unresponsive(无响应)事件向上冒泡——这些都是进程级健壮性设计在 API 层的体现。
  • 窗口即进程的生命周期:当一个 BrowserWindow 实例被销毁,其对应的渲染进程也会随之终止。窗口与渲染进程一一对应、同生共死,是理解整个模型的关键。

应用生命周期:app 模块

主进程通过 Electron 的 app 模块控制应用生命周期。该模块提供了大量事件与方法,用于定制应用行为——例如以编程方式退出、修改 macOS Dock、展示"关于"面板等。

本仓库中 app.ts 的实现揭示了它的底层结构:真正的 app 对象来自原生绑定 process._linkedBinding('electron_browser_app')(其 C++ 实现位于 shell/browser/api/electron_api_app.cc),TS 层通过 Object.setPrototypeOf(app, EventEmitter.prototype) 让它继承 Node.js 的 EventEmitter,使其天然具备事件驱动能力。

下面是最经典的 app 生命周期示例,即教程第一个应用(tutorial-2-first-app.md)中的"全部窗口关闭即退出"逻辑:

// quitting the app when no windows are open on non-macOS platforms
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

注意这里刻意保留了 macOS 例外:按照平台惯例,macOS 应用在关闭所有窗口后通常仍驻留 Dock,等待用户再次激活。

原生能力扩展:系统级 API

为了让 Electron 不只是一个"加载网页的 Chromium 外壳",主进程还额外提供了大量与操作系统交互的自定义 API,覆盖菜单、对话框、托盘图标等原生桌面功能:

完整清单可查阅 lib/browser/api/module-list.ts 中登记的主进程模块,以及对应的 API 文档 目录。

渲染进程(Renderer Process):负责网页渲染

每个打开的 BrowserWindow(以及每个 web embed)都会各自派生一个独立的渲染进程。顾名思义,渲染进程的职责是渲染网页内容

渲染进程中的代码在行为上应符合 Web 标准(至少与 Chromium 的实现一致)。因此,单个窗口内的所有用户界面与功能逻辑,都应该使用你在 Web 开发中惯用的工具和范式来编写。渲染进程技术的底线知识只有三条:

  • HTML 文件是渲染进程的入口点
  • UI 样式通过**层叠样式表(CSS)**添加;
  • 可执行 JavaScript 通过 <script> 元素引入。

相应地,渲染进程不能直接访问 require 或其他 Node.js API。若要在渲染进程里引入 npm 模块,必须像在 Web 前端那样使用打包工具链(如 webpackparcel)进行打包。

[!WARNING] 渲染进程也可以出于开发便利而以"完整 Node.js 环境"启动。历史上这曾是默认行为,但出于安全考虑,该特性后来被默认禁用(详见 security.md 中关于 Node.js 集成的说明)。

到这里自然会产生疑问:Node.js 与 Electron 的原生桌面功能都只对主进程开放,渲染进程的界面如何与之交互?答案很明确:不存在直接导入 Electron 内容脚本的途径——两者之间必须经由下面介绍的桥梁机制。

预加载脚本(Preload Script):渲染进程的安全特权层

预加载脚本(preload script)包含一段在渲染进程网页内容开始加载前执行的代码。它运行在渲染进程上下文内,但被授予更多权限——可以访问 Node.js API

如何挂载与如何理解它的双身份

预加载脚本在主进程中通过 BrowserWindow 构造函数的 webPreferences.preload 选项挂载:

const { BrowserWindow } = require('electron')
// ...
const win = new BrowserWindow({
  webPreferences: {
    preload: 'path/to/preload.js'
  }
})
// ...

注意 preload 路径建议使用 path.join(__dirname, 'preload.js') 拼出绝对路径,避免工作目录变化导致加载失败。

预加载脚本之所以特殊,是因为它同时拥有两套身份:它与所附着的渲染进程共享同一个全局 Window 接口,却又因 Node.js 环境而能访问系统能力。它的典型用途,就是通过在 window 全局上暴露自定义 API,为网页内容提供桌面能力入口。

contextIsolation:为什么不能直接挂 window

尽管预加载脚本与渲染进程共享同一个 window,但由于 contextIsolation(上下文隔离) 默认为开启,你不能直接把预加载脚本中的变量直接赋值给 window

window.myAPI = {
  desktop: true
}
console.log(window.myAPI)
// => undefined

上下文隔离意味着:预加载脚本与渲染进程的"主世界(main world)"彼此隔离,从而避免任何特权 API 泄漏进网页内容代码。这正是 Electron 安全模型的地基之一(glossary.md 中对此有精确定义)。

正确做法:用 contextBridge 安全地暴露 API

正确的做法是使用 contextBridge 模块,它提供唯一的、经隔离验证的通道,把 API 暴露到主世界:

const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('myAPI', {
  desktop: true
})
console.log(window.myAPI)
// => { desktop: true }

实践中,这一机制主要用于两大场景:

  1. IPC 桥接:通过暴露封装好的 ipcRenderer 帮助函数,渲染进程即可借助进程间通信(IPC)触发主进程任务(反之亦然)。完整的双工消息模式可参考 ipc.md 教程与 ipcRenderer / ipcMain API。
  2. 桌面壳增强远程 Web 应用:如果你正在为托管在远程 URL 的既有 Web 应用开发 Electron 包装壳,可以给渲染进程的 window 全局添加自定义属性,让 Web 客户端据此运行"仅桌面端"的逻辑(例如本地文件读写入口、系统通知开关)。

工具进程(Utility Process):主进程的可控子进程

除主/渲染两类进程外,Electron 还提供工具进程。每个 Electron 应用都可以从主进程使用 UtilityProcess API 派生多个子进程

工具进程运行在 Node.js 环境中,可以 require 模块、使用全部 Node.js API。它适合托管以往只能塞进主进程或 Node.js child_process.fork 子进程的负载,典型场景包括:

  • 不可信服务:与主进程隔离,即使被攻破也影响有限;
  • CPU 密集型任务:避免阻塞主进程的事件循环与 UI 响应;
  • 易崩溃组件:把崩溃风险隔离到独立进程。

与 child_process.fork 的本质差异

工具进程与 Node.js child_process 派生的进程之间,最核心的区别在于:工具进程可以利用 MessagePort 与渲染进程建立通信通道。因此官方建议:当主进程需要 fork 子进程时,优先使用 UtilityProcess API 而非 child_process.fork

从实现看,工具进程底层并非 Node.js 的 fork,而是由主进程 TS 包装层(utility-process.ts)调用原生绑定 electron_browser_utility_process,其 C++ 实现位于 electron_api_utility_process.cc,通过 Chromium 的进程管理机制拉起。TS 层同时承担参数校验:例如校验 execArgv 必须为字符串数组、serviceName 必须为字符串、stdio 只允许 pipe/ignore/inherit 三值且 stdin 只能为 ignore,非法配置会直接抛出明确的错误。

fork 选项与用法示例

utilityProcess.fork(modulePath[, args][, options]) 的核心签名:

  • modulePath string —— 子进程入口脚本路径(必填,缺失会抛出 Missing UtilityProcess entry script.);
  • args string[](可选)—— 以 process.argv 形式传入子进程的字符串参数;
  • options Object(可选),主要字段包括:
    • env Object —— 环境变量键值对,默认继承 process.env
    • execArgv string[] —— 传给可执行文件的参数;
    • cwd string —— 子进程当前工作目录;
    • session / partition —— 指定子进程网络请求所用的 session;设置后可获得 HTTP 缓存等会话级网络能力(默认使用不支持 HTTP 缓存的系统网络上下文),partitionpersist: 前缀区分持久/内存会话;
    • stdio —— 配置子进程 stdout/stderr 模式,默认 inheritpipe 可捕获输出流;
    • serviceName —— 进程在 app.getAppMetrics() 中显示的名称,默认 Node Utility Process
    • macOS 专属 allowLoadingUnsignedLibrariesdisclaim 等。

一个把子进程 stdout 接到管道的例子:

const { utilityProcess } = require('electron')

const child = utilityProcess.fork(path.join(__dirname, 'worker.js'), [], {
  stdio: 'pipe'
})

child.on('spawn', () => {
  console.log('child pid:', child.pid)
})

child.stdout.on('data', (data) => {
  console.log(`Received chunk ${data}`)
})

工具进程实例(UtilityProcess)本身是 EventEmitter,可监听 spawnexitmessage 等事件;子进程内部通过 process.parentPort.postMessage() 向主进程回发消息。

[!NOTE] utilityProcess.fork 只能在 app 发出 ready 事件后调用。

进程专属的 TypeScript 类型别名

Electron 的 npm 包还导出了若干子路径,其中包含 Electron TypeScript 类型定义的子集,便于按进程精确约束类型:

  • electron/main —— 主进程全部模块的类型;
  • electron/renderer —— 渲染进程全部模块的类型;
  • electron/common —— 主进程与渲染进程均可运行模块的类型。

这些别名对运行时没有任何影响,仅用于类型检查与自动补全,从类型层面强制你遵守"进程边界":

const { shell } = require('electron/common')
const { app } = require('electron/main')

总结:代码放哪,取决于它想做什么

综合全篇,Electron 的进程边界可以用一张决策表概括:

进程类型 运行环境 职责 代码存放 如何触达其他进程
主进程(唯一) Node.js 应用入口、窗口与生命周期、原生系统 API main.js 及其模块 直接调用 Electron API;经 ipcMain 接收消息
渲染进程(每窗口一个) Web 标准环境 渲染 UI、页面交互 HTML/CSS/JS contextBridge + ipcRenderer 走 IPC
预加载脚本 渲染进程 + Node 特权 安全暴露桥接 API preload.js contextBridge.exposeInMainWorld
工具进程(可多个) Node.js 隔离不可信服务、重活、易崩组件 独立入口脚本 UtilityProcess + MessagePort

判断法则很简单:凡是需要操作系统能力或 Electron 原生模块的,放主进程;凡是页面交互与界面渲染的,放渲染进程;凡是需要跨进程、又要安全的,一律通过 preload + contextBridge + IPC。遵循这套进程模型,不仅能让应用结构清晰,更是写出稳定、安全、可维护的 Electron 应用的第一步。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐