一、Electron 的进程模型与启动流程
1.1 先从 Chromium 说起
要看懂 Electron,得先对 Chromium 的多进程架构有个基本概念。Chromium 本身就是一个多进程浏览器,在 Electron 诞生之前,这套进程分工就已经很清晰了:
| Chromium 进程 | 职责 |
|---|---|
| Browser Process | 主进程:管理窗口、网络请求、存储、安全策略 |
| Renderer Process | 渲染进程:执行 JS、渲染 HTML/CSS、处理 DOM |
| GPU Process | 图形进程:加速 2D/3D 渲染 |
| Utility Process | 工具进程:处理沙箱内的敏感任务(如解码、协议处理) |
| Zygote Process | 孵化进程:快速创建新的渲染进程(Linux/macOS) |
这些进程之间通过 Mojo IPC 进行通信。Electron 做的事情,本质上就是在这套架构之上,把 Node.js 能力嵌进去,再加一层自己的 API 抽象。
1.2 Electron 的进程抽象
Electron 沿用了 Chromium 的多进程模型,对外暴露三种主要进程:
- 主进程(Main Process) :管理应用生命周期、创建和管理窗口(
BrowserWindow)、提供系统能力、协调各个渲染进程。它对应的就是 Chromium 的 Browser Process。 - 渲染进程(Renderer Process):负责页面渲染和 JS 执行。在 Chromium Renderer 的基础上,Electron 还注入了自己的运行时能力。
- GPU 进程(GPU Process):图形加速,直接复用 Chromium 的 GPU 进程。
这个抽象并不复杂,但它定义了 Electron 开发的基本心智模型:主进程管全局,渲染进程管页面,两者通过 IPC 通信。
1.3 启动链路:从进程拉起到用户代码执行
了解了进程模型,接下来看主进程从启动到执行用户代码经历了什么。整个链路大致分三步:
第一步:Chromium 侧启动。 Chromium 先启动,进入 Browser Process 的主线程事件循环(MessageLoop)。这个时候 Electron 的代码还没跑,只是 Chromium 的底层框架先就位。
第二步:Node.js 事件循环的接入。 这一步是 Electron 最核心的技术难点之一。主进程里嵌入了 Node.js,意味着同一个线程里需要同时跑两套事件循环------Chromium 的 MessageLoop 和 Node.js 的 libuv 循环。两者必须协作起来,否则要么 UI 卡死,要么 Node 的定时器和 I/O 回调没人处理。
具体的协作方式后面会展开,这里先记住一个关键点:Electron 主进程侧的初始化入口定义在 ElectronBrowserClient 中(对应 Chromium 的 Content embedder 层)。
第三步:JS 初始化。 进入 lib/browser/init.ts 执行阶段:
- 定位并读取应用的
package.json - 根据
package.json信息配置应用 - 解析入口字段(通常是
main),加载并执行入口脚本------也就是用户写的主进程代码
到这一步,用户代码才开始跑。整个启动链路从 C++ 底层一路走到 TypeScript 层,Electron 就是在这个过程中完成了 Chromium 和 Node.js 的融合。
二、BrowserWindow 的创建链路与视图层封装
上一节讲了启动流程,接下来看用户代码跑起来之后最常做的事情------创建窗口。
2.1 从 TS 到 C++ 的入口
在 Electron 里创建窗口很简单,一行 new BrowserWindow(...) 就行。但这一行背后涉及 TS 层和 C++ 层的配合:
- TS 层 入口:
browser-window.ts - C++ 层 入口:
electron_api_browser_window.cc
每个 BrowserWindow 实例都对应浏览器侧的一个窗口/页面承载,往后还能继续追到对应的渲染进程和站点实例。
2.2 C++ 构造函数里做了什么
从 C++ 构造函数的视角看,BrowserWindow 的创建过程很清晰,主要三步:
cpp
BrowserWindow::BrowserWindow(gin::Arguments* args,
const gin_helper::Dictionary& options)
: BaseWindow(args->isolate(), options) {
// 1. 解析 webPreferences
auto web_preferences = gin_helper::Dictionary::CreateEmpty(isolate);
options.Get(options::kWebPreferences, &web_preferences);
// 2. 创建 WebContentsView(渲染内容容器)
gin_helper::Handle<WebContentsView> web_contents_view =
WebContentsView::Create(isolate, web_preferences);
// 3. 设置到窗口
window()->set_primary_web_contents_view(
static_cast<InspectableWebContentsView*>(web_contents_view->view()));
}
第一步从 options 里取出 webPreferences,这个大家应该都熟悉,就是控制渲染进程行为的配置(比如是否启用 Node 集成、preload 脚本路径等)。第二步创建 WebContentsView,这是渲染内容的容器。第三步把它设为窗口的主视图。
核心在第二步------WebContentsView 到底是什么。
2.3 WebContentsView:Electron 对 Chromium 视图的封装
WebContentsView 是 Electron 自定义的类型,但它不是凭空造出来的,而是对 Chromium 视图层的一层封装。看它的继承关系就明白了:
用文字列出来就是:
WebContentsView继承 Electron 的View(后者又继承EventEmitterView和 Chromium 的ViewObserver)WebContentsView还继承了 Chromium 的WebContentsObserver- 组合成员
view_指向 Chromium 原生views::View - 组合成员
api_web_contents_指向 Electron 封装的WebContents,后者内部通过internal_持有 Chromium 原生content::WebContents
这个继承链说明了几件事:
WebContentsView同时继承了 Electron 自己的View体系和 Chromium 的WebContentsObserver,相当于把 Chromium 的视图观察和网页内容观察能力揉到一起。- 它内部持有两个关键成员:
view_指向 Chromium 原生的views::View,api_web_contents_指向 Electron 封装的WebContents。 WebContents内部又通过internal_持有 Chromium 原生的content::WebContents------这才是真正干活的渲染核心。
换句话说,Electron 在 Chromium 的 content::WebContents 外面套了一层 WebContents(Electron API 层),再套了一层 WebContentsView(视图层),最后塞到 BrowserWindow 里。
2.4 BrowserWindow 的完整层级
把这些关系拼起来,BrowserWindow 内部的层级结构长这样:
视图层负责"怎么显示",逻辑层负责"显示什么"。content::WebContents 是 Chromium 里表示一个网页内容的核心对象,Electron 通过多层封装把它暴露给 JS 层使用。理解了这个层级,后面看 IPC 通信和渲染进程相关代码时就不会迷路了。
三、IPC 通信与 Preload 安全桥梁
前面两节分别看了进程模型和窗口创建,这一节深入到 Electron 最核心的通信机制。Electron 里主进程和渲染进程需要频繁通信,同时还要保证安全性,这两件事是绑在一起设计的。
3.1 Node(libuv) 与 Chromium MessageLoop 的协作
在讲 IPC 之前,先补一个前面留下的坑------主进程里 Node.js 和 Chromium 的事件循环怎么协作。
问题背景:Chromium 主线程必须持续运行自己的 MessageLoop 来处理 UI 和渲染相关任务;同时 Node.js 也需要 libuv 事件循环来处理定时器和 I/O 回调。两个循环跑在同一条线程里,必须想办法让它们交替执行。
Electron 的解法:不是开两个线程然后加锁同步(那样竞态问题太复杂),而是让两套循环在同一线程内"可控地交替泵任务":
关键在于"安全点"------Chromium 不会随时随地切入 Node 的循环,而是等自己处理完一批任务、处于安全状态时才去泵 Node 的队列。这样既保证了两套循环都能正常工作,又避免了多线程同步的复杂度。
3.2 IPC 通信的整体架构
有了前面的基础,来看 IPC 通信。Electron 主进程和渲染进程之间的通信,底层利用的是 Chromium 的 Mojo 机制。整个通信链路横跨 JS 层和 C++ 层:
渲染进程侧(发送方):
- JS 层:
ipcRendererAPI 和ipcRendererInternal - C++ 层:
IPCRenderFrame,负责把 JS 传过来的消息序列化
主进程侧(接收方):
- C++ 层:
ElectronApiIPCHandler,负责反序列化 - TS 层:
ipc-dispatch.ts,负责事件分发 - JS 层:
ipcMain和ipcMainInternal,最终暴露给用户代码
数据流向是:
scss
ipcRenderer (JS) → IPCRenderFrame (C++序列化)
→ Mojo IPC Channel →
ElectronApiIPCHandler (C++反序列化) → ipc-dispatch.ts (分发) → ipcMain (JS)
用流程图表示整体结构:
从用户视角看,就是 ipcRenderer.send() 和 ipcMain.on() 两行代码的事。但中间经过了 JS→C++→跨进程→C++→JS 的完整链路,Mojo 负责跨进程传输,Electron 负责两端的序列化和事件分发。
3.3 Preload 与上下文隔离
IPC 是通信手段,但光有通信还不够------如果渲染进程里的网页代码能直接拿到 ipcRenderer,那任何第三方脚本都能调用系统级 API,安全就形同虚设了。Electron 的解法是 Preload 脚本 + 上下文隔离(Context Isolation)。
核心思路是把渲染进程的 JS 执行环境分成两个"世界":
隔离世界(Isolated World / Preload):
- 独立的 V8 Context
- 可以
require('electron'),能访问 Node.js API - 执行 preload 脚本
- 通过 Mojo IPC 和主进程通信
主世界(Main World / Page):
- 网页本身的 V8 Context
- 只能访问标准 Web API,没有
process对象,没有 Node.js - 网页 JS 在这里执行
两者之间的桥梁是 contextBridge。preload 脚本可以把自己暴露的 API 通过 contextBridge.exposeInMainWorld() 注入到主世界的 window 对象上,但注入的不是原始引用,而是经过安全处理的代理。
3.4 ContextBridge 的实现细节
看 contextBridge 的 TS 层实现:
typescript
const binding = process._linkedBinding('electron_renderer_context_bridge');
const contextBridge: Electron.ContextBridge = {
exposeInMainWorld: (key, api) => {
checkContextIsolationEnabled();
return binding.exposeAPIInWorld(0, key, api);
},
exposeInIsolatedWorld: (worldId, key, api) => {
checkContextIsolationEnabled();
return binding.exposeAPIInWorld(worldId, key, api);
},
executeInMainWorld: (script) => {
checkContextIsolationEnabled();
return binding.executeInWorld(0, script);
}
};
这里 worldId 为 0 代表主世界。每次调用都会先检查上下文隔离是否开启,然后通过 native binding 把 API 传到目标世界。
C++ 层的入口在 electron_api_context_bridge.cc:
cpp
void ExposeAPIInWorld(v8::Isolate* isolate,
const int world_id,
const const std::string& key,
v8::Local<v8::Value> api) {
// 1. 获取源上下文(preload 所在的隔离世界)
v8::Local<v8::Context> source_context = isolate->GetCurrentContext();
// 2. 获取目标上下文(主世界)
v8::MaybeLocal<v8::Context> maybe_target_context =
GetTargetContext(isolate, world_id, target_isolate);
// 3. 核心:跨上下文传递 API 对象
ExposeAPI(isolate, source_context, target_isolate, target_context, key, api);
}
核心就是第三步------跨 V8 Context 传递 API 对象。但直接传引用是不行的,两个 Context 的对象不能混用。Electron 的做法是:如果 API 里有函数,就为它创建一个代理函数。
3.5 代理函数机制
为什么要创建代理函数?因为主世界的代码调用 preload 里定义的函数时,执行上下文需要切换回隔离世界。代理函数就是干这个的。
看源码里 PassValueToOtherContextInner 的函数处理逻辑:
cpp
if (value->IsFunction()) {
auto func = value.As<v8::Function>();
v8::MaybeLocal<v8::Value> maybe_original_fn = GetPrivate(
source_isolate, source_context, func, kOriginalFunctionPrivateKey);
{
v8::Context::Scope destination_scope(destination_context);
v8::Local<v8::Value> proxy_func;
// 如果这个函数已经被代理过,且目标上下文就是它的创建上下文,
// 直接返回原始函数(性能优化)
if (maybe_original_fn.ToLocal(&proxy_func) && proxy_func->IsFunction() &&
proxy_func.As<v8::Object>()->GetCreationContextChecked(
source_isolate) == destination_context) {
return v8::MaybeLocal<v8::Value>(proxy_func);
}
// 否则创建代理函数:
// 1. 用一个 state 对象保存原始函数、this 指针、配置项
v8::Local<v8::Object> state = v8::Object::New(destination_isolate);
SetPrivate(destination_isolate, destination_context, state,
kProxyFunctionPrivateKey, func);
SetPrivate(destination_isolate, destination_context, state,
kProxyFunctionReceiverPrivateKey, parent_value);
// 2. 用 v8::Function::New 创建代理函数,回调指向 ProxyFunctionWrapper
v8::Function::New(destination_context, ProxyFunctionWrapper, state)
.ToLocal(&proxy_func);
// 3. 记下原始函数,避免重复代理
SetPrivate(destination_isolate, destination_context,
proxy_func.As<v8::Object>(), kOriginalFunctionPrivateKey,
func);
object_cache->CacheProxiedObject(value, proxy_func);
return v8::MaybeLocal<v8::Value>(proxy_func);
}
}
这段代码做了几件事:
- 查缓存:先看这个函数是不是已经代理过了。如果已经代理过,并且目标上下文恰好是原始函数的创建上下文,直接返回------不用再包一层。这是个性能优化。
- 创建 state 对象 :用 V8 的 Private Property 保存三样东西------原始函数、函数的 receiver(
this指向)、以及是否支持动态属性的配置。这些信息在代理函数被调用时要用到。 - 创建代理函数 :
v8::Function::New创建一个新函数,回调指向ProxyFunctionWrapper,把state作为 data 传进去。 - 记录原始函数 :在代理函数上标记
kOriginalFunctionPrivateKey,下次再传同一个函数时就能走缓存路径。
3.6 代理函数的调用流程
当网页 JS 调用 window.electronAPI.getAppVersion() 时,实际触发的流程是:
网页 JS 拿到了结果,但全程碰不到 ipcRenderer 对象,也碰不到 Node.js API。隔离世界的 process、require 等对象对主世界完全不可见。这就是上下文隔离的安全意义------不是简单地把 API 藏起来,而是从 V8 Context 层面做了物理隔离,再通过代理函数做受控的跨上下文调用。
写在最后
回过头来看这三块内容,其实是一条很清晰的学习路径:
- 进程模型与启动流程回答的是"Electron 是什么"------它基于 Chromium 多进程架构,在主进程嵌入了 Node.js,启动时要让两套事件循环协作。
- BrowserWindow 与视图层封装 回答的是"窗口怎么来的"------从 TS 层的
new BrowserWindow()到 C++ 层创建WebContentsView,再到 Chromium 原生的content::WebContents,是一层层封装的关系。 - IPC 通信与 Preload 安全桥梁回答的是"进程之间怎么安全通信"------Mojo 负责跨进程传输,Preload + ContextBridge + 代理函数负责安全隔离,三层配合实现既灵活又安全的通信。
Electron 源码里最值得看的部分,我觉得就是 IPC 和 Preload 这一块。它不只是 Electron 的实现细节,也涉及 V8 Context 隔离、Mojo IPC 这些底层机制的运用,对理解 Chromium 本身也有帮助。