Electron 开发

发布于 作者: Ethan

前言

最近在准备midterm(无暇阅读)、空余时间进行了一些electron开发上的探索,这里做下笔记。

Electron

Electron是什么?本质上等于Chromium+Node+Bridge。

Electron启动时会启动:

  1. 一个主进程(main.js),运行Node环境,控制窗口,菜单,FS,IPC。
  2. 多个渲染进程,运行Chromium环境,用于渲染页面UI,前端逻辑。
  3. 预加载进程(preload script),每个渲染页附属,运行Node+Sandbox环境,用于桥接主进程和渲染进程通信(contextBeidge)。

通信

渲染进程运行在Chromium环境,默认不具有Node权限(sandbox),所以需要通过IPC与主进程通信。

为了解决这一安全问题,electron提供了preload脚本。脚本运行于渲染页面加载前,有Node权限,可以通过contextBridge暴露安全的API。

// preload.ts
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('api', {
  getVersion: () => ipcRenderer.invoke('get-version'),
})

// renderer.js
console.log(await window.api.getVersion())

运行

启动通过:

electron .

过程如下:

  1. Electron 启动一个 Node.js 主进程;
  2. 主进程加载 main.js
  3. main.js 创建一个 BrowserWindow(本质是一个 Chromium 渲染实例);
  4. 渲染进程加载本地 HTML(或 Vite 构建的前端);
  5. 用户操作界面;
  6. 渲染进程通过 IPC 与主进程通信(例如打开文件、访问数据库);
  7. 主进程调用底层 API;
  8. 返回结果到渲染层;
  9. 用户关闭窗口 → 主进程销毁资源 → 应用退出。

Chromium? Node?

Chromium和Node都是运行在V8上的,有不同的运行时环境。

V8

Google开发的JS引擎(C++)。负责:

  • Parsing
  • JIT
  • Execute JS bytecode
  • GC

不提供I/O,比如没有fshttpsetTimeoutdocumentwindow...

这些功能都来自Runtime

Chromium

Chromium用V8执行网页中的JS,提供Web APIs,例如:

  • DOM:documentwindowElementquerySelector
  • CSSOM:getComputedStyleclassList
  • Web APIs:fetchWebSocketlocalStorage
  • Timer APIs:setTimeout

这些通过Blink引擎(Chromium渲染核心)实现,并通过C++2JS biding暴露给V8

例如:

document.body.style.color = 'red'

路径:

JavaScript (V8)
 → 调用 Blink 的 C++ DOM API
 → 更新 Render Tree
 → Chromium 渲染进程绘制

NodeJS

一样基于V8,提供的是系统级API:

  • libuv:异步IO,事件循环
  • C++ Bindings:fsnetos
  • Node核心库:BufferrequireprocesssetTimeout

例如:

const fs = require('fs')
fs.readFileSync('/etc/passwd')

执行:

JavaScript (V8)
 → Node C++ 层 (bindings)
 → libuv (C 库)
 → 操作系统 syscalls (open, read)

Binding

运行时是如何将能力提供给V8的?关键是Binding。

运行时在启动V8时会执行:

  1. 创建V8虚拟机上下文
  2. 把这个上下文中还注入全局对象(windowprocessfs
  3. 把这些对象的放啊绑定到底层C++实现,通过V8的C++ API

例子:

  1. 初始化时:

    // 一个新的JS环境创建了,里面什么都没有
    v8::Isolate* isolate = v8::Isolate::New(...);
    v8::HandleScope handle_scope(isolate);
    v8::Local<v8::Context> context = v8::Context::New(isolate);
    
  2. 注入全局对象:

    // Node使用C++调用V8 API注入全局对象
    context->Global()->Set(
        v8::String::NewFromUtf8(isolate, "process").ToLocalChecked(),
        node::CreateProcessObject(isolate)
    );
    

    于是JS层出现:

    console.log(process.version)
    
  3. 模块绑定(以 fs 为例)

    fs 模块的实现路径如下:

    // JS 层
    const fs = require('fs')
    fs.readFileSync('a.txt')
    

    V8 调用链:

    → JS 调用 fs.readFileSync()
    → Node C++ 层 Binding: node_file.cc
    → libuv 调用 C API: open(), read(), close()
    → 操作系统 syscall
    

    C++ 中绑定代码:

    void ReadFileSync(const v8::FunctionCallbackInfo<v8::Value>& args) {
        std::string path = *v8::String::Utf8Value(isolate, args[0]);
        int fd = open(path.c_str(), O_RDONLY);
        ...
    }
    

    注册绑定:

    exports->Set(
        v8::String::NewFromUtf8(isolate, "readFileSync").ToLocalChecked(),
        v8::FunctionTemplate::New(isolate, ReadFileSync)
    );
    

Electron中的Chromium

Electron内部直接嵌入了完整的Chromium内核,包括Blink、V8、网络栈等。

所以每当创建一个窗口BrowserWindow,Electorn就会创建一个Chromium实例,实例就是一个完整的浏览器渲染引擎进程,拥有自己的DOM、CSS、GPU管线、V8,但是没有浏览器的UI(只保留核心渲染能力)。

例子:

const { app, BrowserWindow } = require('electron')

app.whenReady().then(() => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: { preload: './preload.js' }
  })
  win.loadFile('index.html')
})
  1. app.whenReady():初始化主进程(基于Node)
  2. new BrowserWindow():创建一个Chromium渲染实例
  3. Electron启动一个新的渲染进程(Renderer Process)
  4. 这个进程加载index.html,运行Blink+V8
  5. Electron主进程通过IPC管理这个渲染进程

