在 Electron 应用中,主进程和渲染进程是两套独立的运行环境,它们不能直接访问对方的变量或函数。要实现两者之间的数据传递和指令调用,必须依赖 Electron 提供的 IPC(Inter-Process Communication,进程间通信) 机制。这个机制的本质是消息传递:渲染进程向主进程发送消息,主进程处理后再返回结果,或者反过来主动推送消息。
为什么需要双向通信
举个真实场景:用户点击界面上的“保存文件”按钮。这个按钮的点击事件发生在渲染进程,但实际写文件(调用 fs.writeFile)必须在主进程或者受主进程管控的 Node.js 环境中完成——出于安全考虑,现代 Electron 应用会禁用渲染进程直接访问 Node.js 的能力。因此,渲染进程需要通过 IPC 把“保存文件”这个请求和文件内容发送给主进程,主进程执行完文件写入后,再把“保存成功”或出错的信息送回渲染进程,界面上再给出相应提示。这就是一次标准的双向通信。
通信的两种主要模式
Electron 提供了两套 IPC 工具:
- ipcMain(主进程使用):用来监听来自渲染进程的消息,并可以回复。
- ipcRenderer(渲染进程使用,或通过预加载脚本暴露给渲染进程):用来向主进程发送消息,并处理主进程的回复。
通信可以分为“单向”和“双向”两种形式:
- 单向通信:渲染进程发一个消息给主进程,不期待返回值。
例如,通知主进程最小化窗口、隐藏某个菜单项之类。渲染进程用 ipcRenderer.send,主进程用 ipcMain.on 监听。
- 双向通信:渲染进程发请求,主进程处理后返回结果。
这是最常见的需求,比如读取文件、保存数据、获取系统信息。从 Electron 7 开始,官方推荐使用 ipcRenderer.invoke 和 ipcMain.handle,它们基于 Promise,代码更清晰,也避免了回调嵌套。
安全的双向通信实现(推荐方式)
现在的安全最佳实践是开启 contextIsolation: true,并完全禁止渲染进程直接使用 require。所有主进程的能力都通过 预加载脚本(preload.js) 和 contextBridge 有选择地暴露给渲染进程。渲染进程拿到的只是一个纯净的浏览器环境,只能调用你事先定义好的安全接口。
1. 主进程(main.js):注册处理函数
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'), // 指定预加载脚本
contextIsolation: true, // 开启上下文隔离
nodeIntegration: false, // 关闭 Node.js 集成
},
});
win.loadFile('index.html');
}
// 双向通信:处理“打开文件”请求
ipcMain.handle('open-file-dialog', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'txt'] }]
});
if (result.canceled) return null;
return result.filePaths[0]; // 将选中的文件路径返回给渲染进程
});
app.whenReady().then(createWindow);
2. 预加载脚本(preload.js):暴露安全 API
const { contextBridge, ipcRenderer } = require('electron');
// 通过 contextBridge 向渲染进程暴露一个全局对象 window.electronAPI
contextBridge.exposeInMainWorld('electronAPI', {
// 渲染进程调用这个函数,实际会通过 ipcRenderer.invoke 向主进程发请求
openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
// 单向通信示例:通知主进程最小化窗口(不关心返回值)
minimizeWindow: () => ipcRenderer.send('minimize-window'),
// 监听主进程主动推送的消息
onFileSaved: (callback) => ipcRenderer.on('file-saved', callback)
});
3. 渲染进程(renderer.js 或 Vue/React 组件中)
// 界面中点击“打开文件”按钮时
document.getElementById('open-btn').addEventListener('click', async () => {
const filePath = await window.electronAPI.openFileDialog();
if (filePath) {
console.log('用户选择了文件:', filePath);
// 接下来可以再调用其他 API 读取文件内容等
}
});
// 监听主进程发来的消息(例如文件保存完成后)
window.electronAPI.onFileSaved((event, message) => {
console.log('主进程通知:', message);
});
这样,一次完整的双向通信流程是:
- 渲染进程调用
window.electronAPI.openFileDialog() - 预加载脚本中的
ipcRenderer.invoke('open-file-dialog')向主进程发送请求 - 主进程的
ipcMain.handle('open-file-dialog', ...)收到请求,打开系统文件对话框,返回文件路径 - 预加载脚本拿到返回值,转给渲染进程的 Promise,渲染进程得到文件路径
整个过程严格遵守“最小权限”原则:渲染进程不知道文件对话框具体是怎么实现的,它只拥有你显式暴露的那几个函数。
主进程主动向渲染进程推送
通信并不总是由渲染进程发起。比如主进程检测到系统主题变化,需要通知所有窗口更新样式。这时主进程可以通过 webContents.send 主动向指定窗口发送消息:
// 主进程
mainWindow.webContents.send('theme-changed', 'dark');
在渲染进程中,依然通过预加载脚本暴露的监听函数来接收:
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
onThemeChanged: (callback) => ipcRenderer.on('theme-changed', callback)
});
// 渲染进程
window.electronAPI.onThemeChanged((event, theme) => {
document.body.className = theme;
});
注意,这类监听需要记得在窗口销毁或不用时调用 ipcRenderer.removeAllListeners 移除,以免内存泄漏。
总结你的日常使用模式
在实际项目里,你几乎不需要直接记忆所有 IPC 方法名,只需遵循一个固定的文件模板:
- preload.js:罗列所有白名单通信接口,作为渲染进程与主进程之间的唯一桥梁。
- main.js:处理所有来自渲染进程的 invoke 和 send 请求,并且掌管系统的原生能力。
- 渲染进程(.vue/.jsx 等):只调用
window.electronAPI上的方法,完全像调普通异步函数一样使用系统能力。
这套“请求-响应”模型加上 Promise 同步的写法,会让你的代码即便在复杂的桌面应用中也保持简洁和可维护。而安全性从一开始就被内置在了模式里,避免了因为疏忽导致的远程代码执行漏洞。这就是现代 Electron 应用中主进程与渲染进程双向通信的真实、成熟且最实用的做法。