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::View,api_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 层:ipcMain 和 ipcMainInternal,最终暴露给用户代码

数据流向是:

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。隔离世界的 process、require 等对象对主世界完全不可见。这就是上下文隔离的安全意义------不是简单地把 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 本身也有帮助。

相关推荐
fellow9920 小时前
DSH Desktop:把 DeepSeek Harness 变成真正的桌面应用
electron·agent·ai编程·deepseek
CDwenhuohuo2 天前
electron pc项目打包成桌面端
前端·javascript·electron
不可能片场3 天前
命令行中文变问号 我用环境变量救了场
前端·electron
不可能片场3 天前
AI视频口型对齐 用音频驱动说话镜头
前端·electron
Zoom4 天前
【开源】7 天,我用 Rust 复刻了 FinalShell!Rhost v1.0.0 发布
rust·electron·shell
爱丶不疚5 天前
Electron: Sentry 都采集了什么?你知道吗
前端·electron
格数致用5 天前
03- Luckysheet与Vue3+Electron集成实战指南
electron·vue3·luckysheet
fellow997 天前
Electron + pnpm 在鸿蒙应用里跑起来
electron·harmonyos·openharmony·deepseek·deepseekharness
记得开心一点嘛7 天前
Trellora:基于 Electron、React 和本地知识库的 AI 知识工作台
人工智能·react.js·electron
500847 天前
React Native for OpenHarmony 实战:三方库 react-native-url-polyfill 的鸿蒙化适配指南
javascript·react native·react.js·性能优化·electron·harmonyos