大概是全网最详细的Electron ipc 讲解(二)——渲染进程与渲染进程的搭桥牵线

希沃ENOW大前端

公司官网:CVTE(广州视源股份)

团队:CVTE旗下未来教育希沃软件平台中心enow团队

本文作者:

image.png

前言

你盼世界,我盼望你无 bug 。Hello 大家好!我是霖呆呆。

在上一章节中我们介绍了主进程和渲染进程之间互相通信的实现方式,那么对于渲染进程与渲染进程之间呢?他们相互之间传递消息是怎样做的呢?本期就让我们一起来看看吧。

本系列共有以下几个章节:

  • 主进程与渲染进程的两情相悦
  • 渲染进程与渲染进程的搭桥牵线
  • 定情信物传声筒port

您此次阅读的是第一章节:主进程与渲染进程的两情相悦。

注:以上所有文章都被归档到: github.com/LinDaiDai/n… 中 ,案例都上传至:github.com/LinDaiDai/e… ,欢迎 Star,感谢 Star。

案例版本信息:

  • electron: v13.6.7
  • Nodejs: v16.13.2

大纲

事实上,渲染进程与渲染进程的通信会麻烦很多。

首先我们知道一个 Electron 项目中,主进程只能有一个,而渲染进程可以有多个。那么如果你要在渲染进程与渲染进程中发送消息的话,就得先知道要给哪个渲染进程发送消息,这里大家肯定都想到了,应该是会有一个 id 这样的概念,否则根本分不清谁是谁。

其次,每个渲染进程的 id 又该去哪里拿呢?全局变量?主进程?

OKK,被我扯得好像很复杂的样子。不慌,让我们来看看 IPC 模块可以有哪些方法实现渲染进程与渲染进程通信:

  • ipcRenderer.sendTo(webContentsId, channel, ...args) : 使用 ipcRenderer 提供的 sendTo 方法,指定要给哪个渲染进程(webContentsId)发送消息;
  • window.webContents.send :主进程保存所有渲染进程的 webContents 对象,同时主进程拥有接收渲染进程消息的能力,那么主进程就可以充当中间人的角色,使渲染进程之间能够通信;

1. ipcRender.sendTo 案例

在正式介绍这种通信方式之前,让我们先来确定下要做什么事:

1、期望主进程能提供以下能力:

  • 创建窗口的能力;
  • 创建了窗口能将新窗口的 id 存储下来,并对这些窗口 id 进行维护;
  • 给渲染进程提供获取某个窗口 id 的能力

2、创建并打开两个窗口(渲染进程),方便调试

3、通过 sendTo 方法让窗口一给窗口二发送消息

整个时序过程如下图所示:

image.png

(哇!你是真的棒,还画时序图!……啪!)

好的,明确了我们接下来要做什么,那就开动吧。相信经过此案例大家应该就能理解 sendTo 是怎样用的了。

image.png

第一步、调整目录结构

由于本章节讲解的是渲染进程与渲染进程进行通信,所以为了方便大家理解,让我们来创建两个窗口(window-onewindow-two),并将项目目录调整成:

image.png

由于主进程被放进了 src/main/index.js 中,所以 package.json 那边的入口也要记得修改哦:

{
-  "main": "src/main.js",
+  "main": "src/main/index.js",
}
复制代码

第二步、主进程提供创建窗口以及存储窗口 id 的能力

按照上面说的,先让我们来实现主进程的代码逻辑。为了让各部分职责更清晰,我们定义另外两个文件用来处理它们各自的内容:

image.png

main.js 主进程逻辑代码;

createWindow.js 封装了创建窗口的公共方法;

windowManager.js 维护所有窗口 id 的能力。

OKK,相信大家也和我一样觉得创建不同窗口这件事本身就有一些重复的逻辑,那我们就将这部分内容封装一下吧:

createWindow.js

const { BrowserWindow } = require('electron')

exports.createWindow = function (params) {
  const { name, width, height, loadFileUrl } = params;
  const window = new BrowserWindow({
    name,
    width,
    height,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    },
  })

  window.loadFile(loadFileUrl)

  window.webContents.on('did-finish-load', () => {
    // do something
  })

  window.webContents.on('destroyed', () => {
    // do something
  })
}
复制代码

