Electron原理初探

一、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 执行阶段:

  1. 定位并读取应用的 package.json
  2. 根据 package.json 信息配置应用
  3. 解析入口字段(通常是 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 视图层的一层封装。看它的继承关系就明白了:

flowchart TB WCV[&#34;WebContentsView (Electron)&#34;] V[&#34;View (Electron)&#34;] EE[&#34;EventEmitterView (Electron)\n原始类型: gin_helper::EventEmitter&#34;] VObs[&#34;views::ViewObserver (Chromium)&#34;] CVObs[&#34;content::WebContentsObserver (Chromium)&#34;] VV[&#34;views::View (Chromium)\nmember: view_&#34;] WC[&#34;WebContents (Electron)\nmember: api_web_contents_&#34;] CWC[&#34;content::WebContents (Chromium)\nmember: internal_&#34;] WCV -->|继承| V V -->|继承| EE V -->|继承| VObs WCV -->|继承| CVObs V -->|组合 view_| VV WCV -->|组合 api_web_contents_| WC WC -->|组合 internal_| CWC

用文字列出来就是:

  • WebContentsView 继承 Electron 的 View(后者又继承 EventEmitterView 和 Chromium 的 ViewObserver
  • WebContentsView 还继承了 Chromium 的 WebContentsObserver
  • 组合成员 view_ 指向 Chromium 原生 views::View
  • 组合成员 api_web_contents_ 指向 Electron 封装的 WebContents,后者内部通过 internal_ 持有 Chromium 原生 content::WebContents

这个继承链说明了几件事:

  1. WebContentsView 同时继承了 Electron 自己的 View 体系和 Chromium 的 WebContentsObserver,相当于把 Chromium 的视图观察和网页内容观察能力揉到一起。
  2. 它内部持有两个关键成员:view_ 指向 Chromium 原生的 views::Viewapi_web_contents_ 指向 Electron 封装的 WebContents
  3. WebContents 内部又通过 internal_ 持有 Chromium 原生的 content::WebContents------这才是真正干活的渲染核心。

换句话说,Electron 在 Chromium 的 content::WebContents 外面套了一层 WebContents(Electron API 层),再套了一层 WebContentsView(视图层),最后塞到 BrowserWindow 里。

2.4 BrowserWindow 的完整层级

把这些关系拼起来,BrowserWindow 内部的层级结构长这样:

flowchart TB subgraph BW [&#34;BrowserWindow&#34;] direction TB subgraph WCV [&#34;WebContentsView (视图层)&#34;] direction TB subgraph IWC [&#34;InspectableWebContentsView&#34;] direction TB subgraph WV [&#34;views::WebView&#34;] direction TB WC[&#34;content::WebContents (渲染核心)&#34;] end end Props[&#34;属性:\n- web_contents_ (V8引用)\n- api_web_contents_ (C++弱指针)&#34;] IWC -.-> Props end subgraph WC_API [&#34;WebContents (逻辑层)&#34;] direction TB Methods[&#34;方法:\n- LoadURL(), Reload()\n- ExecuteJavaScript()&#34;] Events[&#34;事件:\n- did-finish-load\n- did-fail-load&#34;] Methods -.- Events end WCV -.- WC_API end

视图层负责"怎么显示",逻辑层负责"显示什么"。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 的解法:不是开两个线程然后加锁同步(那样竞态问题太复杂),而是让两套循环在同一线程内"可控地交替泵任务":

sequenceDiagram participant C as Chromium 主线程(事件循环) participant U as Node/libuv(事件循环) U-->>U: 1) 产生异步事件(I/O / Timer 等) U-->>C: 通知/唤醒主线程(跨线程信号) C->>C: 2) 主线程在合适的&#34;安全点&#34;短暂泵一次 Node/libuv 任务 C->>U: 3) 推进 libuv 队列并执行回调 C->>C: 4) 回到 Chromium 事件循环继续处理 UI/Chromium 任务

关键在于"安全点"------Chromium 不会随时随地切入 Node 的循环,而是等自己处理完一批任务、处于安全状态时才去泵 Node 的队列。这样既保证了两套循环都能正常工作,又避免了多线程同步的复杂度。

3.2 IPC 通信的整体架构

有了前面的基础,来看 IPC 通信。Electron 主进程和渲染进程之间的通信,底层利用的是 Chromium 的 Mojo 机制。整个通信链路横跨 JS 层和 C++ 层:

渲染进程侧(发送方):

  • JS 层:ipcRenderer API 和 ipcRendererInternal
  • C++ 层:IPCRenderFrame,负责把 JS 传过来的消息序列化

主进程侧(接收方):

  • C++ 层:ElectronApiIPCHandler,负责反序列化
  • TS 层:ipc-dispatch.ts,负责事件分发
  • JS 层:ipcMainipcMainInternal,最终暴露给用户代码

数据流向是:

scss 复制代码
ipcRenderer (JS) → IPCRenderFrame (C++序列化)
    → Mojo IPC Channel →
ElectronApiIPCHandler (C++反序列化) → ipc-dispatch.ts (分发) → ipcMain (JS)

用流程图表示整体结构:

flowchart LR classDef jsNode fill:#f8fafc,stroke:#cbd5e1,stroke-width:2px,color:#334155; classDef cppNode fill:#fff7ed,stroke:#fed7aa,stroke-width:2px,color:#9a3412; classDef dispatchNode fill:#f0fdf4,stroke:#bbf7d0,stroke-width:2px,color:#166534; subgraph Renderer [&#34;渲染进程 Renderer&#34;] direction TB subgraph R_JS [&#34;JavaScript 层&#34;] R1[&#34;ipcRenderer API&#34;]:::jsNode R2[&#34;ipcRendererInternal&#34;]:::jsNode end subgraph R_CPP [&#34;C++ 层&#34;] R3[&#34;IPCRenderFrame (序列化)&#34;]:::cppNode end end subgraph Main [&#34;主进程 Main&#34;] direction TB subgraph M_CPP [&#34;C++ 层&#34;] M1[&#34;ElectronApiIPCHandler (反序列化)&#34;]:::cppNode end M2[&#34;ipc-dispatch.ts (事件分发)&#34;]:::dispatchNode subgraph M_JS [&#34;JavaScript 层&#34;] M3[&#34;ipcMain&#34;]:::jsNode M4[&#34;ipcMainInternal&#34;]:::jsNode end end R1 --> R3 R2 --> R3 R3 ==>|&#34;Mojo IPC Channel&#34;| M1 M1 --> M2 M2 --> M3 M2 --> M4

从用户视角看,就是 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 在这里执行
flowchart TB classDef mainNode fill:#f8fafc,stroke:#cbd5e1,stroke-width:2px,color:#334155; classDef preloadNode fill:#eff6ff,stroke:#93c5fd,stroke-width:2px,color:#1e40af; classDef pageNode fill:#fefce8,stroke:#fde047,stroke-width:2px,color:#854d0e; subgraph Main [&#34;主进程 Main Process&#34;] M1[&#34;Node.js + Electron APIs<br/>ipcMain, BrowserWindow, app&#34;]:::mainNode end subgraph Renderer [&#34;渲染进程 Renderer Process&#34;] direction TB subgraph Preload [&#34;隔离世界 Isolated World / Preload&#34;] P1[&#34;V8 Context #1<br/>window 对象(独立) / process 对象<br/>require electron / preload.js 执行环境&#34;]:::preloadNode end subgraph Page [&#34;主世界 Main World / Page&#34;] W1[&#34;V8 Context #2<br/>window 对象(网页) / 无 process 对象<br/>无法直接访问 Node.js / 网页 JS 执行环境&#34;]:::pageNode end end M1 <==>|Mojo IPC 跨进程通信| P1 P1 ==>|ContextBridge 同进程内的桥| W1

两者之间的桥梁是 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);
    }
  }

