主题切换
第 2 章:Electron 核心概念与进程模型
理解 Electron 的进程模型是掌握桌面开发的关键。本章将深入讲解主进程与渲染进程的分工、进程间通信(IPC)机制,以及 Electron 的安全架构。
代码风格说明
本章沿用第 1 章的 CommonJS(require)语法和纯 JavaScript,保持最小依赖以聚焦核心概念。从第 3 章开始将切换为 TypeScript + ESM 工程化方案。两种风格使用的 Electron API 完全一致。
主进程 vs 渲染进程
Electron 采用多进程架构,这是它与传统 Web 开发最大的不同。让我们用一个类比来理解:
类比理解
把 Electron 应用想象成一个餐厅:
- 主进程(Main Process) = 餐厅经理(Node.js 服务器)—— 负责管理整个餐厅、协调各部门、处理外部事务
- 渲染进程(Renderer Process) = 各个餐桌的服务员(浏览器标签页)—— 每个窗口一个进程,负责直接为顾客(用户)服务
主进程(Main Process)
- 每个 Electron 应用有且只有一个主进程
- 它是应用的入口,运行
main.js - 拥有完整的 Node.js API 访问权限
- 负责创建和管理所有的渲染进程(窗口)
- 可以访问系统级 API(文件、网络、通知等)
渲染进程(Renderer Process)
- 每个
BrowserWindow对应一个独立的渲染进程 - 本质上是一个 Chromium 浏览器环境
- 负责界面渲染、用户交互、DOM 操作
- 默认情况下不能直接访问 Node.js API(安全考虑)
- 多个窗口之间是相互隔离的
| 特性 | 主进程 (Main) | 渲染进程 (Renderer) |
|---|---|---|
| 数量 | 每个应用 1 个 | 每个窗口 1 个 |
| 入口 | package.json 中 main 字段 | BrowserWindow.loadFile() 加载的 HTML |
| Node.js API | ✅ 完整访问 | ❌ 默认关闭(可通过 nodeIntegration 开启,但不推荐) |
| DOM/BOM | ❌ 无 | ✅ 完整访问 |
| 系统 API | ✅ 完整访问 | ❌ 需通过 IPC 间接调用 |
| 用途 | 应用生命周期、窗口管理、系统交互 | UI 渲染、用户交互、前端逻辑 |
进程间通信(IPC)
既然主进程和渲染进程是隔离的,它们如何协作?答案是 IPC(Inter-Process Communication,进程间通信)。
类比理解
IPC 就像餐厅里服务员用对讲机呼叫经理。渲染进程(服务员)需要调用系统功能(如保存文件)时,通过 IPC 发送消息给主进程(经理),主进程处理完后把结果传回。
IPC 通信模式
| 模式 | 方向 | 特点 | 适用场景 |
|---|---|---|---|
ipcMain.on / ipcRenderer.send | 渲染 → 主 | 单向发送,无返回值 | 通知主进程做某事 |
ipcMain.handle / ipcRenderer.invoke | 渲染 ↔ 主 | 双向,Promise 返回值 | 需要返回数据的请求 |
webContents.send | 主 → 渲染 | 主进程主动推送 | 广播消息、状态更新 |
完整 IPC 示例
主进程(main.js)
javascript
const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')
const fs = require('fs')
let mainWindow
function createWindow () {
mainWindow = new BrowserWindow({
width: 900,
height: 700,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
// 安全设置:关闭 nodeIntegration,启用 contextIsolation
nodeIntegration: false,
contextIsolation: true
}
})
mainWindow.loadFile('index.html')
}
// ========== IPC 处理 ==========
// 模式1:单向接收(渲染 → 主)
ipcMain.on('app:quit', () => {
console.log('收到退出指令')
app.quit()
})
// 模式2:双向请求响应(渲染 ↔ 主)
ipcMain.handle('file:read', async (event, filePath) => {
try {
const content = fs.readFileSync(filePath, 'utf-8')
return { success: true, content }
} catch (err) {
return { success: false, error: err.message }
}
})
// 模式2:打开文件对话框
ipcMain.handle('dialog:openFile', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [
{ name: '文本文件', extensions: ['txt', 'md'] },
{ name: '所有文件', extensions: ['*'] }
]
})
return result
})
app.whenReady().then(createWindow)预加载脚本(preload.js)- 安全桥梁
javascript
const { contextBridge, ipcRenderer } = require('electron')
// 通过 contextBridge 安全地暴露 API 给渲染进程
// 渲染进程只能通过 window.electronAPI 访问这些暴露的方法
contextBridge.exposeInMainWorld('electronAPI', {
// 模式1:单向发送
quitApp: () => ipcRenderer.send('app:quit'),
// 模式2:双向调用(返回 Promise)
readFile: (filePath) => ipcRenderer.invoke('file:read', filePath),
openFileDialog: () => ipcRenderer.invoke('dialog:openFile'),
// 模式3:接收主进程推送(主 → 渲染)
onMessageFromMain: (callback) => {
ipcRenderer.on('main:message', (event, data) => callback(data))
}
})渲染进程(renderer.js)
javascript
// 渲染进程中通过 window.electronAPI 调用暴露的方法
// 模式1:单向发送 - 退出应用
document.getElementById('quit-btn').addEventListener('click', () => {
window.electronAPI.quitApp()
})
// 模式2:双向调用 - 读取文件
async function loadFile() {
try {
// 先打开对话框选择文件
const dialogResult = await window.electronAPI.openFileDialog()
if (!dialogResult.canceled && dialogResult.filePaths.length > 0) {
const filePath = dialogResult.filePaths[0]
// 读取文件内容
const result = await window.electronAPI.readFile(filePath)
if (result.success) {
document.getElementById('content').textContent = result.content
} else {
alert('读取失败:' + result.error)
}
}
} catch (err) {
console.error('调用失败:', err)
}
}
// 模式3:接收主进程消息
window.electronAPI.onMessageFromMain((data) => {
console.log('收到主进程消息:', data)
document.getElementById('status').textContent = data
})
// 绑定按钮事件
document.getElementById('open-btn').addEventListener('click', loadFile)HTML 界面
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>IPC 通信示例</title>
<style>
body { font-family: sans-serif; padding: 20px; background: #1e1e1e; color: #d4d4d4; }
button { padding: 10px 20px; margin: 5px; cursor: pointer; }
#content {
background: #252526;
padding: 15px;
border-radius: 4px;
min-height: 200px;
white-space: pre-wrap;
font-family: monospace;
}
</style>
</head>
<body>
<h1>IPC 通信演示</h1>
<button id="open-btn">📂 打开文件</button>
<button id="quit-btn">❌ 退出应用</button>
<p>状态: <span id="status">就绪</span></p>
<div id="content">点击"打开文件"按钮选择文件...</div>
<!-- 注意:不需要引入 preload.js,它由主进程自动加载 -->
<script src="renderer.js"></script>
</body>
</html>安全警告
永远不要直接暴露 ipcRenderer 给渲染进程!上面的 preload.js 使用了 contextBridge 来选择性暴露特定方法,这是 Electron 推荐的安全做法。直接暴露整个 ipcRenderer 会让恶意脚本有能力发送任意 IPC 消息。
预加载脚本与安全模型
Electron 的安全模型经历了多次演进。理解这些安全设置对构建生产级应用至关重要。
关键安全配置
| 配置项 | 默认值 | 推荐值 | 说明 |
|---|---|---|---|
nodeIntegration | false | false | 是否在渲染进程中启用 Node.js。关闭可防止 XSS 攻击执行系统命令。 |
contextIsolation | true | true | 上下文隔离。开启后 preload 和页面运行在不同上下文,防止页面脚本篡改暴露的 API。 |
enableRemoteModule | false | false | remote 模块已被废弃,使用 IPC 替代。 |
allowRunningInsecureContent | false | false | 允许在 HTTPS 页面中运行 HTTP 内容。保持关闭。 |
安全红线
- 不要在渲染进程中直接执行
require('child_process').exec('rm -rf /') - 不要加载远程不可信的 URL 到主窗口
- 不要禁用
contextIsolation来"图方便" - 所有用户输入都要验证和转义
sandbox 模式详解
sandbox: true 是 Electron 最严格的安全配置。启用后,渲染进程运行在操作系统级沙箱中,类似于 Chrome 的站点隔离:
javascript
webPreferences: {
sandbox: true, // 开启操作系统级沙箱
contextIsolation: true, // 必须同时开启
nodeIntegration: false, // 必须保持关闭
preload: path.join(__dirname, 'preload.js')
}| 配置 | 安全性 | preload 可访问 Node | 适用场景 |
|---|---|---|---|
sandbox: true | 最高 | 有限(部分 Node API 被限制) | 加载不受信内容、生产环境首选 |
sandbox: false | 中等(依赖 contextIsolation) | 完整 | 信任的内容、需要原生模块的 preload |
推荐策略
新项目默认开启 sandbox。仅当你在 preload 中使用了需要操作系统权限的原生模块(如 @nut-tree/nut-js)时才考虑关闭——但这意味着你的 preload 脚本本身需要更严格的安全审查。
安全架构图解
text
┌─────────────────────────────────────────────────────────┐
│ 渲染进程 (Chromium) │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ 页面脚本 │ │ 预加载脚本 │ │
│ │ (不可信代码) │ │ (可信,可访问 Node API) │ │
│ │ │ │ │ │
│ │ window.electron│◄─────│ contextBridge.exposeIn │ │
│ │ API.readFile │ │ MainWorld('electronAPI')│ │
│ └─────────────────┘ └──────────────────────────┘ │
│ ▲ │ │
│ │ 上下文隔离 │ 可访问 Node.js │
│ └───────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ IPC 通信层 │
│ (ipcRenderer ↔ ipcMain) │
├─────────────────────────────────────────────────────────┤
│ 主进程 (Node.js) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 完整的系统访问权限:fs、net、child_process、dialog │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘安全加固:CSP 与 XSS 防护
安全模型做好了进程隔离,但渲染进程的 Web 安全同样重要。以下是两个关键的防护措施。
内容安全策略(CSP)
CSP 限制渲染进程可以加载的资源来源,是防御 XSS 攻击的重要防线:
html
<!-- 严格的 CSP 策略 -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: file:;
font-src 'self' data:;
connect-src 'self' https://your-api.com;
media-src 'none';
object-src 'none';
">javascript
// 更灵活的方案:在主进程中动态设置 CSP
const { session } = require('electron')
app.whenReady().then(() => {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://your-api.com"
]
}
})
})
})XSS 防护检查清单
| 措施 | 说明 | 优先级 |
|---|---|---|
避免 innerHTML | 使用 textContent 或 Vue 的 插值,自动转义 HTML | 必须 |
| 使用 DOMPurify | 如果必须插入 HTML,先用 DOMPurify.sanitize() 过滤 | 必须 |
| 校验用户输入 | 所有通过 IPC 传入主进程的数据都要做类型和范围校验 | 必须 |
禁用 eval() | 确保 webPreferences.nodeIntegration 和 sandbox 配置正确 | 推荐 |
| 定期更新依赖 | Electron、Chromium 和 npm 包的安全补丁要及时跟进 | 推荐 |
| 审核 preload API | 只暴露必要的最小 API 集合,每个接口都要做路径/参数白名单校验 | 必须 |
bash
# 安装 DOMPurify
npm install dompurifyjavascript
import DOMPurify from 'dompurify'
// 安全的 HTML 插入
function safeSetHTML(element, dirtyHTML) {
const clean = DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target'],
})
element.innerHTML = clean
}应用生命周期事件
Electron 应用有一系列生命周期事件,掌握它们能帮你更好地控制应用行为。
javascript
const { app } = require('electron')
// 应用正在准备启动
app.on('will-finish-launching', () => {
console.log('应用即将完成启动')
})
// Electron 初始化完成,可以创建窗口了
app.whenReady().then(() => {
console.log('应用已就绪')
createWindow()
})
// 所有窗口已关闭
app.on('window-all-closed', () => {
console.log('所有窗口已关闭')
// macOS 上通常不退出应用
if (process.platform !== 'darwin') {
app.quit()
}
})
// 应用即将退出
app.on('will-quit', (event) => {
console.log('应用即将退出')
// event.preventDefault() // 可以阻止退出
})
// 应用已退出
app.on('quit', () => {
console.log('应用已退出')
})
// macOS: 点击 Dock 图标时触发
app.on('activate', () => {
console.log('应用被激活')
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
// 应用获得焦点
app.on('browser-window-focus', () => {
console.log('窗口获得焦点')
})
// 应用失去焦点
app.on('browser-window-blur', () => {
console.log('窗口失去焦点')
})生命周期流程图
text
[启动应用]
↓
[will-finish-launching]
↓
[ready] ←── 创建窗口、设置菜单、初始化数据
↓
[运行中] ←── 用户交互、窗口管理
↓
[window-all-closed]
↓
├─→ macOS: 保持运行,等待 [activate] 重新创建窗口
└─→ Win/Linux: 继续 → [will-quit]
↓
[will-quit]
↓
[quit] ←── 清理资源、保存状态最佳实践
- 在
ready事件中初始化应用,不要在此之前调用 Electron API - 在
will-quit中保存用户数据和配置 - macOS 上处理
activate事件,实现点击 Dock 重新打开窗口 - 使用
app.isReady()检查应用状态,避免在 ready 之前操作
本章小结
本章核心要点:
- Electron 采用多进程架构:1 个主进程 + N 个渲染进程
- 主进程拥有系统级权限,渲染进程负责 UI
- IPC 是进程间通信的唯一安全通道,有三种通信模式
- 预加载脚本(preload)是安全桥梁,通过
contextBridge暴露 API - 始终开启
contextIsolation,关闭nodeIntegration - 掌握生命周期事件,正确管理应用启动和退出
下一章我们将学习如何用 Vue3 + Vite 进行工程化开发,告别手搭项目的原始时代。