窗口名称、大小、路径等信息都通过 createWindow 的参数传递进来。

然后依照 windowManager.js 的职责,我们也来简单的写下代码:

const { ipcMain } = require('electron')

// 存储所有窗口的 id
const windowIdMap = {}

// 注册窗口
exports.registerWindowId = function(key, value) {
    windowIdMap[key] = value;
    console.log('registerWindowId', windowIdMap);
}

// 销毁窗口
exports.removeWindowId = function(key) {
    delete windowIdMap[key];
    console.log('removeWindowId', windowIdMap);
}

// 获取某个窗口 id
ipcMain.on('getWindowId', (event, arg) => {
    console.log('getWindowId', arg);
    event.returnValue = windowIdMap[arg];
})
复制代码

根据它的职责,我们定义了一个最简版本的windowManager :注册、销毁、和获取指定窗口 id 的三个功能。

注册和销毁方法都好理解,无非就是对 windowIdMap 对象进行增改。

那么【获取指定窗口 id 】为啥这么写呢?因为一般要拿某个窗口的 id 肯定是渲染进程做的事,所以这里我使用了 ipcMain.on 来监听 "getWindowId" 事件,渲染进程只需要通过上面我们提到的 ipcRenderer.sendSync 方法来给主进程发消息获取就可以了。

接着我们将 windowManagercreateWindow 结合起来吧:我期望在某个窗口创建的时候,自动给 windowManager 中注册窗口 id ;某个窗口销毁时也销毁 windowManager 中的窗口 id

createWindow.js 进行改造:

const { BrowserWindow } = require('electron')
// 一、引入 windowManager
const { registerWindowId, removeWindowId } = require('./windowManager');

exports.createWindow = function (params) {
  const { name, width, height, loadFileUrl } = params;
  const window = new BrowserWindow({
    name,
    width,
    height,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    },
  })

  window.loadFile(loadFileUrl)

  window.webContents.on('did-finish-load', () => {
    // 二、注册窗口id
    registerWindowId(name, window.webContents.id);
  })

  window.webContents.on('destroyed', () => {
    // 三、销毁窗口 id
    removeWindowId(name);
  })
}
复制代码

这里我是监听每个窗口的 did-finish-load 事件,在这个事件回调中调用了注册窗口的方法,并且将窗口的 name 作为 key 值,将 window.webContents.id 作为 value 值。

由于后面渲染进程通过 sendTo 方法给指定的窗口发送消息是需要知道那个窗口的 window.webContents.id ,所以才这么设计。

哈哈哈,有了以上步骤,创建窗口并且注册窗口 id 这件事就变得智能了,让我们接着往下看。

image.png

第三步、编写主进程创建两个窗口的代码逻辑

通用的代码都已经封装好了,然后让我们把它运用到主进程里吧:

const { app, BrowserWindow } = require('electron')
const { createWindow } = require('./createWindow');

function createWindowOne() {
  createWindow({
    name: 'one',
    width: 1000,
    height: 800,
    loadFileUrl: 'src/windows/window-one/index.html',
  })
}

function createWindowTwo() {
  createWindow({
    name: 'two',
    width: 800,
    height: 600,
    loadFileUrl: 'src/windows/window-two/index.html',
  })
}

app.whenReady().then(() => {
  createWindowOne();
  createWindowTwo();
});

// 以下省略 app 监听 'window-all-closed' 和 'activate' 的逻辑
复制代码

当项目启动之后,通过调用 createWindow ,创建并打开两个渲染进程窗口 window-onewindow-two ,并且传入相应的参数。

此时, windowManager 中的 windowIdMap 应该是变成了:

{
  'one': 1,
  'two': 2,
}
复制代码

如果渲染进程调用 ipcRenderer.sendSync('getWindowId', 'two') 得到的结果应该是: 2 。OKK,是符合我们预期的。

第四步、编写渲染进程代码逻辑

主进程的代码完成了,渲染进程这边就简单了。

我们只要简单写下 window-onewindow-twohtml ,再实现 window-onewindow-two 发消息就行了。

