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 中,进程使用 ipcMainipcRenderer 模块,通过开发人员定义的“通道”传递消息来进行通信。 这些通道是 任意 (您可以随意命名它们)和 双向 (您可以在两个模块中使用相同的通道名称)的。


渲染器进程全程使用 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

♥ 赞助 ♥

尽管去做,或许最终的结果不尽人意,但你不付出,他不付出,那怎会进步呢?