前言
今天讲Electron框架的通信流程,首先我们需要知道为什么需要通信。这得益于Electron的多进程模型,它主要模仿chrome的多进程模型如下图:
作为应用开发者,我们将控制两种类型的进程:主进程和渲染器进程 。
主进程、渲染进程和Preload脚本
主进程
每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点。 主进程在 Node.js 环境中运行,这意味着它具有 require
模块和使用所有 Node.js API 的能力。
除此之外,主进程还可通过BrowserWindow类的每个实例创建一个应用程序窗口,且在单独的渲染器进程中加载一个网页。
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({ width: 800, height: 1500 })
win.loadURL('https://github.com')
const contents = win.webContents
console.log(contents)
渲染进程
每个 Electron 应用都会为每个打开的 BrowserWindow
( 与每个网页嵌入 ) 生成一个单独的渲染器进程。 洽如其名,渲染器负责 渲染 网页内容。 所以实际上,运行于渲染器进程中的代码是须遵照网页标准的 (至少就目前使用的 Chromium 而言是如此) 。
因此,一个浏览器窗口中的所有的用户界面和应用功能,都应与您在网页开发上使用相同的工具和规范来进行攥写。
此外,这也意味着渲染器无权直接访问 require
或其他 Node.js API。 为了在渲染器中直接包含 NPM 模块,您必须使用与在 web 开发时相同的打包工具 (例如 webpack
或 parcel
)
既然如此,那渲染器进程用户界面怎样才能与 Node.js 和 Electron 的原生桌面功能进行交互呢?这用到了Preload脚本
Preload脚本
预加载(preload)脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。 这些脚本虽运行于渲染器的环境中,却因能访问 Node.js API 而拥有了更多的权限。
预加载脚本可以在 BrowserWindow
构造方法中的 webPreferences
选项里被附加到主进程。
const { BrowserWindow } = require('electron')
// ...
const win = new BrowserWindow({
webPreferences: {
preload: 'path/to/preload.js'
}
})
// ...
因为预加载脚本与浏览器共享同一个全局 Window 接口,并且可以访问 Node.js API,所以它通过在全局 window
中暴露任意 API 来增强渲染器,以便你的网页内容使用。
// preload.js
window.myAPI = {
desktop: true
}
// renderer.js
console.log(window.myAPI)
// => undefined
语境隔离(Context Isolation)意味着预加载脚本与渲染器的主要运行环境是隔离开来的,以避免泄漏任何具特权的 API 到您的网页内容代码中。我们可以使用contextBridge方法安全的暴露api
// preload.js
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
desktop: true
})
// renderer.js
console.log(window.myAPI)
// => { desktop: true }
IPC通道
通过ipcMain和ipcRenderer模块可以创建任意 (您可以随意命名它们)和 双向 (您可以在两个模块中使用相同的通道名称)的通道。
模式 1:渲染器进程到主进程(单向)
通常使用此模式从 Web 内容调用主进程 API。 我们将通过创建一个简单的应用来演示此模式,可以通过编程方式更改它的窗口标题。
对于此演示,您需要将代码添加到主进程、渲染器进程和预加载脚本。 完整代码如下:
// main.js
const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
// 使用ipcMain.on监听事件
ipcMain.on('set-title', (event, title) => {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
// preload.js
const { contextBridge, ipcRenderer } = require('electron/renderer')
//通过预加载脚本暴露ipcRenderer.send
contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title)
})
// renderer.js
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
const title = titleInput.value
window.electronAPI.setTitle(title)
})
// index.html
// 构建渲染器进程UI
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Hello World!</title>
</head>
<body>
Title: <input id="title"/>
<button id="btn" type="button">Set</button>
<script src="./renderer.js"></script>
</body>
</html>
模式 2:渲染器进程到主进程(双向)
双向 IPC 的一个常见应用是从渲染器进程代码调用主进程模块并等待结果。 可以通过 ipcRenderer.invoke和 ipcMain.handle.实现。
在下面的示例中,我们将从渲染器进程打开一个原生的文件对话框,并返回所选文件的路径。完整代码如下:
// main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron/main')
const path = require('node:path')
async function handleFileOpen () {
const { canceled, filePaths } = await dialog.showOpenDialog()
if (!canceled) {
return filePaths[0]
}
}
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
// 使用ipcMain.handle监听事件
ipcMain.handle('dialog:openFile', handleFileOpen)
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
// preload.js
const { contextBridge, ipcRenderer } = require('electron/renderer')
// 预加载脚本暴露ipcRenderer.invoke
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile')
})
// renderer.js
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')
btn.addEventListener('click', async () => {
const filePath = await window.electronAPI.openFile()
filePathElement.innerText = filePath
})
// index.html
// 构建渲染器进程UI
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Dialog</title>
</head>
<body>
<button type="button" id="btn">Open a File</button>
File path: <strong id="filePath"></strong>
<script src='./renderer.js'></script>
</body>
</html>
模式 3:主进程到渲染器进程
将消息从主进程发送到渲染器进程时,需要指定是哪一个渲染器接收消息。主要通过WebContents实例和send方法实现
为了演示此模式,我们将构建一个由原生操作系统菜单控制的数字计数器。
对于此演示,您需要将代码添加到主进程、渲染器进程和预加载脚本。 完整代码如下:
// main.js
const { app, BrowserWindow, Menu, ipcMain } = require('electron/main')
const path = require('node:path')
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
// 使用webContents模块发送信息
click: () => mainWindow.webContents.send('update-counter', 1),
label: 'Increment'
},
{
// 使用webContents模块发送信息
click: () => mainWindow.webContents.send('update-counter', -1),
label: 'Decrement'
}
]
}
])
Menu.setApplicationMenu(menu)
mainWindow.loadFile('index.html')
// Open the DevTools.
mainWindow.webContents.openDevTools()
}
app.whenReady().then(() => {
ipcMain.on('counter-value', (_event, value) => {
console.log(value) // will print value to Node console
})
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
// preload.js
const { contextBridge, ipcRenderer } = require('electron/renderer')
// 通过预加载脚本暴露ipcRenderer.on
contextBridge.exposeInMainWorld('electronAPI', {
onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
counterValue: (value) => ipcRenderer.send('counter-value', value)
})
// renderer.js
const counter = document.getElementById('counter')
window.electronAPI.onUpdateCounter((value) => {
const oldValue = Number(counter.innerText)
const newValue = oldValue + value
counter.innerText = newValue.toString()
window.electronAPI.counterValue(newValue)
})
// index.html
// 构建渲染器UI
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Menu Counter</title>
</head>
<body>
Current value: <strong id="counter">0</strong>
<script src="./renderer.js"></script>
</body>
</html>
模式 4:渲染器进程到渲染器进程
没有直接的方法可以使用 ipcMain
和 ipcRenderer
模块在 Electron 中的渲染器进程之间发送消息。 为此,您有两种选择:
- 将主进程作为渲染器之间的消息代理。 这需要将消息从一个渲染器发送到主进程,然后主进程将消息转发到另一个渲染器。
- 通过MessagePort构建通信通道, 这将允许在初始设置后渲染器之间直接进行通信。
消息端口
简介
MessagePort是一个允许在不同上下文之间传递消息的Web功能。 就像 window.postMessage
, 但是在不同的通道上。
下面是 MessagePort 是什么和如何工作的一个非常简短的例子:
// renderer.js
// 消息端口是成对创建的。 连接的一对消息端口
// 被称为通道。
const channel = new MessageChannel()
// port1 和 port2 之间唯一的不同是你如何使用它们。 消息
// 发送到port1 将被port2 接收,反之亦然。
const port1 = channel.port1
const port2 = channel.port2
// 允许在另一端还没有注册监听器的情况下就通过通道向其发送消息
// 消息将排队等待,直到一个监听器注册为止。
port2.postMessage({ answer: 42 })
// 这次我们通过 ipc 向主进程发送 port1 对象。 类似的,
// 我们也可以发送 MessagePorts 到其他 frames, 或发送到 Web Workers, 等.
ipcRenderer.postMessage('port', null, [port1])
// main.js
// 在主进程中,我们接收端口对象。
ipcMain.on('port', (event) => {
// 当我们在主进程中接收到 MessagePort 对象, 它就成为了
// MessagePortMain.
const port = event.ports[0]
// MessagePortMain 使用了 Node.js 风格的事件 API, 而不是
// web 风格的事件 API. 因此使用 .on('message', ...) 而不是 .onmessage = ...
port.on('message', (event) => {
// 收到的数据是: { answer: 42 }
const data = event.data
})
// MessagePortMain 阻塞消息直到 .start() 方法被调用
port.start()
})
实例使用
在两个渲染进程之间建立 MessageChannel
在这个示例中,主进程设置了一个MessageChannel,然后将每个端口发送给不同的渲染进程。 这样可以让渲染进程彼此之间发送消息,而无需使用主进程作为中转。
// main.js
const { BrowserWindow, app, MessageChannelMain } = require('electron')
app.whenReady().then(async () => {
// 创建窗口
const mainWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadMain.js'
}
})
const secondaryWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadSecondary.js'
}
})
// 建立通道
const { port1, port2 } = new MessageChannelMain()
// webContents准备就绪后,使用postMessage向每个webContents发送一个端口。
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.postMessage('port', null, [port1])
})
secondaryWindow.once('ready-to-show', () => {
secondaryWindow.webContents.postMessage('port', null, [port2])
})
})
接下来,在你的预加载脚本中通过IPC接收端口,并设置相应的监听器
// preloadMain.js 和 preloadSecondary.js
const { ipcRenderer } = require('electron')
ipcRenderer.on('port', e => {
// 接收到端口,使其全局可用。
window.electronMessagePort = e.ports[0]
window.electronMessagePort.onmessage = messageEvent => {
// 处理消息
}
})
这意味着 window.electronMessagePort 在全局范围内可用,你可以在应用程序的任何地方调用postMessage
方法,以便向另一个渲染进程发送消息。
// renderer.js
// elsewhere in your code to send a message to the other renderers message handler
window.electronMessagePort.postMessage('ping')
Worker进程
在这个示例中,你的应用程序有一个作为隐藏窗口存在的 Worker 进程。 你希望应用程序页面能够直接与 Worker 进程通信,而不需要通过主进程进行中继,以避免性能开销。
// main.js
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')
app.whenReady().then(async () => {
// Worker 进程是一个隐藏的 BrowserWindow
// 它具有访问完整的Blink上下文(包括例如 canvas、音频、fetch()等)的权限
const worker = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true }
})
await worker.loadFile('worker.html')
// main window 将发送内容给 worker process 同时通过 MessagePort 接收返回值
const mainWindow = new BrowserWindow({
webPreferences: { nodeIntegration: true }
})
mainWindow.loadFile('app.html')
// 在这里我们不能使用 ipcMain.handle() , 因为回复需要传输
// MessagePort.
// 监听从顶级 frame 发来的消息
mainWindow.webContents.mainFrame.ipc.on('request-worker-channel', (event) => {
// 建立新通道 ...
const { port1, port2 } = new MessageChannelMain()
// ... 将其中一个端口发送给 Worker ...
worker.webContents.postMessage('new-client', null, [port1])
// ... 将另一个端口发送给主窗口
event.senderFrame.postMessage('provide-worker-channel', null, [port2])
// 现在主窗口和工作进程可以直接相互通信,无需经过主进程!
})
})
// worker.html
<script>
const { ipcRenderer } = require('electron')
const doWork = (input) => {
// 一些对CPU要求较高的任务
return input * 2
}
// 我们可能会得到多个 clients, 比如有多个 windows,
// 或者假如 main window 重新加载了.
ipcRenderer.on('new-client', (event) => {
const [ port ] = event.ports
port.onmessage = (event) => {
// 事件数据可以是任何可序列化的对象 (事件甚至可以
// 携带其他 MessagePorts 对象!)
const result = doWork(event.data)
port.postMessage(result)
}
})
</script>
// app.html
<script>
const { ipcRenderer } = require('electron')
// 我们请求主进程向我们发送一个通道
// 以便我们可以用它与 Worker 进程建立通信
ipcRenderer.send('request-worker-channel')
ipcRenderer.once('provide-worker-channel', (event) => {
// 一旦收到回复, 我们可以这样做...
const [ port ] = event.ports
// ... 注册一个接收结果处理器 ...
port.onmessage = (event) => {
console.log('received result:', event.data)
}
// ... 并开始发送消息给 work!
port.postMessage(21)
})
</script>
回复流
Electron的内置IPC方法只支持两种模式:即发即弃(例如, send
),或请求-响应(例如, invoke
)。 使用MessageChannels,你可以实现一个“响应流”,其中单个请求可以返回一串数据。
// renderer.js
const makeStreamingRequest = (element, callback) => {
// MessageChannels 是轻量的
// 为每个请求创建一个新的 MessageChannel 带来的开销并不大
const { port1, port2 } = new MessageChannel()
// 我们将端口的一端发送给主进程 ...
ipcRenderer.postMessage(
'give-me-a-stream',
{ element, count: 10 },
[port2]
)
// ... 保留另一端。 主进程将向其端口发送消息
// 并在完成后关闭它
port1.onmessage = (event) => {
callback(event.data)
}
port1.onclose = () => {
console.log('stream ended')
}
}
makeStreamingRequest(42, (data) => {
console.log('got response data:', data)
})
// 我们会看到 "got response data: 42" 出现了10次
// main.js
ipcMain.on('give-me-a-stream', (event, msg) => {
// 渲染进程向我们发送了一个 MessagePort
// 并期望得到响应
const [replyPort] = event.ports
// 在这里,我们同步发送消息
// 我们也可以将端口存储在某个地方,异步发送消息
for (let i = 0; i < msg.count; i++) {
replyPort.postMessage(msg.element)
}
// 当我们处理完成后,关闭端口以通知另一端
// 我们不会再发送任何消息 这并不是严格要求的
// 如果我们没有显式地关闭端口,它最终会被垃圾回收
// 这也会触发渲染进程中的'close'事件
replyPort.close()
})
总结:
- 主进程和渲染器进程之间存在语境隔离
- 主进程和渲染器进程可通过electron内置IPC通道进行通信
- 渲染器之间可通过IPC通道,主进程为中间人互相通信;也可通过信息端口构建信道直接进行通信