Electron主进程与渲染器进程、预加载脚本、进程间通信
Hi I'm Shendi.
这里开始记录我对Electron的了解,结合官方文档编写。
建议先阅读官网的快速入门:https://www.electronjs.org/zh/docs/latest/tutorial/quick-start
多进程模型
Electron 继承了来自 Chromium 的多进程架构,这使得此框架在架构上非常相似于一个现代的网页浏览器。
Electron 分为 主进程
与 渲染进程
,渲染进程可以理解为是一个单独的浏览器,可以加载url,html等,其编写等同于web开发,而主进程是整个程序的入口。
主进程
每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点。 主进程在 Node.js 环境中运行,这意味着它具有 require
模块和使用所有 Node.js API 的能力。
主进程的主要目的是创建和管理应用程序窗口,使用 BrowserWindow
。
可以通过 app
模块来控制应用程序的生命周期。
例如,当窗口右上角的关闭按钮被点击,那么关闭整个程序。
只有当所有窗口被关闭后整个程序才会退出,包括隐藏的窗口。所以,通常会对主窗口进行下面的操作
const { app,BrowserWindow } = require('electron');
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 600,
height: 480,
});
win.on('closed', (e) => {
console.log("关闭整个程序");
app.quit();
});
});
在 Electron 中,只有在 app
模块的 ready
事件被激发后才能创建浏览器窗口。可以通过 app.whenReady()
来监听。
渲染器进程
每个 Electron 应用都会为每个打开的 BrowserWindow
( 与每个网页嵌入 ) 生成一个单独的渲染器进程。 洽如其名,渲染器负责 渲染 网页内容。 所以实际上,运行于渲染器进程中的代码是须遵照网页标准的 (至少就目前使用的 Chromium 而言是如此) 。
使用Electron编写软件——简单的编写流程
这里简单概述一下编写一个软件需要使用到的基本的部分(大抵是废话)。
程序从主进程开始,主进程从 package.json 中指定的 main 开始。
主进程编写需要非界面部分的代码,窗口的管理等,可以理解为后端(Node),而窗口可以理解为Web前端。
这两部分是隔离开来的,所以需要通过一些方式来建立联系,主进程创建管理窗口,而窗口加载的是一个html(Web),那么在这个窗口注入js脚本就可以了,Electron对此提供了一个叫预加载(preload)脚本的东西,通过这个脚本,可以实现主进程与渲染器进程的下相互通信(IPC),比如窗口中有一个按钮,点击这个按钮就打开一个新窗口,对于这种基本的需求皆需要使用到IPC,因为窗口只能在主进程打开。
预加载脚本(Preload)
预加载(preload)脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。 这些脚本虽运行于渲染器的环境中,却因能访问 Node.js API 而拥有了更多的权限。
预加载脚本可以说是主进程与渲染器进程之间的桥梁,进程间通信(IPC)也需要通过预加载脚本得以实现。
除此之外,还可以用其做一些页面自动化操作之类的,就相当于在页面中嵌入js脚本。
const { BrowserWindow } = require('electron')
// ...
const win = new BrowserWindow({
webPreferences: {
preload: 'path/to/preload.js'
}
});
// ...
上面的 preload
的值即预加载脚本文件的位置,使用相对路径,例如目录结构是这样的。
|-main.js
|-js
|--preload.js
上述代码在 main.js
中,那么 preload
的值应为 js/preload.js
因为预加载脚本与浏览器共享同一个全局 Window
接口,并且可以访问 Node.js API,所以它通过在全局 window
中暴露任意 API 来增强渲染器,以便你的网页内容使用。
虽然预加载脚本与其所附着的渲染器在共享着一个全局 window
对象,但您并不能从中直接附加任何变动到 window
之上,因为 contextIsolation
(语境隔离) 是默认的。
也就是说,预加载脚本对渲染器进程中的对象的修改都将是无用的,有隔离。
这里指的是 js 中对象的修改,例如在预加载脚本中
window.myAPI = {
desktop: true
};
在渲染器脚本中
console.log(window.myAPI);
// undefined
但 Electron 提供了 contextBridge
来安全的实现交互
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
desktop: true
});
渲染器脚本中
console.log(window.myAPI);
// => { desktop: true }
预加载脚本是在渲染器进程中,所以当渲染器进程页面刷新,那么预加载脚本也会跟着重新执行。
(等于在页面最开头引入的js,但优先于页面加载之前)
进程间通信(IPC)
在 Electron 中,进程使用 ipcMain
和 ipcRenderer
模块,通过开发人员定义的“通道”传递消息来进行通信。 这些通道是 任意 (您可以随意命名它们)和 双向 (您可以在两个模块中使用相同的通道名称)的。
渲染器进程全程使用 ipcRender
,而主进程使用 ipcMain
。
对于渲染器进程,应该预加载脚本中使用IPC,然后将函数提供给渲染器进程。
出于 安全原因,我们不会直接暴露整个
ipcRenderer
API。 确保尽可能限制渲染器对 Electron API 的访问。
渲染器进程到主进程
从渲染器进程中,发送消息到主进程,例如点击某个按钮...
单向,通过 send
函数。预加载脚本中,代码如下
const { contextBridge, ipcRenderer } = require('electron');
// ipcRenderer.send('事件名称', 参数列表是可变参数);
// 因为当前是在预加载脚本中,通常是在渲染器进程中的执行,所以要将接口暴露出去,这里就给 window 增加一个 api
contextBridge.exposeInMainWorld('api', {
setTitle: (title) => ipcRenderer.send('set-title', title)
});
上面的代码给渲染器脚本的 window 增加了一个 api
对象,而其中有一个函数 setTitle
,函数接收一个字符串title
,在渲染器的脚本中,执行下面这样的代码即可调用
window.api.setTitle("新的窗口名称 - 砷碲");
现在已经将发送事件做好了,通道名称为 set-title
,在主进程应当需要监听此事件,通过 on
函数
const { app, BrowserWindow, ipcMain } = require('electron');
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
});
mainWindow.loadFile('index.html');
ipcMain.on('set-title', (event, title) => {
mainWindow.setTitle(title);
});
- ipc监听函数中,第一个参数皆为
event
,包含此事件的一些信息,后面的是传递的参数,顺序和数量与传递的是一致的。
双向,通过 invoke
。预加载脚本中,代码如下
// ipcRenderer.invoke('事件名称', 参数列表是可变参数); 函数返回Promise
contextBridge.exposeInMainWorld('api', {
setTitle: (title) => ipcRenderer.invoke('set-title', title)
});
主进程通过 handle
监听此事件,并返回一个 Promise
(也可以函数是async,通过return值的方式)。
ipcMain.handle("set-title", (event, title) => {
return new Promise((resolve, reject) => {
// 操作...
// 将传递的title原封不动的返回
resolve(title);
});
});
在渲染器进程使用是这样的
// 返回的是Promise,也可以使用 async await 的方式
window.api.setTitle("test").then(function (title) {
console.log(title);
});
主进程到渲染器进程
同 渲染器进程到主进程
,只不过方向需要调转。
单向
例如设置html中的 title
在渲染器进程中使用 on
来监听。
// ipcRenderer.on('事件名称', (_event, 参数列表) => { 当触发事件后此处执行 })
contextBridge.exposeInMainWorld('api', {
setTitle: (callback) => ipcRenderer.on('set-title', (event, title) => { callback(title); })
});
在主进程通过窗口对象的 webContents.send
来发送事件
const mainWindow = new BrowserWindow({ /*...*/ });
mainWindow.webContents.send("set-title", "网页标题 - 砷碲");
在渲染器进程中使用预加载脚本提供的接口监听。
window.api.setTitle((title) => {
document.title = title;
});
双向
对于从主进程到渲染器进程的 IPC,没有与 ipcRenderer.invoke
等效的 API。但是可以在触发 on
后,使用 send
发送事件达到相似效果。
多个渲染器进程到主进程
这是我使用中遇到的一个问题,当时并没有搜到一个良好的解决办法,以及通过询问等,最终尝试自己解决。
举个场景的例子:用户列表,每个用户后面有一个发送邮件的按钮,点击后弹出一个新窗口用以编写发送邮件。在发送完后关闭窗口。
这样的例子,点击发送邮件,使用渲染器到主进程就可以了,但是打开的窗口中,发送邮件后,如何关闭窗口呢?难点就在这,主进程无法得知触发事件的是哪一个窗口。
在编写这篇文章发现了官网文档最开始的主进程中 event.sender,可以用其得到当前 BrowserWindow,不知是否可行,这里展示出来,但我使用的是另一种方法。
function handleSetTitle (event, title) { const webContents = event.sender const win = BrowserWindow.fromWebContents(webContents) win.setTitle(title) }
要想知道是哪个窗口,其实很简单,但繁琐。
我的解决办法是这样的:
对于上面的例子,主进程需要有三个事件,一个是打开新窗口,一个是绑定,一个是关闭窗口。
在主进程事件触发,新打开一个窗口时,生成一个唯一id,将id与窗口存入一个Array,将id发送,然后在窗口的预加载脚本中,最开始监听到id并发送绑定事件,主进程的绑定事件中,event
有一个属性 processId
,这个东西可以用来确定当前是哪个窗口,每个窗口是不一样的,将其存入。然后下次就可以通过这个processId
来进行具体操作了。
主进程伪代码如下
const { app, BrowserWindow, ipcMain } = require('electron');
app.whenReady().then(() => {
// 因为不会重复,所以使用Object,需要有两个Object,一个是创建时随机id对应窗口,一个是 processId对应窗口
// 绑定后 {processId : {browser:'窗口', uid:'用户id'}}
let windows = {},
// 创建时 {id : {browser:'窗口', uid:'用户id'}}
bindIds = {};
// 打开新窗口,uid可以代表用户id
ipcMain.handle("open", (event, uid) => {
return new Promise((resolve, reject) => {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
});
// node是单线程,用时间戳作为窗口唯一id问题不大。
let id = new Date().getTime();
// 将窗口存入
bindIds[id] = {
browser : mainWindow,
uid : uid
// 除此之外,还可以将resolve,reject等存入
};
// 发送id给预加载脚本
mainWindow.webContents.send("id", id);
// 窗口开始加载
mainWindow.loadFile('index.html');
});
});
// 绑定,这里的id是预加载脚本监听到的id
ipcMain.on("bind", (event, id) => {
let val = bindIds[id];
if (val) {
delete bindIds[id];
windows[event.processId] = val;
}
});
// 这里是具体业务操作,如发送邮件完成,关闭窗口
ipcMain.on("ok", (event) => {
let val = windows[event.processId];
delete windows[event.processId];
// 因为在不同进程,所以可能会有多次调用导致报错,这里直接try了
try {
val.browser.close();
} catch (e) {console.log(e);}
});
});
而预加载脚本中,代码如下
const { contextBridge, ipcRenderer } = require('electron');
// 拿到id后将id发送给主进程进行绑定,没有id则代表页面刷新,忽略
ipcRenderer.on("id", (_event, id) => {
ipcRenderer.send("bind", id);
});
// 具体业务代码,如判断输入,发送邮件...
// 也可以将其暴露给渲染器进程...
// 发送成功,让主进程关闭当前窗口
ipcRenderer.send("ok");
END
本文链接:https://sdpro.top/blog/html/article/1238.html♥ 赞助 ♥
尽管去做,或许最终的结果不尽人意,但你不付出,他不付出,那怎会进步呢?