2

前言

Electron 的应用是由一个主进程与多个渲染器进程一同组成的。

关于 主进程与渲染器进程 大家可以翻阅官方文档来查阅。

主进程

《Electron:Web 应用桌面化》 中有创建一个 Demo 项目。打开 main.js 文件:

// main.js
const { app, BrowserWindow } = require('electron')

app.on('ready', () => {
  let win = new BrowserWindow({
    width: 500,
    height: 300
  })
  win.loadFile('index.html')
})

BrowserWindow 类用来创建一个页面,也就是一个渲染进程,但是它只能在主进程中使用

这里不是说 BrowserWindow 创建的窗口是主进程,而是指整个 main.js 这个脚本是主进程,而主进程拥有 new 一个 window 的能力。

所有的页面(渲染进程)都应该由主进程来统一管理调度。

渲染器进程

每个页面都是一个渲染器进程,所以上面的 BrowserWindow 创建的页面就是跑在渲染器进程上的。

为了更加直观,下面来改造一下 demo 文件结构。

在根目录添加 src/ 目录,具体内容如下:

demo
│  main.js
│  package-lock.json
│  package.json
│
└─src
    └─windows
        │  style.css
        │
        ├─main
        │      main.html
        │      main.js
        │      main.page.js
        │
        ├─sub1
        │      sub1.html
        │      sub1.js
        │      sub1.page.js
        │
        └─sub2
                sub2.html
                sub2.js
                sub2.page.js

可以先把 windows 目录下的每个文件夹都理解为一个窗口。

IPC

Inter-Process Communication 进程间通讯,指至少两个进程或线程之间传递数据或信号的一些技术或方法。

由于 Chromium 的页面与浏览器的主进程并非是同一个进程,而 Electron 又是基于 Chromium 来实现的,所以必然存在着进程之间通讯的需求。

在 Electron 中,主进程只有一个,渲染器进程存在多个,所以大致可以把进程之间的通讯划分为以下 3 类:

  • 渲染器进程调用主进程
  • 主进程调用渲染器进程
  • 渲染器进程之间的交互

下面在 Electron 中分别实践对应的交互方式。

渲染器进程调用主进程

Each BrowserWindow instance runs the web page in its own renderer process.

可以认为每个页面(窗体)的实例就是一个渲染器进程,所以每当我们需要在页面之中调用主进程之中的功能的时候,就需要进入 渲染器进程调用主进程 这样的场景。

Electron 模块提供了 ipcMain 和 ipcRenderer 可以很方便的实现 Electron 进程间的通讯,对于 渲染器进程调用主进程 这样的场景,需要在渲染器进程中使用 require('electron').ipcRenderer.invoke('channel', ...args) 来发起通讯,并且在主进程中使用 require('electron').ipcMain.handle('channel', listener) 来监听渲染器进程发起的通讯。

对应 demo 中的代码如下(涉及到代码的地方请配合文中提供的项目目录结构来阅读理解):

// 主进程的 main.js 
const { app, ipcMain } = require('electron')
const { createMainWindow } = require('./src/windows/main/main') // 主页面
const { createSub1Window } = require('./src/windows/sub1/sub1') // 子页面1
const { createSub2Window } = require('./src/windows/sub2/sub2') // 子页面2

app.on('ready', () => createMainWindow())

// 监听渲染器进程发起的 open-window 管道的通讯
ipcMain.handle('open-window', (event, ...args) => {
  switch (args[0]) {
    case 'sub1':
      createSub1Window()
      break;
    case 'sub2':
      createSub2Window()
      break;
    default:
      break;
  }
})
// src/windows/main/main.page.js
const renderer = require('electron').ipcRenderer

// 该函数会在主页面点击按钮后执行
function handleOpenSub1 () {
  renderer.invoke('open-window', 'sub1') // 发起 open-window channel 的通讯,参数是 sub1
}

下面是主页面的 html 代码,主要就是点击按钮,打开 sub1 窗体。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>main window</title>
  <link rel="stylesheet" href="../style.css">
</head>

<body>
  <h1>Main Window</h1>
  <button onclick="handleOpenSub1()">打开 Sub1 Window</button>

  <script src="./main.page.js"></script>
</body>

</html>

构建 sub1 的相关代码放在了 src/windows/main/main.js 当中:

// src/windows/main/main.js
const { BrowserWindow } = require('electron')

let mainWindow

