【Electron】它到底怎么运作:架构、安全、打包与性能全景

19 分钟

VS Code、Slack、Discord、Notion、1Password 的桌面端都是 Electron 写的。它让你用写网页的技术做跨平台桌面应用,代价是每个应用自带一份浏览器内核。接下来我们需要弄清楚:Electron 到底是什么、为什么是这套多进程结构、几个核心概念怎么结合在一起。

Electron 就是 Chromium 加 Node,再切成多个进程

Electron = Chromium(渲染界面)+ Node.js(文件 / 进程 / 系统 API)+ 多进程架构。

  • Chromium 把 HTML/CSS/JS 渲染成界面,相当于内置了一个浏览器内核。
  • Node.js 提供读写文件、启动子进程、访问系统 API 这些浏览器里无法直接获取的能力。
  • 多进程把这两部分组织起来。

为什么要拆成多进程:从 Chrome 继承来的崩溃隔离

官方 Process Model 文档记载了这段历史。早期浏览器所有标签页跑在一个进程里,开销小,但一个页面崩了或卡死会拖垮整个浏览器。Chrome 的解法是让每个标签页跑在自己的渲染进程里。这样某个页面出了问题,崩溃也被关在它自己的进程里。

这样做的代价是每多一个进程就多一份内存和运行时开销,但是能换来两层隔离:崩溃上,一个渲染进程挂了不会拖垮别的窗口;安全上,进程边界是操作系统级的,一段恶意脚本即便在渲染进程里跑起来,也被挡在进程内,无法访问进程外的内存和资源。Electron 直接继承了这套模型。

主进程和渲染进程:一个管系统,一个管界面

Electron 应用至少有两类进程。

主进程(main process)只有一个,跑在 Node.js 环境里。拥有创建和管理窗口(BrowserWindow)、用 app 模块控制启动退出、调用菜单 / 对话框 / 托盘这些原生桌面能力,管理整个应用的生命周期和原生资源。它是可信的一侧,能读写文件、起进程、调系统 API。

渲染进程(renderer process)每个窗口一个,跑在 Chromium 里,行为遵循 web 标准。你的 React / Vue / 普通 JS 界面代码都在这里。默认情况下它被限制在浏览器沙箱(操作系统级的受限环境,阻止代码读写文件或调系统)里,无法使用 Node.js。不过 Electron 也允许你关闭沙箱、开启 nodeIntegration,这时 Node.js 也会被嵌入渲染进程,渲染进程就能直接用 require('fs') 读写文件。但这样做不安全:渲染进程加载的是网页,网页被 XSS 攻击就等于直接给了攻击者系统权限,所以 Electron 20 起默认不这么做。

主进程渲染进程
数量1 个每个窗口 1 个
运行环境Node.jsChromium
职责窗口管理、生命周期、文件 / 进程 / 系统 API渲染界面,遵循 web 标准
默认能不能用 Node不能

两个循环怎么合并到一起

主进程需要同时管两类事情:一类是创建窗口、弹对话框、管理托盘图标这些原生 GUI 操作,需要依赖 Chromium 的能力;另一类是读写文件、调系统 API、启动子进程等等,依赖的是 Node.js 的能力。所以 Electron 把 Node.js 直接嵌入 Chromium 进程里,让它们在同一个进程里一起跑。

但这里有个麻烦:Chromium 自己有一套消息循环,用来处理窗口事件、用户输入等;Node.js 底层(一个叫 libuv 的 C 库)也有自己的一套事件循环,用来处理文件读写、网络请求这些异步操作。同一个主线程上,一次只能跑一个循环。Electron 得想办法让它们俩配合起来。

默认情况下的渲染进程就没这个问题。默认的渲染进程是纯 Chromium,不嵌入 Node,自己跑一套循环就够了。

第一次尝试:用 Node 的循环替代 Chromium 的

Electron 作者 Cheng Zhao 最初的想法很简单:直接用 libuv 替代 Chromium 的消息循环不就行了?这个做法在渲染进程上行得通:渲染进程的 Chromium 只负责渲染页面,它的消息循环只需要等文件描述符和定时器,用 libuv 直接替代 Chromium 的消息循环就能正常运转。所以即便渲染进程嵌入了 Node,也能用同一套 libuv 循环调度所有事件,不存在两个循环冲突的问题。

但主进程不行。每个操作系统的 GUI 底层都有自己的循环(macOS 的 NSRunLoop、Linux 的 glib),libuv 对接不了所有平台特有的机制,直接替换总会碰到覆盖不了的边界情况。

第二次尝试:插个定时器轮询

