前言
最近在准备midterm(无暇阅读)、空余时间进行了一些electron开发上的探索,这里做下笔记。
Electron
Electron是什么?本质上等于Chromium+Node+Bridge。
Electron启动时会启动:
- 一个主进程(
main.js),运行Node环境,控制窗口,菜单,FS,IPC。 - 多个渲染进程,运行Chromium环境,用于渲染页面UI,前端逻辑。
- 预加载进程(
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 .
过程如下:
- Electron 启动一个
Node.js主进程; - 主进程加载
main.js; main.js创建一个BrowserWindow(本质是一个 Chromium 渲染实例);- 渲染进程加载本地 HTML(或 Vite 构建的前端);
- 用户操作界面;
- 渲染进程通过 IPC 与主进程通信(例如打开文件、访问数据库);
- 主进程调用底层 API;
- 返回结果到渲染层;
- 用户关闭窗口 → 主进程销毁资源 → 应用退出。
Chromium? Node?
Chromium和Node都是运行在V8上的,有不同的运行时环境。
V8
Google开发的JS引擎(C++)。负责:
- Parsing
- JIT
- Execute JS bytecode
- GC
不提供I/O,比如没有fs,http,setTimeout,document,window...
这些功能都来自Runtime
Chromium
Chromium用V8执行网页中的JS,提供Web APIs,例如:
- DOM:
document、window、Element、querySelector - CSSOM:
getComputedStyle、classList - Web APIs:
fetch、WebSocket、localStorage - 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:
fs、net、os - Node核心库:
Buffer、require、process、setTimeout
例如:
const fs = require('fs')
fs.readFileSync('/etc/passwd')
执行:
JavaScript (V8)
→ Node C++ 层 (bindings)
→ libuv (C 库)
→ 操作系统 syscalls (open, read)
Binding
运行时是如何将能力提供给V8的?关键是Binding。
运行时在启动V8时会执行:
- 创建V8虚拟机上下文
- 把这个上下文中还注入全局对象(
window、process、fs) - 把这些对象的放啊绑定到底层C++实现,通过V8的C++ API
例子:
-
初始化时:
// 一个新的JS环境创建了,里面什么都没有 v8::Isolate* isolate = v8::Isolate::New(...); v8::HandleScope handle_scope(isolate); v8::Local<v8::Context> context = v8::Context::New(isolate); -
注入全局对象:
// Node使用C++调用V8 API注入全局对象 context->Global()->Set( v8::String::NewFromUtf8(isolate, "process").ToLocalChecked(), node::CreateProcessObject(isolate) );于是JS层出现:
console.log(process.version) -
模块绑定(以 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() → 操作系统 syscallC++ 中绑定代码:
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')
})
app.whenReady():初始化主进程(基于Node)new BrowserWindow():创建一个Chromium渲染实例- Electron启动一个新的渲染进程(Renderer Process)
- 这个进程加载
index.html,运行Blink+V8 - Electron主进程通过IPC管理这个渲染进程
IPC
为了进行IPC,Electron提供了三个对象:
-
ipcMain存在于主进程,是C++2Node层桥接暴露的全局单例,内部通过Chromium Mojo IPC管道与各个渲染进程通信。
-
ipcRenderer存在于渲染进程,是Chromium渲染层中的Electron扩展对象,用于发消息给主进程。
是Electron在V8上下文中注入的对象包装器,底层连接到同一个IPC通道(Mojo),可以向主进程发送消息。
-
contextBridge存在于preload脚本,chromium会为网页(渲染层)和preload分配两个独立的JS上下文,preload可以访问Node而renderer不可以。
contextBridge是Electron的一个全局对象,用于在隔离上下文间安全暴露有限接口。
通信模式
-
主动请求 / 响应模式(
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
-
单向事件通知模式(
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) - 适用于持续反馈场景
-
渲染进程之间的通信
渲染进程之间不能直接通信,必须经由主进程中转:
// 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); }); -
contextBridge:安全暴露机制渲染层默认不允许直接访问
ipcRenderer, 所以需要在preload脚本中使用contextBridge。// preload.ts contextBridge.exposeInMainWorld("electronAPI", { readFile: (path) => ipcRenderer.invoke("read-file", path), notify: (msg) => ipcRenderer.send("notify", msg), });渲染进程只能访问暴露的
window.electronAPI 对象。 这样可以防止任意 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 的版本,如 es2015、es2020、esnext |
├ module |
string | 模块系统,如 commonjs、esnext、nodenext |
├ lib |
string[] | 包含的标准库,如 ["DOM", "ESNext"] |
├ jsx |
string | JSX 转换方式,如 react-jsx、preserve |
├ 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 | 压缩方式(esbuild 或 terser) |
├ target |
string | 输出 JS 版本(如 esnext) |
define |
object | 自定义全局常量(如 __APP_VERSION__) |
envDir |
string | 环境变量文件路径(默认项目根目录) |
envPrefix |
string[] | 环境变量前缀过滤(默认 VITE_) |