window-one/renderer.js

const { ipcRenderer } = require('electron');

function sendToWindowTwo() {
    const windowOneId = ipcRenderer.sendSync('getWindowId', 'two');
    console.log('windowOneId', windowOneId);
    ipcRenderer.sendTo(windowOneId, 'windowOne-send-to-windowTwo', '窗口1通过 sendTo 给窗口2发送消息');
}
复制代码

window-one.html

<body>
    <h1>Window One</h1>
    <button onclick="sendToWindowTwo()">窗口1通过 sendTo 给窗口2发送消息</button>
    <script src="./renderer.js"></script>
</body>
复制代码

窗口一中,当点击按钮的时候,调用 sendToWindowTwo 方法,该方法中会先通过 sendSync 向主进程拿到 window-two 的窗口 id ,再通过 ipcRenderer.sendTowindow-two 发送消息。

对于窗口二:

window-two/renderer.js

const { ipcRenderer } = require('electron');

ipcRenderer.on('windowOne-send-to-windowTwo', (event, arg) => {
    console.log('receive:', arg);
})
复制代码

window-two.html

<body>
    <h1>Window Two</h1>
    <script src="https://juejin.cn/post/renderer.js"></script>
</body>
复制代码

窗口二单纯的监听 windowOne-send-to-windowTwo 事件,接收返回值即可。

OKK,完成了以上种种,让我们来看看效果吧:

image.png

如上图所示,点击窗口一的按钮,可以成功给窗口二发送消息了。

可以看到,上面这种方式其实是依赖于主进程的,因为渲染进程需要从它那去拿取其它渲染进程的 id

当然你也可以通过其它的方式来实现这一步骤,例如将这段 id 映射的关系保存在一个全局变量中,每次从中去取即可,不过要注意对这些映射的更新哦,因为每次渲染进程的创建与销毁,产生的 id 都是不一样的,但是带来的好处就是每次通信可以少给主进程发送一次消息请求。当然,小伙伴们有兴趣可以自己试一下,本文就不再展开了。

image.png

2. window.webContents.send 案例

上面已经介绍了是如何通过 ipcRenderer.sendTo  实现渲染进程之间的通信,那么再让我们来看看第二种方式,通过 window.webContents.send

这种方式也需要依赖到主进程,而且比第一种更依赖…

大致的实现方式是:

1、每次新产生渲染进程的时候,都将渲染进程的 window.webContents 对象保存在主进程中;

2、渲染进程一想要给渲染进程二发消息的时候,就通知主进程,同时附带上事件名(消息名)和参数(消息内容);

3、主进程找到渲染进程二的 window.webContents ,通过它的 send 方法给渲染进程二自己发送消息;

4、渲染进程二那边用 ipcRenderer.on 来接收即可。

了解了大致的实现方式之后,让我们来看看各个部分都应该具备哪些能力:

1、期望主进程能提供以下能力:

  • 创建窗口的能力;
  • 创建了窗口能将新窗口的 window.webContents 存储下来,并对这些窗口 window.webContents 进行维护;
  • 给渲染进程提供通知要发送消息给其它渲染进程的能力;

2、渲染进程需要提供发送消息给主进程的能力,以及接收消息的能力。

好的嘞,明确了实现方式,也明确了各部分的职责,我们再次动手吧。

第一步、主进程提供存储 window.webContents  的能力

先来看看主进程,主进程创建窗口以及存储 window.webContens 我们可以参考上面一种方式的案例来实现,项目目录保持不变:

image.png

主进程的也不变,不过 windowManager.js 这个脚本我们需要改造一下,之前是存储每个窗口的 id ,现在需要存储它们的 webContents

(呆呆给文件命名就是厉害,windowManager.js 脚本这名字多么通用,都可以不用改名了,你说我要是取个 windowIdManagerid 相关的,在这个案例中我可能还得改成 windowContentsManager 了)

// windowManager.js
const { ipcMain } = require('electron')

const windowContentsMap = {}

exports.registerWindowContents = function(key, value) {
    windowContentsMap[key] = value;
    console.log('registerWindowContents', windowContentsMap);
}