Cheng Zhao 换了一种思路:不动 Chromium 的循环,在 libuv 的事件循环里插一个定时器,每隔很短时间就去轮询 Chromium 的消息循环,让主线程在跑 GUI 循环的间隙也能处理 libuv 事件。但问题是这个定时器一直在跑,即使没有事件需要处理也不能停,既浪费 CPU,又让某些操作有明显的延迟感。

最终方案:后台线程轮询,有事件再通知

后来 libuv 引入了一个叫 backend fd 的机制。libuv 等待操作系统通知时,底层依赖一个文件描述符来监听。Cheng Zhao 的思路变成:另开一个后台线程专门轮询这个 backend fd,发现 libuv 有事件待处理时,就往 Chromium 的消息循环投递一条消息。

主线程从消息队列里读到这条消息,就跑一轮 libuv,把 Node 那边堆积的回调(比如 readFile 读完后的回调、网络请求返回后的回调)逐一执行,执行完就回来继续跑 Chromium 的循环。

两套循环没有合并成一套,只是通过后台线程联动。Chromium 的循环始终是主循环,libuv 的循环等主循环通知后才跑一轮。后台线程只在有事件待处理时发通知,其余时间挂起等待。这套方案既解决了空转和延迟的问题,又不需要改动 Chromium 和 Node 本身的代码。

Preload:连接两个世界的通道

主进程可以读写文件、调起子进程,渲染进程则被沙箱限制,干不了这些。那界面想调系统能力怎么办。解决方案是在 preload 脚本中增加进程间通信(IPC)。

preload 脚本由 Electron 在渲染进程加载网页内容之前注入执行。preload 脚本要在网页代码执行之前把 contextBridge 的接口搭好。注入时 Electron 赋予它访问部分 Node.js API 和部分 Electron API 的能力,这是普通网页代码没有的特权。不过前面那个限制渲染进程的沙箱(默认开启)同样约束着 preload,它能用的 Electron API 只是一个受限的集合,比如 ipcRenderercontextBridge,加上 eventstimersurl 这类少数的 Node 内置模块,文件读写还是得交给主进程处理。

preload 脚本的作用是给界面暴露一个受限、可控的接口,它不会把 Node 能力整个交给网页,而是通过 contextBridge 把几个明确的函数挂到 window 上,其余的一概不暴露,遵守最小知识原则。

Context Isolation:为什么网页默认无法访问 Node

Context Isolation 是 Electron 的一种安全机制,它把 preload 脚本和网页代码放到不同的 JavaScript 执行环境里。因为 preload 能调用部分 Node API,如果网页能直接访问 preload 的作用域的话,第三方脚本或 XSS 注入就可以利用这些 Node 能力进行一些危险的攻击行为。把它们的运行环境隔离,网页代码就没有 preload 调用 Node API 的能力。

这套机制依靠的是 V8 引擎的隔离上下文能力。V8 允许在同一个引擎里创建多个互不关联的运行环境,各自有自己的一套全局对象,你改你的 window 不影响另一边的 window。preload 跑在一个上下文里,网页代码跑在另一个上下文里,preload 挂到 window 上的东西,网页那边是看不见的。

Context Isolation 只在 JS 层面有效。网页顺着 window 引用链无法访问到 preload 那一侧的变量,但它不是操作系统级的进程隔离,所以还需要叠加前面那个沙箱才能构成完整的保护。

既然两边的 window 不是同一个,就不能在 preload 里直接往 window 上挂变量给网页用。得通过 contextBridge.exposeInMainWorld 来传值。contextBridge 跨边界传递时,普通值(字符串、数字、对象)做结构化拷贝传过去,函数则通过代理(proxy)机制让网页侧调用但实际在 preload 侧执行。preload 这侧的对象引用不会泄露给网页。接口暴露什么、不暴露什么,由开发者来决定。

这几个安全默认值是逐版本收紧的:contextIsolation 从 Electron 12 起默认 truesandbox 从 Electron 20 起默认 true(见官方 Breaking Changes)。20 以前的老教程里那种‘关掉 contextIsolation,在渲染进程直接 require’的写法,现在既不安全也不符合默认值。

这里有三个常被混淆的开关:nodeIntegration 决定渲染进程能不能直接用 Node(默认关),sandbox 决定渲染进程是否被限制在沙箱(默认开),contextIsolation 决定网页和 preload 的 JS 上下文是否隔离(默认开)。渲染进程默认无法访问 Node,直接原因是 nodeIntegration 关闭;sandbox 的约束比 nodeIntegration 更强,连 preload 能用的 Node 都压缩到极小子集;contextIsolation 处理的是另一个维度,网页和 preload 的 JS 上下文隔不隔离。三者应对不同方向的越界,少配置一个都会留下风险缺口。另外 sandbox 开启时会强制覆盖 nodeIntegrationfalse,即使你显式设为 true 也不生效。