module.exports.createMainWindow = () => {
  mainWindow = new BrowserWindow({
    width: 1024,
    height: 500,
    webPreferences: {
      nodeIntegration: true
    }
  })
  mainWindow.webContents.openDevTools();
  mainWindow.loadFile('src/windows/main/main.html')
}

这段代码的目的如图所示

点击主页面按钮弹出子页面1.png

思路分析:

由于渲染器进程没有创建一个新的渲染器进程的权利,因此,当我们在一个页面中先要打开另一个页面时,就需要通知主进程,让主进程帮忙打开这个页面,所以也就用到了 渲染器进程调用主进程 这样的场景。

如果直接在渲染器进程调用了 new BrowserWindow 会如何?随后我在 sub1 之中打开 sub2 时会尝试一下。

主进程调用渲染器进程

我们已经知道了每个 Electron 应用都由一个主进程和多个渲染器进程组成,所以对于渲染器进程来说,只要 invoke 那么必然是去通知主进程,然而返回来如果主进程 invoke 就比较迷茫了,那么多的渲染器进程我具体是要 invoke 哪一个呢?

我们假设一个在主进程中不停的递增一个变量,并且将其展示在 sub1 的页面上的需求以此来实践主进程调用渲染器进程的目的。

在 Electron 中,主进程调用渲染器进程 需要首先在主进程中找到渲染器进程的实例对象(比如 sub1),然后 sub1.webContents.send('channel', ...args),对应的还需要在渲染器进程中通过 ipcRenderer.on('channel', listener) 来监听。

为此我们需要修改部分代码:

为了让主进程可以拿到渲染器进程,我们需要把渲染器进程的实例返回给主进程

// src/windows/sub1/sub1.js
const { BrowserWindow } = require('electron')

let sub1Window

module.exports.createSub1Window = () => {
  if (sub1Window) {
    sub1Window.show()
  } else {
    sub1Window = new BrowserWindow({
      width: 800,
      height: 500,
      webPreferences: {
        nodeIntegration: true
      }
    })
    sub1Window.on('closed', () => {
      sub1Window = undefined
    })
    sub1Window.webContents.openDevTools()
    sub1Window.loadFile('src/windows/sub1/sub1.html')
  }

  return sub1Window
}

主进程也需要接收并保存渲染器进程 sub1,同时把递增的代码也加上:

// main.js
const { app, ipcMain } = require('electron')
const { createMainWindow } = require('./src/windows/main/main')
const { createSub1Window } = require('./src/windows/sub1/sub1')
const { createSub2Window } = require('./src/windows/sub2/sub2')

app.on('ready', () => createMainWindow())

let sub1, sub2

ipcMain.handle('open-window', (event, ...args) => {
  switch (args[0]) {
    case 'sub1':
      sub1 = createSub1Window()
      break;
    case 'sub2':
      createSub2Window()
      break;
    default:
      break;
  }
})


let num = 0, intervalId = setInterval(() => {
  try {
    if (sub1) {
      sub1.webContents.send('increase', num)
      num++
    }
  } catch (error) {
    sub1 = undefined
  }
}, 1000);

渲染器进程需要相应主进程发起的 'increase' channel:

// src/windows/sub1/sub1.page.js
const { BrowserWindow, ipcRenderer } = require('electron')

function handleCreateWindow (e) {
  if (e === 1) {
    let win = new BrowserWindow({
      width: 500,
      height: 500
    })
    win.loadURL('https://www.baidu.com')
  } else {
    ipcRenderer.invoke('open-window', 'sub2')
  }
}

let num = document.querySelector('#num')

ipcRenderer.on('increase', (e, ...args) => {
  num.textContent = args[0];
})

sub1 的 html 部分只是添加了一个 p 标签:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>sub1</title>
  <link rel="stylesheet" href="../style.css">
</head>

<body>
  <h1>Sub1 Window</h1>
  <button onclick="handleCreateWindow(1)">使用 browserWindow 打开一个页面</button>
  <button onclick="handleCreateWindow(2)">使用 ipcRenderer.invoke 打开一个页面</button>
  <p id="num"></p>
  <script src="./sub1.page.js"></script>
</body>

</html>

本模块的效果如图:

主进程调用渲染器进程.png

渲染器进程之间的交互

渲染器进程之间的交互有两种方式,分别是:

  • 通过 main process 中转
  • 通过 ipcRenderer.sendTo(webContentsId, 'channel', ...args)