exports.removeWindowContents = function(key) {
    delete windowContentsMap[key];
    console.log('removeWindowContents', windowContentsMap);
}

function getWindowContents(key) {
    return windowContentsMap[key];
}

/**
 * event: 事件对象
 * params: { channel: string; targetWindow: string; data: any }
 * channel: 事件名; targetWindow: 目标窗口的唯一标识; data: 要传递的内容
 */
ipcMain.on('renderer-send-to-renderer', (event, params) => {
    console.log('renderer-send-to-renderer', params);
    const { channel, targetWindow, data } = params;
    const contents = getWindowContents(targetWindow);
    if (contents) {
        contents.send(channel, data);
    } else {
        console.error('targetWindow non-existent');
    }
})
复制代码

上面的代码也很简单,我们将 windowIdMap 改成了 windowContentsMap ,用来存储 windowContents 对象。

同时 ipcMain 还要监听一个名为 renderer-send-to-renderer 的事件,在这个事件里,我们实现给目标窗口发送消息的功能。

第二步、改动创建窗口的逻辑

好的,再改动一下 createWindow.js 里注册和移除的逻辑即可:

const { BrowserWindow } = require('electron')
// 一、引入 windowManager
const { registerWindowContents, removeWindowContents } = require('./windowManager');

/**
 * 公共的创建窗口的方法
 * @param {*} params 参数
 */
exports.createWindow = function (params) {
  const { name, width, height, loadFileUrl } = params;
  const window = new BrowserWindow({
    name,
    width,
    height,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    },
  })

  window.loadFile(loadFileUrl)

  // 二、注册 webContents
  window.webContents.on('did-finish-load', () => {
    registerWindowContents(name, window.webContents);
  })
  // 三、销毁 webContents
  window.webContents.on('destroyed', () => {
    removeWindowContents(name);
  })
}
复制代码

注册的时候,不再传入 id,而是 window.webContents

image.png

第三步、实现渲染进程发送和接收消息的逻辑

咦,完成了主进程的实现,接下来渲染进程那边是不是也很好搞了,来一起看看:

window-one/renderer.js

const { ipcRenderer } = require('electron');

function sendToWindowTwo() {
    ipcRenderer.send('renderer-send-to-renderer', {
        channel: 'windowOne-send-to-windowTwo',
        targetWindow: 'two',
        data: '窗口1通过 sendTo 给窗口2发送消息',
    });
}
复制代码

这次,我们只需要给主进程发送一次 renderer-send-to-renderer 消息就可以了,同时把其余的内容放到参数中传递即可。例如我想要通知窗口二,你要执行 windowOne-send-to-windowTwo 的事件,那么就把这个事件名放到 channel 里。

而对于窗口二,我们需要监听 windowOne-send-to-windowTwo 事件:

const { ipcRenderer } = require('electron');

ipcRenderer.on('windowOne-send-to-windowTwo', (event, arg) => {
    console.log('event:', event);
    console.log('receive:', arg);
})
复制代码

此时,来查看一下效果吧,也是我们预期的呢。

image.png

GOOD~

总结

OKK,介绍完了功能,分析完了案例,让我们来加以总结一下吧。

渲染进程之前通信:

  • ipcRenderer.sendTo(webContentsId, channel, ...args) : 使用 ipcRenderer 提供的 sendTo 方法,指定要给哪个渲染进程(webContentsId)发送消息;
  • window.webContents.send :主进程保存所有渲染进程的 webContents 对象,同时主进程拥有接收渲染进程消息的能力,那么主进程就可以充当中间人的角色,使渲染进程之间能够通信;

image.png

后语

这篇文章就介绍到这里。通过前两个章节的介绍,相信你对 ipc 大致的通信都有所了解了吧。但其实还有更厉害的你不知道的,咦,这里就让我来卖个关子,下一章再来给大家介绍 定情信物传声筒 port。咱们今天就先到这,拜拜!

喜欢「霖呆呆」的小伙还希望可以关注霖呆呆的公众号 LinDaiDai

我会不定时的更新一些前端方面的知识内容以及自己的原创文章?

你的鼓励就是我持续创作的主要动力 ?.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享