IPC:进程之间的通话方式

进程之间内存不共享,主进程和渲染进程要协作,靠的是进程间通信(IPC,Inter-Process Communication)。Electron IPC 底层走 Chromium 的 mojo 管道,消息序列化用 V8 内置的序列化机制。可以把它理解成应用内部一套轻量的请求 - 响应接口:渲染进程发消息,主进程处理,结果再回传给渲染进程。ipcRenderer.invoke 是 request-response 模式,返回 Promise 是内置行为,不需要手动包装;send / on 则是单向通知,收发双方各司其职。没有 IPC,被隔离的界面和有特权的主进程就无法协作。

一条完整的数据流:点按钮读文件

把上面几个概念串到一起看。界面上有个按钮,点一下让主进程读一个文件、再把内容显示出来,整个过程是这样的:

preload 暴露一个最小接口:

// preload.js
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("electronAPI", {
  openFile: () => ipcRenderer.invoke("dialog:openFile"),
});

主进程注册处理函数:

// main.js
const { ipcMain, dialog } = require("electron");
const fs = require("node:fs/promises");

ipcMain.handle("dialog:openFile", async () => {
  const { canceled, filePaths } = await dialog.showOpenDialog();
  if (canceled) return null;
  return fs.readFile(filePaths[0], "utf-8");
});

渲染进程里的界面代码在按钮回调里调用,拿到内容写进页面:

// 渲染进程(按钮点击回调里)
const content = await window.electronAPI.openFile();
document.querySelector("#output").textContent = content;

三段组合起来是一条完整的往返:网页只看得到 window.electronAPI 上那个函数,调用它,请求经 preload 走 IPC 到主进程,主进程弹对话框、用 Node 的 fs 读文件,结果原路作为 Promise 返回写进页面。把这三段串联起来要靠 BrowserWindow 创建时指定 preload 脚本:

// main.js
const mainWindow = new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, "preload.js"),
  },
});

invoke / handle 是现在的推荐写法(官方 IPC 文档),IPC 还有 send / on(单向通知)等模式,但 invoke / handle 是开发时最常遇到的。

安全模型:渲染进程不可信

上面的数据流里,渲染进程发什么、主进程做什么,表面看都是自己写的代码,似乎是可控的。但安全上并不能这么假设:渲染进程加载的是网页,网页可能被 XSS 注入、可能引入第三方脚本。Electron 不是浏览器,这里的 JS 能读写文件、能调用系统,一旦被攻破代价比网页漏洞大得多。所以官方 Security 文档始终坚持一个原则:渲染进程是不可信的,只有主进程是可信的。

这个信任边界的落脚点是 IPC。contextBridge 只决定了网页能调哪些函数,决定不了这些函数被谁调,网页一旦被 XSS 注入,注入的代码照样能调用那几个暴露出来的接口。所以每一条从渲染进程发来的消息,都要当成来自不可信客户端的请求,需要校验内容,也要校验来源,这是 contextBridge 之外的第二道防线。官方建议在每个 ipcMain.handle 里用 event.senderFrame 拿到来源 frame,核对它的 URL 是否在白名单里。因为一条 IPC 消息可能来自任意窗口,甚至来自页面里嵌的 iframe(比如第三方广告 iframe 也能往主进程发消息)。主进程不能默认每条消息都出自自己的界面,授权判断只能放在主进程,不能依赖渲染进程传过来的状态。

具体做法有固定的模式:preload 用 contextBridge 只暴露有限而具体的函数,绝不把整个 ipcRenderer 传出去;再加一层 CSP(内容安全策略)来阻挡 XSS。核心原则就是:渲染进程是不可信的,IPC 是需要校验的信任边界。

代价:包体、内存,以及什么时候别用

每个应用自带一份 Chromium 和 Node,所以安装包通常有几十到上百 MB,内存占用也比原生应用高不少。

Electron 维护者 Felix Rieseberg 写过一篇关于 Electron 常见误解的文章(Things people get wrong about Electron),其中提到:Electron 本来就允许你把 web 界面和任意原生代码搭配,C++、Objective-C、Rust 都行,特别吃性能的部分可以改用原生代码来写,1Password 的本地核心几乎全是用 Rust 写的。所以性能表现最终还是取决于怎么用。

如果你做的是对体积和内存很敏感的小工具,或者本来就只跑一个平台、能用系统原生框架(比如 macOS 上的 SwiftUI、Windows 的 WPF、或者 Rust 写的 Tauri),那为了一套浏览器内核付出几十上百 MB 就不太划算了。

