Electron底层基于Chromium浏览器,因此也继承了其多进程架构。下图是Chromium的进程模型,浏览器的每个标签页都运行有自己的渲染进程,这能够避免网页上有误或恶意的代码对整个应用程序造成损害。
Electron中也对应分为「主进程」和「渲染进程」。通常情况下,主进程不能操作DOM,渲染进程也不能直接访问主进程的函数。此外实际开发中我们会发现,主进程和渲染进程的控制台输出也不在一起,渲染进程的控制台输出信息在Chromium的开发者工具中,而主进程的输出在IDE控制台上。
Electron中主进程是程序的入口点,主进程在NodeJS环境中运行,它能够使用所有NodeJS API。主进程主要负责:
Electron中每个BrowserWindow
都有单独的渲染进程,它其实就是运行在一个浏览器页面中,因此运行于渲染进程中的代码应该是遵照网页开发标准的。
渲染进程虽然不能直接访问NodeJS API操作本地内容,但Electron允许通过进程通信方式间接调用本地功能。
Electron中,预加载(Preload)脚本用于在渲染进程中提供额外的功能和访问权限,预加载脚本的主要用途就是将Electron的主进程和渲染进程桥接到一起,这涉及到ipcMain
和ipcRenderer
。
Electron中,ipcMain
和ipcRenderer
模块用于进程间通信。
ipcMain:用于监听和处理来自渲染进程的消息。
ipcRenderer:用于给主进程发送消息或是监听主进程发来的消息。
Electron提供了ipcMain
、ipcRenderer
模块用于处理进程间通信的场景,我们可以使用这两个模块在进程之间发送消息或是进行跨进程的函数调用。
此外我们要知道,由于Electron采用了多进程架构,进程间通信使用序列化机制在进程间传递数据,因此只有可被序列化的对象能够在IPC中进行传递,除此之外的例如DOM对象、NodeJS中由C++类支持的对象(process.env、Stream)等是无法进行序列化传递的。
下面我们介绍Electron中常用的跨进程通信方式。
下面例子中,我们使用预加载脚本和ipcMain
、ipcRenderer
实现渲染进程向主进程发送消息。代码逻辑十分简单,总体来说就是渲染进程使用ipcRenderer.send()
发送消息,主进程使用ipcMain.on()
接收消息。
preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
printMsg: (msg) => ipcRenderer.send('print-msg', msg)
});
上面预加载脚本调用了contextBridge.exposeInMainWorld('electronAPI', {})
方法,这会在渲染进程window
对象上增加一个electronAPI
对象,渲染进程代码中可以通过window.electronAPI.printMsg()
访问预加载脚本中的printMsg()
方法。
main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const createWindow = () => {
// 创建窗口
const window = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
});
ipcMain.on('print-msg', (event, msg) => {
console.log(msg);
});
// 加载HTML文件
window.loadFile('index.html');
};
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
})
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
主进程中,在创建BrowserWindow
时,我们传了webPreferences
属性,其中preload
用于指定预加载脚本的路径,这里注意预加载脚本必须使用绝对路径方式指定,因此这里我们使用path.join(__dirname, 'preload.js')
来指定。
此外代码中我们使用了ipcMain
来监听消息,收到消息并打印到控制台上。
app.js
window.addEventListener('load', () => {
window.electronAPI.printMsg('Hello.');
});
渲染进程代码中,我们直接使用window
对象调用暴露的接口即可。
上面编写的代码我们可以认为是单向的从渲染进程到主进程。实际上,主进程收到消息后,还可以调用event.reply()
回复消息,渲染进程用ipcRenderer.on()
接收,但这种方式比较少用而且可读性不佳。对于双向通信,建议使用下面介绍的invoke
和handle
方式。
前面我们介绍了如何从渲染进程发送消息给主进程,实际上对于比较复杂的双向通信,建议使用invoke
和handle
这组API。渲染进程以函数方式调用主进程,可以使用ipcRenderer.invoke()
和ipcMain.handle()
实现,下面是一个例子。
preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
printMsg: (msg) => ipcRenderer.invoke('print-msg', msg)
});
预加载脚本中,我们给渲染进程暴露了printMsg()
方法,它接收1个参数msg
,并使用ipcRenderer.invoke()
调用主进程,以及将msg
参数传递给主进程。
这里要注意,ipcRenderer.invoke()
返回值是Promise
,它是采用异步方式调用的,因此我们获取主进程的返回值时需要使用异步写法,比如promise.then()
或是现在更推荐的async/await
。
main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const createWindow = () => {
// 创建窗口
const window = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
});
ipcMain.handle('print-msg', (_event, msg) => {
console.log(msg);
return 'Message received!';
});
// 加载HTML文件
window.loadFile('index.html');
};
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
})
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
主进程中,我们主要关注ipcMain
的使用,这里我们调用了ipcMain.handle()
接收函数调用并输出一个返回值,注意handle()
的第1个参数是事件对象,我们真正传递的参数实际上在其之后。
app.js
window.addEventListener('load', async () => {
const reply = await window.electronAPI.printMsg('Hello.');
console.log(reply);
});
渲染进程中,我们调用window.electronAPI.printMsg()
函数,得到主进程返回值后,将其打印在渲染进程控制台上。前面提到ipcRenderer.invoke()
返回值为Promise
,因此我们这里定义了异步函数,并使用await
等待主进程的返回值。
Electron中主进程可以向渲染进程发送消息,但要注意的是主进程和渲染进程是一对多的关系,因此主进程需要指定发送消息到哪一个渲染进程,然后调用对应窗体的webContents.send()
。主进程向渲染进程发送消息的一个主要使用场景就是将用户对窗体的一些操作反馈到渲染进程,比如点击了窗体的菜单项。
preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
onClickMenu: (callback) => ipcRenderer.on('click-menu', callback)
});
预加载脚本代码中,我们给渲染进程暴露了onClickMenu()
函数,它接收一个回调函数作为参数,主进程向渲染进程发送click-menu
消息时,该回调函数被调用。
main.js
const { app, BrowserWindow, Menu } = require('electron');
const path = require('path');
const createWindow = () => {
// 创建窗口
const window = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
});
// 创建菜单项
const menu = Menu.buildFromTemplate([
{
label: '文件',
submenu: [
{
label: '点击',
click: () => window.webContents.send('click-menu', 'hello')
}
]
}
]);
Menu.setApplicationMenu(menu);
// 加载HTML文件
window.loadFile('index.html');
};
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
})
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
主进程中我们给窗体定义了菜单,菜单的文件->点击
菜单项被点击时,调用了window.webContents.send()
函数,它向window
窗体的渲染进程发送消息。
app.js
window.electronAPI.onClickMenu((event, msg) => {
console.log(msg);
});
渲染进程中,我们使用预加载脚本暴露的onClickMenu()
函数,并将接收到的消息打印到控制台上。