这段代码做了几件事:

  1. 查缓存:先看这个函数是不是已经代理过了。如果已经代理过,并且目标上下文恰好是原始函数的创建上下文,直接返回------不用再包一层。这是个性能优化。
  2. 创建 state 对象 :用 V8 的 Private Property 保存三样东西------原始函数、函数的 receiver(this 指向)、以及是否支持动态属性的配置。这些信息在代理函数被调用时要用到。
  3. 创建代理函数v8::Function::New 创建一个新函数,回调指向 ProxyFunctionWrapper,把 state 作为 data 传进去。
  4. 记录原始函数 :在代理函数上标记 kOriginalFunctionPrivateKey,下次再传同一个函数时就能走缓存路径。

3.6 代理函数的调用流程

当网页 JS 调用 window.electronAPI.getAppVersion() 时,实际触发的流程是:

sequenceDiagram autonumber participant Web as 网页 JS participant Proxy as 代理函数 participant ISO as 隔离世界 Web->>Proxy: 调用 window.electronAPI.getAppVersion() Note over Proxy: 调用 ProxyFunctionWrapper() Proxy->>Proxy: 获取原始函数 (通过私有属性) Proxy->>ISO: 切换到隔离世界上下文 Note over ISO: 执行原始函数<br/>ipcRenderer.invoke(...) ISO-->>Proxy: 返回结果 Proxy-->>Web: 返回代理 Promise Note over Web: 拿到结果<br/>但无法访问 ipcRenderer