性能:启动慢在哪,重活往哪放

官方 Performance 文档列出了拖慢 Electron 的常见原因,总结起来是两类。

一类是启动时做的事情太多。require 一堆带大量子依赖的模块、把初始化全堆在启动那一刻、代码没打包导致多个 require 同步阻塞主线程。

一类是运行时卡住线程。一个长任务跑在主进程里,整个界面就被卡住了。官方的第一条建议是先做 profiling,用 DevTools Performance 面板或者 Electron 自带的工具看看哪些函数的执行时间最长,找到最耗资源的那块再动手,不要一上来就套优化技巧。

CPU 密集或者容易崩溃的任务不应该放在主进程里。Node 本身提供了启动子进程和子线程的机制(child_processworker_threads),Electron 又额外提供了更贴合自己的 utilityProcess,官方建议从主进程拆分任务时优先使用它。选型上大致可以这样判断:utilityProcess 适合需要 Electron 特定能力或者和主进程同生命周期管理的场景,worker_threads 适合纯 CPU 计算,child_process 适合独立脚本或者二进制调用。

打包与分发:从源码到能装的安装包

开发的时候执行命令 electron . 就能启动应用,但用户装的是安装包。把源码变成可分发的安装包(Windows 的安装程序、macOS 的 .app)需要额外工具。官方 打包教程 首推 Electron Forge,它是 Electron 官方维护的一体化工具,底层把 @electron/packager@electron/osx-sign@electron/notarize 这些工具整合到一起。社区里另一个主流选择是第三方的 electron-builder,配置更灵活一些,支持的安装包格式更多,还自带一套自动更新方案。

代码签名虽然技术上可以跳过,但不签的话用户下载后通常会被系统拦下来,需要经过多重手动步骤才能运行,实际使用中几乎行不通。Windows 和 macOS 各有自己的签名体系,macOS 还要额外做一步公证(notarization),把 app 传给 Apple 自动核验。签名也是自动更新的前提条件。

原生模块:为什么装好的模块搬进 Electron 就报错

有些 npm 包里带了原生模块,也就是用 C/C++ 写的、需要编译的 .node 二进制文件,比如 better-sqlite3。这类包有一个 Electron 特有的问题。先说 ABI(应用二进制接口),它是编译好的二进制文件和运行它的程序之间的接口约定,比如内存怎么排、函数怎么调,约定对不上模块就加载不了。Electron 内置的 Node 是一份定制构建:它链接的是 Chromium 的 BoringSSL 而不是 OpenSSL,V8 和编译选项也不一样,整体 ABI 和系统的 Node 对不上(官方文档正是拿 BoringSSL 替换 OpenSSL 来举例说明这个差异)。NODE_MODULE_VERSION 就是给这套 ABI 打的标记,原生模块编译时会记下它、加载时拿它来比对。所以正常 npm install 下来、按系统 Node 编译出来的原生模块,一搬到 Electron 里加载就会报 NODE_MODULE_VERSION 不匹配。

解决方法是针对 Electron 重新编译一遍。官方 Native Node Modules 文档建议用 @electron/rebuild:它能自动判断 Electron 的版本、下载对应的头文件、把原生模块重新编译,有现成的预编译产物就直接用。每次升级 Electron 之后,通常都要重新跑一遍 rebuild。

附:版本节奏

Electron 的大版本是 8 周一个节奏,与 Chromium 的版本对齐,对齐的是 Chromium 的偶数版本(官方 Electron Timelines)。比如 Electron 26 用的是 Chromium 116,Electron 27 用的是 Chromium 118。Node.js 则在偶数版进入 Active LTS 时跟着更新。官方只维护最近三个稳定大版本,落后了安全补丁就跟不上了。

Electron 最初是 GitHub 为 Atom 编辑器做的运行时,2013 年叫 Atom-Shell,2015 年改名为 Electron,2016 年发布了 1.0。

小结

到这里,Electron 的完整心智图就立起来了。运行时是一个 Chromium 加 Node 的多进程系统:主进程拥有系统特权,渲染进程被沙箱限制在 web 标准范围内。preload 通过 contextBridge 暴露受限的接口,IPC 在两者之间传递消息,Context Isolation 保证网页无法访问 preload 和 Electron 内部的特权 API。安全上始终要把渲染进程当成不可信的一侧,IPC 是需要校验的信任边界。再往外一步,把它做成能分发的产品,还要考虑打包签名、原生模块的 ABI 重编译,以及多进程带来的内存和启动代价。

参考资料

目录