2、Electron核心架构:理解主进程与渲染进程;进程间通信(IPC)基础;使用contextBridge暴露安全的API;preload脚本的作用与编写。
好,我们直接进入正题。Electron 的架构,说白了就是「两个世界」的协作。一个是主进程,一个是渲染进程。很多新手一上来就懵,觉得这俩东西怎么这么绕。我刚开始接触时也踩过坑,后来才明白——这其实是桌面应用最优雅的设计之一。
2.1 主进程 vs 渲染进程:谁负责什么?
先打个比方。主进程就像餐厅的后厨,掌控全局——管理菜单、调度厨师、处理订单。渲染进程呢?就是前厅的餐桌,每张桌子上的客人各吃各的,互不干扰。
具体到技术层面:
- 主进程:Node.js 环境,负责窗口管理、系统交互、文件操作、原生菜单、托盘图标等。一个应用只有一个主进程。
- 渲染进程:Chromium 环境,负责页面渲染、用户交互、DOM 操作。每个窗口都是一个独立的渲染进程。
嗯,这里要注意:渲染进程不能直接调用 Node.js API。为什么?因为安全。你想想看,如果网页里的 JavaScript 能随便读写你的文件系统,那得多危险。我在项目中遇到过有人试图在渲染进程里直接 require('fs'),结果报错——这就是 Electron 刻意设计的隔离机制。
核心原则:主进程管「系统」,渲染进程管「界面」。两者各司其职,通过 IPC 沟通。
2.2 进程间通信(IPC)基础
两个世界要对话,就得有「信使」。Electron 提供了两套 IPC 机制:ipcMain 和 ipcRenderer。
我个人习惯把 IPC 分成两种模式:
- 单向通信:渲染进程发消息给主进程,不需要回复。比如用户点击「保存」按钮,通知主进程写文件。
- 双向通信:渲染进程发消息,主进程处理完再回复。比如请求读取某个文件的内容。
来看个最简单的例子。渲染进程发消息:
// renderer.js
const { ipcRenderer } = require('electron')
ipcRenderer.send('save-file', '这是要保存的内容')
主进程接收:
// main.js
const { ipcMain } = require('electron')
ipcMain.on('save-file', (event, content) => {
// 这里写文件操作
console.log('收到内容:', content)
})
双向通信稍微复杂一点,用 invoke 和 handle:
// renderer.js
const result = await ipcRenderer.invoke('read-file', '/path/to/file')
console.log('文件内容:', result)
// main.js
ipcMain.handle('read-file', async (event, filePath) => {
const fs = require('fs')
return fs.readFileSync(filePath, 'utf-8')
})
我的经验:能用 invoke/handle 就别用 send/on。前者是异步的,不会阻塞渲染进程,而且代码更清晰。我曾经在一个项目里混用了两种模式,调试起来简直想砸键盘。
2.3 preload 脚本的作用与编写
好,问题来了。渲染进程不能直接调用 Node.js API,那它怎么跟主进程通信?答案就是 preload 脚本。
preload 脚本运行在渲染进程里,但它有特权——可以访问 Node.js 和 Electron 的 API。它就像一个「桥梁」,在渲染进程加载之前先执行,把需要的功能暴露给渲染进程。
来看一个标准的 preload 脚本:
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (content) => ipcRenderer.invoke('save-file', content),
readFile: (path) => ipcRenderer.invoke('read-file', path),
onMenuClick: (callback) => ipcRenderer.on('menu-click', callback)
})
然后在渲染进程里,你就可以安全地调用:
// renderer.js
window.electronAPI.saveFile('你好,世界!')
.then(() => console.log('保存成功'))
避坑指南:我曾经在 preload 脚本里直接暴露了整个 ipcRenderer 对象,结果被安全审计打回来。记住:只暴露你需要的 API,不要图省事。你想想看,如果渲染进程能随意调用 ipcRenderer.send,那恶意脚本就能伪造任何消息。
2.4 使用 contextBridge 暴露安全的 API
contextBridge 是 Electron 12 之后推荐的方式。它解决了两个关键问题:
- 隔离性:暴露的 API 运行在独立的上下文里,不会污染全局作用域
- 安全性:只能暴露你明确指定的方法,不能直接访问 Node.js 模块
我建议你遵循这个模式:
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
// 只暴露方法,不暴露对象
getData: () => ipcRenderer.invoke('get-data'),
setData: (data) => ipcRenderer.invoke('set-data', data),
// 监听事件也要封装
onUpdate: (callback) => {
ipcRenderer.on('data-updated', (event, data) => callback(data))
}
})
渲染进程里这样用:
const data = await window.api.getData()
window.api.onUpdate((newData) => {
console.log('数据更新了:', newData)
})
记住这个原则:preload 脚本只做「桥接」,不做「业务」。业务逻辑应该放在主进程或渲染进程里。preload 脚本越薄越好,只负责把 IPC 通道包装成安全的 API。
2.5 完整的工作流
把上面这些串起来,一个典型的 Electron 应用工作流是这样的:
- 主进程启动,创建窗口,加载
index.html - 窗口加载前,先执行
preload.js,通过contextBridge暴露 API - 渲染进程加载完毕,用户点击按钮,调用
window.api.saveFile() - preload 脚本里的
ipcRenderer.invoke把消息发给主进程 - 主进程的
ipcMain.handle处理请求,执行文件操作 - 主进程返回结果,渲染进程收到后更新界面
嗯,这个流程你得多练几遍。我记得第一次完整跑通这个流程时,那种「两个世界终于对话了」的感觉,真的很爽。
小技巧:调试时可以在主进程和 preload 脚本里加 console.log,然后在开发者工具里看控制台。不过要注意——preload 脚本的日志会出现在渲染进程的控制台里,别找错地方。
最后说一句:Electron 的进程模型虽然学习曲线陡峭,但一旦理解了,你会发现它其实很合理。主进程负责「硬核」的系统操作,渲染进程负责「灵活」的界面展示,preload 脚本做安全的桥梁——各司其职,互不越界。这就是桌面应用该有的样子。