网页 JS 拿到了结果,但全程碰不到 ipcRenderer 对象,也碰不到 Node.js API。隔离世界的 processrequire 等对象对主世界完全不可见。这就是上下文隔离的安全意义------不是简单地把 API 藏起来,而是从 V8 Context 层面做了物理隔离,再通过代理函数做受控的跨上下文调用。


写在最后

回过头来看这三块内容,其实是一条很清晰的学习路径:

  1. 进程模型与启动流程回答的是"Electron 是什么"------它基于 Chromium 多进程架构,在主进程嵌入了 Node.js,启动时要让两套事件循环协作。
  2. BrowserWindow 与视图层封装 回答的是"窗口怎么来的"------从 TS 层的 new BrowserWindow() 到 C++ 层创建 WebContentsView,再到 Chromium 原生的 content::WebContents,是一层层封装的关系。
  3. IPC 通信与 Preload 安全桥梁回答的是"进程之间怎么安全通信"------Mojo 负责跨进程传输,Preload + ContextBridge + 代理函数负责安全隔离,三层配合实现既灵活又安全的通信。

Electron 源码里最值得看的部分,我觉得就是 IPC 和 Preload 这一块。它不只是 Electron 的实现细节,也涉及 V8 Context 隔离、Mojo IPC 这些底层机制的运用,对理解 Chromium 本身也有帮助。

相关推荐
江米小枣tonylua5 天前
升级到 Prisma 7:“Rust 除锈” 带来的意外红利
electron
还好还好不是吗9 天前
MatrixMedia v0.10.x 更新:B站封面自定义 + 二开指南,开源项目的开发者体验再升级
electron·开源
习明然9 天前
我的本地化AI项目(三)
人工智能·python·electron·c#·avalonia
Hyyy10 天前
为什么 macOS 应用一换 Bundle ID,之前授予的权限就全失效了?
macos·electron
月走乂山12 天前
Anything Analyzer MCP 401 Unauthorized 故障排查
electron·故障排查·mcp·claude code·anything analyzer
熊猫钓鱼>_>12 天前
Electron:当 Web 技术统治桌面
大数据·前端·javascript·人工智能·架构·electron·agent
程序员老刘12 天前
Opencode从Tauri切回Electron,桌面端技术选型别被体积陷阱带偏了
electron·ai编程
大意的百合13 天前
Electron 应用如何上架微软商店:从 MSIX 打包到商店提交
javascript·microsoft·electron
专职14 天前
electron中拖动文件到桌面案例
前端·javascript·electron