electron
# Electron 核心知识
用 Web 技术(HTML/CSS/JS)开发跨平台桌面应用,底层运行时 = Chromium + Node.js。
首次接触 Electron,最该关注的不是"怎么写 UI",而是 Main / Renderer / Preload / IPC 之间的安全边界。
# 一、Electron 是什么
Electron App
│
├── Chromium → 渲染进程(Renderer Process),跑 HTML/CSS/JS/React/Vue
├── Node.js → 主进程(Main Process),跑文件、网络、系统 API、窗口管理
└── Electron API → IPC 通信,连接上面两者
2
3
4
5
一个最小 Electron 项目的文件结构:
my-app/
├── main.js # 主进程入口
├── preload.js # 预加载脚本(安全桥梁)
├── index.html # 渲染进程页面
└── renderer.js # 渲染进程逻辑
2
3
4
5
三者关系:
┌─────────────────────┐
│ Electron 运行时 │
└──────────┬──────────┘
│
┌────────────┴────────────┐
│ │
Main Process Renderer Process
Node.js Chromium
│ │
└──────── IPC ────────────┘
│
Preload(安全桥梁)
2
3
4
5
6
7
8
9
10
11
12
关键类比
把 Electron 类比成 Android:
- Main Process ≈ Application / 系统服务层
- Renderer ≈ Activity / WebView UI
- Preload ≈ Bridge / Binder 接口层
- IPC ≈ Binder / Intent
- contextIsolation ≈ 进程权限隔离
# 二、Main Process(主进程)
应用启动后第一个运行的进程,有且只有一个。拥有完整 Node.js 权限。
const { app, BrowserWindow } = require('electron');
app.whenReady().then(() => {
const win = new BrowserWindow({ width: 1200, height: 800 });
win.loadFile('index.html');
});
2
3
4
5
6
主进程职责:
- 创建和管理窗口(
BrowserWindow) - 应用生命周期(
app.whenReady/window-all-closed) - 菜单、托盘、系统通知
- 文件系统、网络请求、子进程
- 所有系统级原生 API
- 注册 IPC 处理器(
ipcMain.handle)
定位
主进程 = 桌面应用的系统服务层 / 后端,高权限。
# 三、Renderer Process(渲染进程)
每个 BrowserWindow 对应一个渲染进程,本质就是一个 Chromium 标签页。
// renderer.js —— 和写前端一模一样
document.querySelector('#btn').addEventListener('click', () => {
console.log('hello');
});
2
3
4
渲染进程能做:
- HTML / CSS / JavaScript
- React / Vue / TypeScript
- DOM 操作、Web API
渲染进程默认不能做(现代 Electron 安全配置下):
- ❌
require('fs') - ❌
require('child_process') - ❌ 任何 Node.js API
核心原则
Renderer ≠ Node.js。渲染进程必须被当作"不可信的 Web 页面"来对待。
# 四、为什么 Renderer 不能直接访问 Node.js
假设开启了 nodeIntegration: true:
// 渲染进程里的恶意脚本(XSS 注入)
require('child_process').exec('rm -rf ~/*'); // 删光用户文件
require('fs').readFileSync('~/.ssh/id_rsa'); // 偷私钥
2
3
攻击链路:
远程网页 XSS
↓
Renderer JS(有 Node 权限)
↓
文件系统 / Shell
↓
操作系统被接管
2
3
4
5
6
7
一个普通的 Web XSS 直接升级为远程代码执行(RCE)。这就是为什么现代 Electron 默认关闭 nodeIntegration。
# 五、Preload + contextBridge(安全桥梁)
Preload 是渲染进程加载页面之前执行的脚本,运行在独立上下文中,可以使用 Node.js,但通过 contextBridge 只暴露有限 API 给页面。
# 标准写法
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
getUserInfo: () => ipcRenderer.invoke('get-user-info'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config)
});
2
3
4
5
6
7
// renderer.js —— 只能拿到 preload 暴露的方法
const user = await window.api.getUserInfo();
2
// main.js —— 主进程注册对应处理器
ipcMain.handle('get-user-info', async () => {
return { name: 'Jack' };
});
2
3
4
完整调用链:
Renderer (window.api.getUserInfo)
↓ contextBridge 包装
Preload (ipcRenderer.invoke)
↓ IPC 跨进程
Main (ipcMain.handle)
↓
Node.js / 系统能力
2
3
4
5
6
7
# contextBridge 为什么重要
错误写法(等于把万能钥匙交出去):
contextBridge.exposeInMainWorld('api', {
ipcRenderer // ❌ 暴露完整 ipcRenderer,可发任意 IPC 消息
});
2
3
正确写法(白名单,只暴露具体方法):
contextBridge.exposeInMainWorld('api', {
getUserInfo: () => ipcRenderer.invoke('get-user-info'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config)
// ✅ 每个方法都是一个受控的 IPC 通道
});
2
3
4
5
核心思想
不要给 Renderer 一把万能钥匙,而是提供有限的白名单 API。
# 实际项目示例:expression-trainer 的 analyzeText 完整流转
项目地址:https://github.com/fxy2311-youyou/expression-trainer (opens new window) \ https://github.com/jacky1234/expression-trainer (opens new window) 以"词库分析"功能为例,追踪一次
window.api.analyzeText(text)调用从渲染进程到主进程再返回的完整路径。
# 项目技术栈速览
| 层 | 技术 | 文件 |
|---|---|---|
| 主进程 | Electron 33 + Node.js | main.js |
| Preload | contextBridge 白名单 | preload.js |
| 渲染进程 | 原生 HTML/CSS/JS(无框架) | src/index.html + src/app.js |
| 业务逻辑 | 纯 JS 模块 | lib/lexicon.js、lib/asr.js、lib/ai-feedback.js |
| 语音识别 | sherpa-onnx-node(离线 ASR) | models/ 目录 |
# 完整调用链路
渲染进程 (src/app.js)
│
│ ① 实时识别每句话 / 粘贴文本逐句
│ const analysis = await window.api.analyzeText(text)
▼
Preload (preload.js:27)
│
│ ② analyzeText: (text) => ipcRenderer.invoke('analyze-text', text)
│ 函数调用 → 序列化为 IPC 消息
▼
IPC 通道(Electron 内部,跨进程传递)
│
│ channel = 'analyze-text', payload = text
▼
主进程 (main.js:305-307)
│
│ ③ ipcMain.handle('analyze-text', (event, text) => {
│ return analyzeText(text);
│ })
│ 匹配 channel → 调用 lib/lexicon.js
▼
业务逻辑 (lib/lexicon.js:107-170)
│
│ ④ segmentText(text) → 中文分词(最大正向匹配 2~6 字)
│ ⑤ 遍历分词结果,查表检测四类词:
│ - FILLER_WORDS → 填充词(嗯、啊、那个、就是...)
│ - HEDGE_WORDS → 犹豫词(可能、大概、我觉得...)
│ - VAGUE_TO_PRECISE → 笼统词(做、说、好,带替换建议)
│ - lexiconData → 情绪词(27000 词库)
│ ⑥ 表达密度 = (总词数 - 填充词 - 犹豫词) / 总词数
│ ⑦ generateSuggestions() → 生成文字建议
│ ⑧ return { totalWords, fillers, hedges, vagueWords, emotionWords, density, suggestions }
▼
返回值(原路返回)
│
│ 对象 → IPC 序列化 → Promise resolve
▼
渲染进程 (app.js:230-239)
│
│ ⑨ 累加 stats(fillers / hedges / vagueWords / totalWords)
│ updateStatsDisplay() → 刷新左侧统计面板
│ 碰到笼统词 → 右侧反馈栏弹出替换建议
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 逐层代码对照
# 第 1 层:渲染进程调用
src/app.js 中有两处调用:
// 场景 A:实时录制中,每识别完一句话调一次(line 228)
async analyzeCurrentSentence(text) {
const analysis = await window.api.analyzeText(text);
if (analysis) {
this.stats.fillers += analysis.fillers.length;
this.stats.hedges += analysis.hedges.length;
this.stats.vagueWords += analysis.vagueWords.length;
this.stats.totalWords += analysis.totalWords;
this.updateStatsDisplay();
// 笼统词 → 即时弹替换建议
if (analysis.vagueWords?.length > 0) {
analysis.vagueWords.forEach(item => {
const alts = item.alternatives.slice(0, 3).join(' / ');
// ... 渲染到反馈栏
});
}
}
}
// 场景 B:粘贴文本模式,按句号切分后逐句循环(line 463-477)
for (const sentence of sentences) {
const analysis = await window.api.analyzeText(sentence);
if (analysis) {
this.stats.fillers += analysis.fillers.length;
// ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 第 2 层:Preload 桥接
preload.js 只做透传,无业务逻辑:
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
// ... 其他 15 个方法
analyzeText: (text) => ipcRenderer.invoke('analyze-text', text), // line 27
// ...
});
2
3
4
5
6
7
关键细节
ipcRenderer.invoke(channel, ...args) 是请求-响应模式,返回一个 Promise。主进程 return 的值会自动作为 Promise 的 resolve 值,不需要手动发回消息。
# 第 3 层:主进程 IPC 注册
main.js 注册 channel 处理器:
// line 5:引入业务模块
const { loadLexicon, analyzeText } = require('./lib/lexicon');
// line 305-307:注册处理器
ipcMain.handle('analyze-text', (event, text) => {
return analyzeText(text);
});
2
3
4
5
6
7
handler 签名
ipcMain.handle(channel, (event, ...args) => {...}) —— 第一个参数永远是 event 对象(含 sender 等元信息),从第二个参数开始才是渲染进程传过来的实际数据。
# 第 4 层:业务逻辑(纯规则,无 AI)
lib/lexicon.js 的 analyzeText 是纯函数,不调任何大模型:
function analyzeText(text) {
if (!text || !text.trim()) return null; // 空文本直接返回
const words = segmentText(text); // 中文分词
const totalWords = words.length;
// 检测填充词
const fillers = [];
words.forEach((word, idx) => {
if (FILLER_WORDS.includes(word)) fillers.push({ word, position: idx });
});
// 检测犹豫词
const hedges = [];
words.forEach((word, idx) => {
if (HEDGE_WORDS.includes(word)) hedges.push({ word, position: idx });
});
// 检测笼统词(带替换建议)
const vagueWords = [];
words.forEach((word, idx) => {
if (VAGUE_TO_PRECISE[word]) {
vagueWords.push({ word, position: idx, alternatives: VAGUE_TO_PRECISE[word] });
}
});
// 检测情绪词(27000 词库,启动时加载到内存)
const emotionWords = [];
if (lexiconData?.emotions) {
words.forEach((word, idx) => {
if (lexiconData.emotions[word]) emotionWords.push({ word, position: idx, ...lexiconData.emotions[word] });
});
}
// 表达密度
const meaningfulWords = totalWords - fillers.length - hedges.length;
const density = totalWords > 0 ? (meaningfulWords / totalWords) : 1;
return {
totalWords, fillers, hedges, vagueWords, emotionWords,
density: Math.round(density * 100),
suggestions: generateSuggestions(vagueWords, fillers, hedges)
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 对照表:每层在做什么
| 层 | 文件:行 | 代码 | 职责 |
|---|---|---|---|
| 渲染进程触发 | app.js:229 / app.js:470 | await window.api.analyzeText(text) | 拿到识别文本后请求分析 |
| Preload 桥接 | preload.js:27 | ipcRenderer.invoke('analyze-text', text) | 函数调用 → IPC 消息,白名单透传 |
| 主进程注册 | main.js:305-307 | ipcMain.handle('analyze-text', (event, text) => analyzeText(text)) | 接收 IPC,分发到业务模块 |
| 分词 | lexicon.js:112 | segmentText(text) | 中文分词(2~6 字最大正向匹配) |
| 四类检测 | lexicon.js:116-155 | 查 FILLER / HEDGE / VAGUE / emotions 表 | 纯规则匹配,计数 + 定位 |
| 密度计算 | lexicon.js:158-159 | meaningfulWords / totalWords | 百分比 |
| 渲染进程消费 | app.js:231-239 | 累加 stats、刷新面板、弹建议 | 更新 UI |
# 这个例子体现的 Electron 设计原则
- 渲染进程不碰业务逻辑——分词、查词库全在主进程,渲染进程只负责 UI
- Preload 零逻辑——只做
window.api.xxx→ipcRenderer.invoke的映射 - 主进程持有重资源——27000 词库加载在主进程内存,渲染进程不持有
- IPC 参数即不可信输入——虽然这里没做校验(纯分析无副作用),但涉及文件/网络的 IPC 必须校验
- invoke 是请求-响应——不需要手动
webContents.send回传,return即可
# 同项目的其他 IPC 通道(共 16 个)
| 通道 | Preload 方法 | 主进程处理 | 用途 |
|---|---|---|---|
get-settings | getSettings() | loadSettings() | 读 AI 后端配置 |
save-settings | saveSettings(s) | saveSettings(s) | 保存配置到 userData |
open-settings | openSettings() | createSettingsWindow() | 打开设置窗口 |
get-custom-prompt | getCustomPrompt() | loadCustomPrompt() | 读自定义 Prompt 模板 |
save-custom-prompt | saveCustomPrompt(d) | saveCustomPrompt(d) | 保存自定义 Prompt |
init-asr | initASR() | 初始化 sherpa-onnx | 启动离线语音识别 |
feed-audio | feedAudio(samples) | 喂音频流给 ASR | 实时音频识别 |
analyze-text | analyzeText(text) | lib/lexicon.js | 词库分析(本文示例) |
get-realtime-feedback | getRealtimeFeedback(t) | lib/ai-feedback.js | AI 实时反馈 |
get-final-report | getFinalReport(d) | lib/ai-feedback.js | AI 总结报告 |
test-llm-connection | testLLMConnection(s) | 测试 API 连通性 | 保存设置时测通 |
save-file | saveFile(c, f) | 写文件 | 导出报告 |
close-current-window | closeWindow() | win.close() | 关闭子窗口 |
# 六、最重要的安全配置
创建窗口时的 webPreferences:
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: false, // 渲染进程禁用 Node.js
contextIsolation: true, // 隔离 Preload 和网页 JS 上下文
sandbox: true, // 增强渲染进程沙箱(可选但推荐)
preload: path.join(__dirname, 'preload.js')
}
});
2
3
4
5
6
7
8
| 配置 | 推荐值 | 意义 |
|---|---|---|
nodeIntegration | false | Renderer 禁止直接使用 Node.js |
contextIsolation | true | 隔离 Preload 与网页的 JS 运行环境,防止网页篡改 Preload 暴露的对象 |
sandbox | true | 增强 Renderer 沙箱隔离,Preload 也受限 |
preload | 指定文件 | 提供受控 API 桥梁 |
| CSP | 配置 | 限制脚本来源,防 XSS |
| HTTPS | 使用 | 防止网络传输被劫持 |
注意
contextIsolation: true 时,Preload 里直接 window.xxx = ... 网页是看不到的,必须用 contextBridge.exposeInMainWorld。
# 七、IPC 也是安全边界
很多人以为 IPC 是内部通信就放松警惕,实际上 IPC 参数必须被当作不可信输入。
危险设计:
// main.js
ipcMain.handle('execute', (event, command) => {
return exec(command); // ❌ 任意命令执行
});
2
3
4
// renderer.js
window.api.execute('rm -rf /'); // 直接删根目录
2
安全设计(白名单 + 校验):
ipcMain.handle('open-file', async (event, fileId) => {
if (!ALLOWED_FILE_IDS.includes(fileId)) {
throw new Error('permission denied');
}
return readFile(fileId);
});
2
3
4
5
6
原则
即使请求来自"自己的 Renderer",也不能认为参数天然可信——XSS 可以伪造任何 IPC 调用。
# 八、不要把 Secret 放在 Renderer
// renderer.js ❌
const API_KEY = 'sk-xxxxxxxx';
2
Electron 打包后 JS 在 app.asar 里,用户可以直接解压读取,等于公开。
正确架构:
Renderer(无 Key)
↓ Preload / IPC
Main(持有 API_KEY)
↓ HTTPS
OpenAI / 自建服务
2
3
4
5
API Key 只存在于主进程,渲染进程通过 IPC 请求主进程代发请求。
# 九、加载第三方网页的风险
如果应用需要加载远程页面:
win.loadURL('https://third-party.com');
风险链路:
第三方网页(不可信)
↓
Renderer
↓
Preload API(如果暴露了 deleteFile / executeCommand)
↓
Main(高权限)
↓
本机被控制
2
3
4
5
6
7
8
9
对策:
- 第三方网页用独立的 BrowserWindow / WebContents,不挂 Preload 或挂最小权限 Preload
- 自己的 UI 页面才挂完整 Preload API
- 开启
sandbox: true
# 十、推荐的生产级目录结构
electron-app/
├── main/ # 主进程
│ ├── index.ts # 入口
│ ├── ipc/ # IPC 处理器(按领域拆分)
│ │ ├── user.ts
│ │ ├── file.ts
│ │ └── system.ts
│ └── services/ # 业务逻辑
│ ├── fileService.ts
│ └── userService.ts
├── preload/
│ └── index.ts # contextBridge 白名单
├── renderer/ # 渲染进程(前端)
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ └── services/ # 调用 window.api
│ └── index.html
└── shared/ # 主进程与渲染进程共享
├── types/ # TypeScript 类型定义
└── constants/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
核心原则:
renderer → 只能调用有限 API (window.api)
preload → IPC 白名单转发
main → 系统 / 网络 / 文件系统
2
3
# 十一、总结:一张图记住
Internet
│
HTTPS
▼
┌──────────────────────────────────────────────┐
│ Electron │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Renderer Process │ │
│ │ │ │
│ │ React / Vue / TypeScript │ │
│ │ │ │
│ │ ❌ Node.js │ │
│ │ ❌ fs │ │
│ │ ❌ child_process │ │
│ └────────────────┬───────────────────────┘ │
│ │ │
│ Limited API(白名单) │
│ ↓ │
│ ┌────────────────────────────────────────┐ │
│ │ Preload │ │
│ │ │ │
│ │ contextBridge │ │
│ │ ipcRenderer │ │
│ └────────────────┬───────────────────────┘ │
│ │ IPC │
│ ↓ │
│ ┌────────────────────────────────────────┐ │
│ │ Main │ │
│ │ │ │
│ │ Node.js │ │
│ │ File System │ │
│ │ Native API │ │
│ │ Network(持有 Secret) │ │
│ └────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
一句话总结
把 Renderer 当成"不可信 Web 页面",把 Main 当成"高权限系统服务",Preload + IPC 是两者之间严格受控的安全边界。
# 十二、下一步学习重点
实际开发 Electron,优先搞懂这 5 个东西:
- contextIsolation — 为什么要隔离,不隔离会怎样
- contextBridge — 正确暴露白名单 API
- IPC —
ipcRenderer.invoke/ipcMain.handle的请求-响应模式 - sandbox — 渲染进程沙箱增强
- CSP — Content Security Policy,防 XSS
这五个基本就是 Electron 安全架构的全部核心。