IPC

为了进行IPC,Electron提供了三个对象:

  1. ipcMain

    存在于主进程,是C++2Node层桥接暴露的全局单例,内部通过Chromium Mojo IPC管道与各个渲染进程通信。

  2. ipcRenderer

    存在于渲染进程,是Chromium渲染层中的Electron扩展对象,用于发消息给主进程。

    是Electron在V8上下文中注入的对象包装器,底层连接到同一个IPC通道(Mojo),可以向主进程发送消息。

  3. contextBridge

    存在于preload脚本,chromium会为网页(渲染层)和preload分配两个独立的JS上下文,preload可以访问Node而renderer不可以。contextBridge是Electron的一个全局对象,用于在隔离上下文间安全暴露有限接口。

通信模式

  1. 主动请求 / 响应模式(invoke / handle

    用于渲染进程请求主进程执行某任务(常见如读写文件、获取系统信息)。

    // main.ts
    import { ipcMain } from "electron";
    
    // 如果渲染进程调用 get-version,要执行以下 handler。
    // 当渲染进程调用 ipcRenderer.invoke('get-version') 时:
    // 1. Electron 底层通过 Mojo IPC 把请求传给主进程;
    // 2. 主进程调用上面注册的 handler;
    // 3. handler 返回结果后,再通过通道异步返回给渲染进程。
    ipcMain.handle("get-version", async () => {
    return process.versions.electron;
    });
    
    // preload.ts
    import { contextBridge, ipcRenderer } from "electron";
    
    //将一个对象挂载到浏览器的 window 全局对象下。
    // 名字为 "api",即 window.api。
    // 渲染层就能安全调用 window.api.getVersion()。
    contextBridge.exposeInMainWorld("api", {
        // 定义一个函数,调用 ipcRenderer.invoke();
        // 这会通过 IPC 通道发送一个 "get-version" 请求;
        // 主进程中对应的 ipcMain.handle("get-version") 就会收到;
        // 主进程返回值后,invoke 自动返回一个 Promise。
    getVersion: () => ipcRenderer.invoke("get-version"),
    });
    
    // renderer.js
    //调用的是 preload 中定义的函数;
    // 内部触发 ipcRenderer.invoke("get-version");
    // 主进程执行 handler 后返回 Electron 版本;
    // await 等待 Promise 完成;
    const version = await window.api.getVersion();
    console.log(version);
    
    • 异步调用,支持 await
    • 有返回值
    • 自动带上 Promise
  2. 单向事件通知模式(send / on

    用于发送通知或消息(如状态更新、日志、进度条等)。

    // renderer.js
    ipcRenderer.send("download-started", { file: "a.pdf" });
    
    // main.ts
    ipcMain.on("download-started", (event, data) => {
    console.log("Download:", data.file);
    event.reply("download-progress", { progress: 0.3 });
    });
    
    // renderer.js
    ipcRenderer.on("download-progress", (_, data) => {
    console.log(data.progress);
    });
    
    • 不需要返回值
    • 类似事件总线(EventEmitter
    • 适用于持续反馈场景
  3. 渲染进程之间的通信

    渲染进程之间不能直接通信,必须经由主进程中转:

    // renderer1
    ipcRenderer.send("to-renderer2", { msg: "hi" });
    
    // main
    ipcMain.on("to-renderer2", (event, args) => {
    const win2 = getWin2();
    win2.webContents.send("msg-from-other-renderer", args);
    });
    
  4. contextBridge:安全暴露机制

    渲染层默认不允许直接访问 ipcRenderer, 所以需要在 preload 脚本中使用 contextBridge

    // preload.ts
    contextBridge.exposeInMainWorld("electronAPI", {
    readFile: (path) => ipcRenderer.invoke("read-file", path),
    notify: (msg) => ipcRenderer.send("notify", msg),
    });
    

    渲染进程只能访问暴露的 window.electron API 对象。 这样可以防止任意 JS 调用系统 API。

JS上下文

V8引擎中,每次运行JS代码时,都需要有一个执行环境(Execution Context)。一个独立的JS全局环境拥有独立的globalThis、作用域、原型链】内建对象(Array、Object、JSON等)。

上下文间可以通过消息机制(postMessage、IPC)等交流,但是默认情况下他们的全局对象(window)互不共享。

Preload

理解了上下文,可以更好理解Preload。

Preload是在渲染进程中一个独立的V8上下文,当渲染进程销毁时,它的内存空间和上下文一起释放

┌────────────────────────────┐
│ 渲染进程(Renderer Process)│
│────────────────────────────│
│  V8 Context #1:Main World(网页脚本)     │ ← 真正运行 renderer.js 的地方
│  V8 Context #2:Isolated World(preload) │ ← Electron 在这里运行 preload.js
│  V8 Context #3...(比如 devtools, extensions) │
└────────────────────────────┘

这些上下文都处于一个渲染进程(PID),但是全局对象,作用域,内建对象等完全隔离,只有C++桥阶层可以在它们之间传递数据。preload需要访问Node模块执行IPC,给网页暴露API,所以要隔离。

那为啥preload中运行的内容可以更新渲染进程?


Renderer Process 内部:

 ┌──────────────────────────────────────┐
 │ V8 Context #1 (Main World)           │ ← 渲染层真正执行网页代码
 │ globalThis.window = {...}            │
 ├──────────────────────────────────────┤
 │ V8 Context #2 (Isolated World)       │ ← 运行 preload.js
 │ 有 Node 权限,可调用 contextBridge   │
 └──────────────────────────────────────┘

当Preload运行:

contextBridge.exposeInMainWorld('api', {
  getVersion: () => ipcRenderer.invoke('get-version')
});

Electron内部实际逻辑:

// preload 世界(Isolated World)
void ExposeInMainWorld(std::string name, v8::Local<v8::Value> object) {
    // Electron 内部:
    // 找到当前 WebFrame 对应的主世界 (Main World)
    v8::Local<v8::Context> main_context = frame->MainWorldContext();
    // 将对象“复制”到主世界 globalThis 下
    main_context->Global()->Set(
        v8::String::NewFromUtf8("api"),
        CloneValueAcrossContexts(object)
    );
}

所以Electron会把Isolated World中的JS对象序列化,再在Main World重新构造一份副本。

至此,Electron的部分基本结束了。

tsconfig, package.json, vite config常见配置一览

tsconfig.json

TypeScript 编译器的配置文件,用于控制编译行为。

字段 类型 说明
compilerOptions object 核心配置项集合
target string 输出 JS 的版本,如 es2015es2020esnext
module string 模块系统,如 commonjsesnextnodenext
lib string[] 包含的标准库,如 ["DOM", "ESNext"]
jsx string JSX 转换方式,如 react-jsxpreserve
moduleResolution string 模块解析策略,如 node, bundler
baseUrl string 模块解析的基路径
paths object 路径别名映射,如 "@/*": ["src/*"]
outDir string 编译输出目录
rootDir string 输入源码目录
strict boolean 开启严格类型检查(推荐)
esModuleInterop boolean 允许兼容 CommonJS 的导入方式
skipLibCheck boolean 跳过声明文件类型检查,加快编译
allowSyntheticDefaultImports boolean 允许从没有 default export 的模块导入
resolveJsonModule boolean 支持导入 .json 文件
types string[] 指定全局类型定义文件(如 ["vite/client"]

package.json

Node.js 项目的元数据文件,定义依赖、脚本、模块类型等。

字段 类型 说明
name string 项目名称
version string 项目版本号
description string 项目描述
scripts object npm/yarn/pnpm 脚本命令
dev string 启动开发服务器命令(如 vite
build string 打包命令(如 vite build
lint string 代码检查命令
dependencies object 生产环境依赖
devDependencies object 开发依赖
type string 模块系统:"module" 表示 ESM,"commonjs" 表示 CJS
main string Node 入口文件路径
module string ESM 模块入口(常用于打包库)
types string TypeScript 类型声明入口(如 dist/index.d.ts
files string[] 发布时包含的文件
exports object 模块导出定义(现代打包系统使用)
engines object 指定 Node 版本要求
keywords string[] 项目关键词
license string 项目许可证类型

vite.config.(ts/js)

Vite 的主配置文件,用于控制开发服务器、打包、插件等行为。

字段 类型 说明
plugins array 插件列表(如 react(), vue()
resolve object 模块解析设置
alias object 路径别名,如 { '@': '/src' }
extensions string[] 自动补全的文件扩展名
server object 开发服务器配置
port number 启动端口
host string 主机地址
open boolean 启动后自动打开浏览器
proxy object 接口代理转发
build object 打包相关配置
outDir string 输出目录(默认 dist
sourcemap boolean 生成源码映射
minify string 压缩方式(esbuildterser
target string 输出 JS 版本(如 esnext
define object 自定义全局常量(如 __APP_VERSION__
envDir string 环境变量文件路径(默认项目根目录)
envPrefix string[] 环境变量前缀过滤(默认 VITE_