本文首发同名微信公众号:前端徐徐
大家好,我是徐徐。今天我们要讲的是 Electron 中的进程间通信(IPC)。
前言
在开发 Electron 项目的时候我们会经常遇到进程间通信的场景,进程间通信也是传统桌面端开发中非常常见的场景,我们在这里不仅仅是讲如何使用 IPC,我们更多的是通过源码去了解 IPC 底层的实现,然后通过了解原理去更好的解决我们在实际场景中可能会遇到的一些问题以及一些注意事项。
IPC 原理
我们在使用 IPC 的时候可以看看它是如何实现的,主要是看 ipcMain
和 ipcRenderer
这两个的实现。
ipcMain
源码路径:GitHub1s
import { IpcMainInvokeEvent } from 'electron/main';
import { EventEmitter } from 'events';
export class IpcMainImpl extends EventEmitter implements Electron.IpcMain {
private _invokeHandlers: Map<string, (e: IpcMainInvokeEvent, ...args: any[]) => void> = new Map();
constructor () {
super();
// Do not throw exception when channel name is "error".
this.on('error', () => {});
}
handle: Electron.IpcMain['handle'] = (method, fn) => {
if (this._invokeHandlers.has(method)) {
throw new Error(`Attempted to register a second handler for '${method}'`);
}
if (typeof fn !== 'function') {
throw new TypeError(`Expected handler to be a function, but found type '${typeof fn}'`);
}
this._invokeHandlers.set(method, fn);
};
handleOnce: Electron.IpcMain['handleOnce'] = (method, fn) => {
this.handle(method, (e, ...args) => {
this.removeHandler(method);
return fn(e, ...args);
});
};
removeHandler (method: string) {
this._invokeHandlers.delete(method);
}
}
这个 IpcMainImpl
类提供了一个简单的 IPC 处理机制,允许你注册处理函数,管理它们的生命周期(如一次性处理),并确保在错误发生时不会抛出异常。它将所有的 IPC 处理函数保存在一个 Map
中,方便查找和管理。这个实现可以用作 Electron 应用的 IPC 通信的基础,提供了灵活且安全的处理方式。
我们可以看到IpcMainImpl
类继承自 EventEmitter
,这使得它可以使用事件触发和监听的功能。以下是事件机制的几个关键点:
- 事件触发:在
IpcMainImpl
中,你可以通过this.emit(eventName, args)
来触发特定的事件,像是错误事件(error
)。 - 事件监听:通过
this.on(eventName, listener)
方法,你可以注册监听器,处理特定事件的发生。在构造函数中,注册了一个error
事件的监听器,以避免在处理 IPC 时抛出异常。 - 异步处理:通过事件机制,处理函数可以在事件被触发时异步执行,这对于 IPC 通信非常重要,因为它允许主进程和渲染进程之间的消息传递,而不会阻塞其他操作。
- 灵活性:事件机制提供了高度的灵活性,你可以根据需要添加、移除或修改事件处理函数,使得 IPC 的管理更加动态。
事件机制为 IpcMainImpl
提供了一个强大的基础,支持异步通信和事件驱动的编程模式。
ipcRenderer
源码路径:GitHub1s
import { EventEmitter } from 'events';
const { ipc } = process._linkedBinding('electron_renderer_ipc');
const internal = false;
class IpcRenderer extends EventEmitter implements Electron.IpcRenderer {
send (channel: string, ...args: any[]) {
return ipc.send(internal, channel, args);
}
sendSync (channel: string, ...args: any[]) {
return ipc.sendSync(internal, channel, args);
}
sendToHost (channel: string, ...args: any[]) {
return ipc.sendToHost(channel, args);
}
async invoke (channel: string, ...args: any[]) {
const { error, result } = await ipc.invoke(internal, channel, args);
if (error) {
throw new Error(`Error invoking remote method '${channel}': ${error}`);
}
return result;
}
postMessage (channel: string, message: any, transferables: any) {
return ipc.postMessage(channel, message, transferables);
}
}
export default new IpcRenderer();
可以看到 ipcRenderer
的实现也是基于事件的,IpcRenderer
类继承自 EventEmitter
,使得它能够利用事件触发和监听的功能。在 Electron 中,IPC 通信的许多操作都是基于事件驱动的。这些事件可以包括消息的发送、接收、错误处理等。了解了原理之后,我们来看看一些 IPC 的实践。
IPC 实践
在开始实践之前,我们需要理清楚在 Electron 中有多少种通信的场景,可以看下面这张图。
在实现进程通信的方法之前,我们需要先了解一下Electron的上下文隔离。这里就不做过多的解释了,Electron的官方网站有相应的解释:上下文隔离 | Electron
了解上下文隔离主要是为了在开始进行跨进程通信之前实现 preload 脚本打下基础,不然不会了解 Electron 中的流程模型,这些知识点都是一个个窜起来的,所以在开始之前都学习了解一下总没有坏处,后面实现跨进程通信思路就清晰了。
渲染进程向主进程发送消息
单向通信
一个简单的需求,点击渲染进程上的按钮打开默认浏览器。
在预加载脚本实现的时候我们已经在preload
中注入了openUrlByDefaultBrowser
这个方法,现在我们可以从渲染层出发逐级向下让Electron触发打开浏览器的行为。
我们在渲染进程中添加如下代码
- src/render/App.tsx
import React from "react"
const App = () => {
const openUrlByDefaultBrowser = () => {
window.electronAPI.openUrlByDefaultBrowser('https://www.baidu.com')
}
return <div>
<h1>Hello Vite + Electron</h1>
<button onClick={openUrlByDefaultBrowser}>openUrlByDefaultBrowser</button>
</div>
}
export default App
这里的window.nativeBridge.openUrlByDefaultBrowser
就是我们渲染层调用preload的暴露出来的方法的方式,当渲染层触发这个方法后,preload就会发送一个openUrlByDefaultBrowser
的消息给主进程,主进程用ipcMain.on
接受消息,然后触发相应的调用方法,整个单向通信的流程就结束了。
- src\main\ipc\index.ts
import { ipcMain, IpcMainEvent, shell } from "electron"
const openUrlByDefaultBrowser = (e:IpcMainEvent, url: string, options?: Electron.OpenExternalOptions) => {
shell.openExternal(url, options)
}
const initIpcOn = () => {
ipcMain.on('openUrlByDefaultBrowser', openUrlByDefaultBrowser)
}
export const initIpc = () => {
initIpcOn()
}
- src\main\index.ts
import { initIpc } from "./ipc";
const main = () => {
initIpc();
}
双向通信
双向通信分为三种方式,分别是promise 、 同步和异步模式,下面我们来看看这三种模式。
Promise模式
这种模式是 Electron 官方推荐的方法,使用方便快捷,推荐在实际场景中使用这个方式。
编写渲染进程代码
- src/render/pages/main/index.tsx
const communicateWithEachOtherSendMsgPromise = () => {
window.electronAPI.communicateWithEachOtherWithPromise("Hello Promise").then((msg: any) => {
console.log(msg)
})
}
前置脚本注入
- src/preload/index.ts
communicateWithEachOtherWithPromise: (msg:string) => ipcRenderer.invoke('communicateWithEachOtherWithPromise', msg),
主进程中处理消息
- src/main/ipc/index.ts
ipcMain.handle('communicateWithEachOtherWithPromise', (event: IpcMainInvokeEvent, ...args: any[]): Promise<string> => {
const msg = args[0];
return Promise.resolve(`I got ${msg}, ok`);
});
异步
先编写渲染进程代码
- src/render/pages/main/index.tsx
const communicateWithEachOtherSendMsg = () => {
window.electronAPI.communicateWithEachOtherSendMsg("Hello");
};
<button onClick={communicateWithEachOtherSendMsg}>
communicateWithEachOtherSendMsg
</button>
然后在前置脚本中注入 communicateWithEachOtherSendMsg
这个方法。
- src/preload/index.ts
communicateWithEachOtherSendMsg: (msg:string) => ipcRenderer.send('communicateWithEachOtherSendMsg', msg)
主进程中处理消息
- src/main/ipc/index.ts
ipcMain.on('communicateWithEachOtherSendMsg', (event:IpcMainEvent, msg:string) => {
event.reply('communicateWithEachOtherReply', `I got ${msg},ok`)
})
前置脚本接收数据
- src/preload/index.ts
ipcRenderer.on('communicateWithEachOtherReply', (_event, arg) => {
console.log(arg)
})
可以看出这种方式是比较复杂的,有两缺点:
- 您需要设置第二个
ipcRenderer.on
监听器来处理渲染器进程中的响应。 使用invoke
,您将获得作为 Promise 返回到原始 API 调用的响应值。 - 没有显而易见的方法可以将
asynchronous-reply
消息与原始的asynchronous-message
消息配对。 如果您通过这些通道非常频繁地来回传递消息,则需要添加其他应用代码来单独跟踪每个调用和响应。
同步
使用 ipcRenderer.sendSync
API 向主进程发送消息,并同步等待响应。
依然,先在渲染进程中编写代码。
- src/render/pages/main/index.tsx
const communicateWithEachOtherSendMsgSendSync = () => {
const msg = window.electronAPI.communicateWithEachOtherSendSyncMsg("Hello sync");
console.log(msg)
}
前置脚本
- src/preload/index.ts
communicateWithEachOtherSendSyncMsg: (msg:string) => ipcRenderer.sendSync('communicateWithEachOtherSendSyncMsg', msg),
主进程
- src/main/ipc/index.ts
ipcMain.on('communicateWithEachOtherSendSyncMsg', (event:IpcMainEvent, msg:string) => {
event.returnValue = `I got ${msg},ok`
})
这份代码的结构与 invoke
模型非常相似,但出于性能原因,官方是建议避免使用此 API。 它的同步特性意味着它将阻塞渲染器进程,直到收到回复为止。
在双向通信的模式中,promise 模式是现阶段 electron 官方最推荐的方法,后面异步和同步的方式是在 electron 7.0 以前使用的方式。
主进程向渲染进程发消息
将消息从主进程发送到渲染器进程时,需要指定是哪一个渲染器接收消息。 消息需要通过其 WebContents 实例发送到渲染器进程。 此 WebContents 实例包含一个 send 方法,其使用方式与 ipcRenderer.send 相同。
我们现在做一个小功能来演示这个发送消息的过程,在menu上点击一个按钮让渲染进程的数据变化。
首先利用 webContents 发送消息
- src/main/index.ts
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: () => {
mainWindow.webContents.send("update-counter", 1);
},
label: "IncrementNumber",
},
],
},
]);
Menu.setApplicationMenu(menu);
然后在 preload 中通过 ipcRenderer.on 监听数据
- src/preload/index.ts
onUpdateCounterFormMain: (callback:OnUpdateCounterFormMainCallback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
渲染进程监听数据
- src/render/pages/main/index.tsx
useEffect(() => {
window.electronAPI.onUpdateCounterByMain((e: Event, value: any) => {
setCount((pre) => {
return pre + value
})
});
},[]);
经过上面的步骤,我们就可以通过原生的菜单按钮改变渲染进程中的数据。
附带返回
如果我们还需要返回一个值给主进程,我们就再发生一个消息到主进程就可以了。
渲染进程发送消息
- src/render/pages/main/index.tsx
useEffect(() => {
window.electronAPI.onUpdateCounterFormMain((value: number) => {
setCount((pre) => {
const res = pre + value
window.electronAPI.updateCounterCallback(res);
return res
})
});
},[]);
prload 脚本
- src/preload/index.ts
updateCounterCallback: (value:number) => ipcRenderer.send('counterValueCallback', value)
主进程监听
- src/main/ipc/index.ts
ipcMain.on('counterValueCallback', (event:IpcMainEvent, value:string) => {
console.log('counterValueCallback',value)
})
不同渲染进程之间通信
现阶段 electron 没有直接的方法可以使用 ipcMain
和 ipcRenderer
模块在 Electron 中的渲染器进程之间发送消息,所以你有如下两种方式来实现,一种是通过主进程转发,一种是通过MessagePort。在这之前我们需要改造一下前端应用,添加路由然后创建另外的窗口,引用不同的路由,如此就可以演示不同进程之间通信了。
前端项目改造
- src/render/index.tsx
import * as React from "react";
import { createRoot } from 'react-dom/client';
import {
createHashRouter,
RouterProvider,
} from "react-router-dom";
import Main from "./pages/main";
import Work from "./pages/work";
import App from "./app";
const container = document.getElementById('root');
const root = createRoot(container!);
const router = createHashRouter([
{
path: "/",
element: <App />,
children: [
{
path: "main",
element: <Main />,
},
{
path: "work",
element: <Work />,
},
],
},
]);
root.render(<RouterProvider router={router} />);
主进程创建窗口改造
- src/main/index.ts
import { BrowserWindow, Menu, app } from "electron";
import { resolve, join } from "path";
import { initIpc } from "./ipc";
const createWindow = (hashRoute = '') => {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: join(__dirname, "../preload/index.cjs"),
},
});
const baseUrl = import.meta.env.MODE === "dev"
? import.meta.env.VITE_DEV_SERVER_URL
: `file://${resolve(__dirname, "../render/index.html")}`;
const url = `${baseUrl}#${hashRoute}`;
mainWindow.loadURL(url);
if (import.meta.env.MODE === "dev") {
mainWindow.webContents.openDevTools({ mode: "detach", activate: true });
}
return mainWindow;
};
const initMenu = (mainWindow: BrowserWindow) => {
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: () => {
mainWindow.webContents.send("update-counter", 1);
},
label: "IncrementNumber",
},
],
},
]);
Menu.setApplicationMenu(menu);
};
const main = () => {
const mainWindow = createWindow('main');
createWindow('work')
initMenu(mainWindow);
initIpc();
};
app.whenReady().then(() => {
main();
});
这里创建了两个窗口,访问的路由不同,这样就可以演示两个渲染进程见的通信了。
通过主进程转发
大概步骤就是,渲染进程 main
通过 ipcRenderer.send
发送消息到主进程,主进程用 ipcMain.on
接收数据然后再用 webContents.send
发送消息到渲染进程 work
,然后 work
进程用 ipcRenderer.on
接收消息。
渲染进程 main
发送消息到主进程
- src/preload/index.ts
mainSendMsgToWork: (msg:string) => ipcRenderer.send('mainSendMsgToWork', msg),
- src/render/pages/main/index.tsx
const mainSendMsgToWork = () => {
window.electronAPI.mainSendMsgToWork("Hello work");
}
主进程转发消息
- src/main/ipc/index.ts
ipcMain.on('mainSendMsgToWork', (event:IpcMainEvent, msg:string) => {
winodws.workWindow.webContents.send('workSendMsgToMain', msg)
})
渲染进程 work 接收消息
- src/preload/index.ts
listenMsgFromMain: (callback:(msg:string) => void) => ipcRenderer.on('workSendMsgToMain', (_event, msg) => callback(msg)),
- src/render/pages/work/index.tsx
import React, { useEffect, useState } from "react"
const Work = () => {
const [msg,setMsg] = useState('')
useEffect(() => {
window.electronAPI.listenMsgFromMain((value: string) => {
console.log(value)
setMsg(value)
})
},[])
return <div>
<h1>Work</h1>
<div>
<p>{msg}</p>
</div>
</div>
}
export default Work
点击 mainSendMsgToWork 按钮 work 窗口会展示相应的消息内容。
通过MessagePort
关于MessagePort:
MessagePort - Web APIs | MDN
在渲染器中, MessagePort
类的行为与它在 web 上的行为完全一样。 但是,主进程不是网页(它没有 Blink 集成),因此它没有 MessagePort
或 MessageChannel
类。 为了在主进程中处理 MessagePorts 并与之交互,Electron 添加了两个新类: MessagePortMain
和 MessageChannelMain
。
整体思路如下:
在主进程中设置MessageChannel让两个渲染进程产生联系
- src/main/index.ts
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.postMessage('portMain', null, [port1])
})
workWindow.once('ready-to-show', () => {
workWindow.webContents.postMessage('portWork', null, [port2])
})
在各自的进程中设置自己的MessagePort
- src/preload/index.ts
mainMessagePort: (callback:(msg:string) => void) => {
ipcRenderer.on('portMain', e => {
window.electronMainMessagePort = e.ports[0]
window.electronMainMessagePort.onmessage = messageEvent => {
callback(messageEvent.data)
}
})
},
mainMessagePortSend: (msg:string) => {
if (!window.electronMainMessagePort) return
window.electronMainMessagePort.postMessage(msg)
},
workMessagePort: (callback:(msg:string) => void) => {
ipcRenderer.on('portWork', e => {
window.electronWorkMessagePort = e.ports[0]
window.electronWorkMessagePort.onmessage = messageEvent => {
callback(messageEvent.data)
}
})
}
进程间通过prot通信
- src/render/pages/main/index.tsx
const mainSendMsgToWorkByMessagePort = () => {
window.electronAPI.mainMessagePortSend("Hello work, I am main,send by message port");
}
work 进程接收消息
- src/render/pages/work/index.tsx
window.electronAPI.workMessagePort((value) => {
setPortMsg(value)
})
通过上面三种情况的通信,我们基本上把 Electron 的进程通信方式都梳理清楚了。不过在使用的时候还是有一些问题需要注意和避免,下面我们来梳理一些常见问题。
IPC 使用注意事项
通信数据格式
Electron 的 IPC 实现使用 HTML 标准的 结构化克隆算法 来序列化进程之间传递的对象,这意味着只有某些类型的对象可以通过 IPC 通道传递。
特别是 DOM 对象(例如 Element
,Location
和 DOMMatrix
),Node.js 中由 C++ 类支持的对象(例如 process.env
,Stream
的一些成员)和 Electron 中由 C++ 类支持的对象(例如 WebContents
、BrowserWindow
和 WebFrame
)无法使用结构化克隆序列化。
通信频率
讨论这个问题的时候我们先看下进程见通信的流程:
渲染进程 -> 序列化数据 -> 进程间通信 -> 反序列化数据 -> 主进程
所以在这种情况下可能出现三个比较常见的问题:
- 事件循环阻塞
ipcMain.on('channel', (event, data) => {
// 大量消息处理导致主进程事件循环阻塞,一个非常耗时的处理
heavyProcessing(data)
})
如果主进程中在接收消息的时候有非常耗时的动作会导致事件循环阻塞,队列积压,无法正常消费。
一个例子
我们在渲染进程发起一个定时任务然后和主进程通信,但是主进程处理消息的速度明显比定时任务的执行频率低,代码如下。
渲染进程定时任务为 200 ms
const communicateWithEachOtherSendMsg = () => {
setInterval(() => {
window.electronAPI.communicateWithEachOtherSendMsg("Hello");
},200)
};
主进程处理任务需要 5 s
let count = 0;
ipcMain.on('communicateWithEachOtherSendMsg', (event:IpcMainEvent, msg:string) => {
count++;
const currentCount = count;
console.log(`收到消息 ${currentCount}, 时间: ${new Date().toLocaleTimeString()}`);
const sendDelayedMessage = () => {
return new Promise<string>((resolve) => {
setTimeout(() => {
resolve(`消息 ${currentCount} 完成,${msg}`);
}, 5000)
})
}
;(async () => {
console.log(`开始处理消息 ${currentCount}, 时间: ${new Date().toLocaleTimeString()}`);
const data = await sendDelayedMessage(); // 这里每个消息都会等5秒
console.log(`消息 ${currentCount} 处理完成, 时间: ${new Date().toLocaleTimeString()}`);
event.reply('communicateWithEachOtherReply', data);
})()
})
如果运行上面的代码就会出现如下情况,主进程挤压很多未完成的任务动作,随着时间的推移,堆积得越来越多,最后会导致整个程序运行的速度越来越慢,甚至崩溃。
在这种情况下,就需要特别注意数据返回的时间问题,必要的时候需要加锁。
- 内存泄漏
let accumulator = []
ipcMain.on('channel', (event, data) => {
accumulator.push(data) // 数据持续累积
})
如果 accumulator 数组的内容越来越多,很可能导致内存泄漏,然后让整个应用崩溃。
- 渲染进程卡顿
setInterval(() => {
// 频繁的 IPC 通信影响渲染性能
ipcRenderer.send('channel', data)
}, 16) // 低于屏幕刷新率
过高的频率发送消息导致渲染进程卡顿,如果低于屏幕刷新率会更加卡顿。
通信数据量
在一些应用场景下,我们可能会有大量数据需要在进程间传递。如果出现这种情况的话,首先想到的就是优化整体架构,避免出现大量数据在进程中流转的情况,因为大数据在进程中传递非常消耗来回转换的成本,而且会占用很多内存,所以如果出现这种情况我们应该尽可能的在需要获取数据的进程里面直接获取数据,因为 IPC 的设计大多时候还是适合指令集低数据量的场景设计的,如果实在避免不了可以考虑如下方案。
- 懒加载策略
// 渲染进程
async function loadDataByPage(page, pageSize) {
const result = await ipcRenderer.invoke('fetch-data', {
page,
pageSize,
filters: { /* 过滤条件 */ }
});
return result;
}
// 主进程
ipcMain.handle('fetch-data', async (event, params) => {
const { page, pageSize, filters } = params;
const start = (page - 1) * pageSize;
const end = start + pageSize;
// 从数据源获取部分数据
const data = await database.slice(start, end);
return {
items: data,
total: database.length
};
});
- GRPC 方案。
- webSocket 方案。
- 开启
nodeIntegration
,主进程写文件,渲染进程读取文件。 - 写本地数据库,渲染进程读数据库。
上面的这些方案核心概念都是将数据传输压力变小或者转移,相当于有一个中间商的概念,后期我会对上面的方案写出相应的实践方案和文章。
结语
通过探讨 Electron 的 IPC 机制,我们不仅掌握了其使用方法,更重要的是通过源码分析理解了其底层实现原理,理解原理让我们能够在遇到问题时快速定位根源,在设计方案时有更多的选择空间,在优化性能时知道突破口在哪里。这不仅仅是对 Electron IPC 的学习,更是对跨进程通信这一普遍性问题的深入认识,这些知识和经验将在未来的桌面应用开发中持续发挥价值。在日常开发中肯定会或多或少遇到一些类似的问题,我们需要转变普通的前端开发的思维,重新去审视 Electron 的架构才能做出最优雅的实践方案,最后祝大家可以找到适合自己业务场景的实践方案。
源码
https://github.com/Xutaotaotao/electron-proplay/tree/feature-ipc
开源项目
一站式Electron开发解决方案 Electron-Prokit:https://github.com/Xutaotaotao/electron-prokit