由于篇幅问题,这里只对 sendTo 方式进行展示。

假设我们要从 sub2 使用 sendTosub1 通讯。

sendTo 方法的重点在于 webContentsId,因此我们需要额外的有一个获取 webContentsId 的 channel。

a) 在 sub2.html 添加两个按钮,分别是获取 webContentsId 和 通讯。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>sub1</title>
  <link rel="stylesheet" href="../style.css">
</head>

<body>
  <h1>Sub2 Window</h1>
  <button onclick="handleGetSub1Id()">首先获取到 sub1 的 id</button>
  <button onclick="handleComunicateSub1()">使用主 sendTo 与 sub1 交互</button>
  <script src="./sub2.page.js"></script>
</body>

</html>

b) sub2 页面对应的 js 代码

// src/windows/sub2/sub2.page.js
const renderer = require('electron').ipcRenderer

let sub1Id

// 获取 sub1 webContentsId 的代码
async function handleGetSub1Id () {
  sub1Id = await renderer.invoke('get-process-id', 'sub1')
}

// sendTo 与 sub1 通讯
function handleComunicateSub1 () {
  renderer.sendTo(sub1Id, 'append-li', 'from sub2')
}

c) 修改主进程,支持 get-process-id channel

// main.js
const { app, ipcMain } = require('electron')
const { createMainWindow } = require('./src/windows/main/main')
const { createSub1Window } = require('./src/windows/sub1/sub1')
const { createSub2Window } = require('./src/windows/sub2/sub2')

app.on('ready', () => createMainWindow())

let sub1, sub2

ipcMain.handle('open-window', (event, ...args) => {
  switch (args[0]) {
    case 'sub1':
      sub1 = createSub1Window()
      break;
    case 'sub2':
      sub2 = createSub2Window()
      break;
    default:
      break;
  }
})

ipcMain.handle('get-process-id', (event, processName) => {
  switch (processName) {
    case 'main':
      return 0; // 偷懒一下
    case 'sub1':
      return sub1.webContents.id;
    case 'sub2':
      return sub2.webContents.id;
    default:
      break;
  }
})

ipcMain.handle('proxy-comunicate-sub2', (event, msg) => {
  sub2.webContents.send('append-p', msg)
})

let num = 0, intervalId = setInterval(() => {
  try {
    if (sub1) {
      sub1.webContents.send('increase', num)
      num++
    }
  } catch (error) {
    sub1 = undefined
  }
}, 1000);

d) 修改 sub2 构建窗体的 js 代码,使之返回 renderer instance

// src/windows/sub2/sub2.js
const { BrowserWindow } = require('electron')

let sub2Window

module.exports.createSub2Window = () => {

  if (sub2Window) {
    sub2Window.show()
    return
  }

  sub2Window = new BrowserWindow({
    width: 800,
    height: 500,
    webPreferences: {
      nodeIntegration: true
    }
  })

  sub2Window.webContents.openDevTools()
  sub2Window.loadFile('src/windows/sub2/sub2.html')

  // 返回 instance
  return sub2Window
}

e) 修改 sub1.page.js 支持 append-li channel

const { BrowserWindow, ipcRenderer } = require('electron')

function handleCreateWindow (e) {
  if (e === 1) {
    let win = new BrowserWindow({
      width: 500,
      height: 500
    })
    win.loadURL('https://www.baidu.com')
  } else {
    ipcRenderer.invoke('open-window', 'sub2')
  }
}

let num = document.querySelector('#num')

ipcRenderer.on('increase', (e, ...args) => {
  num.textContent = args[0]
})

// 新增:支持 append-li channel
ipcRenderer.on('append-li', (e, msg) => {
  let li = document.createElement('li')
  li.textContent = msg
  document.querySelector('body').appendChild(li)
})

效果如图所示:

Sub2SendToSub1.png

小结

本文主要以实践的方式来了解了 Electron 的进程间的交互方式,包括:

  • 渲染器进程与主进程通讯

    • ipcRenderer.invoke 与 ipcMain.handle
  • 主进程与渲染器进程通讯

    • renderer.webContents.send 与 ipcRenderer.on
  • 渲染器进程与渲染器进程通讯

    • renderer.sendTo 与 ipcRenderer.on

基本套路都是相同的,一端发起一端监听。

由于篇幅的问题并没有将通过主进程中转的方式进行渲染器进程之间通讯的过程贴上,你可以点此查看完整代码


youbei
318 声望70 粉丝