Skip to content

主进程和渲染进程

基于 Electron 开发的应用程序与一般的应用程序不同。
一般的应用程序在处理一些异步工作时,往往需要开发人员自己创建线程,并维护好线程和线程之间的关系。
Electron 应用程序开发人员不用关心线程的问题,但要关心进程的问题。
Electron 应用程序区分主进程和渲染进程,而且主进程和渲染进程互访存在着很多误区,因此开发人员一不小心就会犯错

区分主进程与渲染进程

前面npm run start命令运行的其实是electron ./index.js指令,该指令让 Electron 的可执行程序执行 index.js 中的代码。
index.js 中的代码逻辑即运行在 Electron 的主进程中。

在这个示例中,主进程负责完成监听应用程序的生命周期事件、启动第一个窗口、加载 index.html 页面、应用程序关闭后回收资源、退出程序等工作。
渲染进程负责完成渲染界面、接收用户的输入、响应用户的交互等工作。

一个 Electron 应用只有一个主进程,但可以有多个渲染进程。一个 BrowserWindow 实例就代表着一个渲染进程。
当 BrowserWindow 实例被销毁后,渲染进程也跟着终结。

主进程负责管理所有的窗口及其对应的渲染进程。每个渲染进程都是独立的,它只关心所运行的 Web 页面。
在开启 nodeIntegration 配置后,渲染进程也有能力访问 Node.js 的 API

在 Electron 中,GUI 相关的模块仅在主进程中可用。
如果想在渲染进程中完成创建窗口、创建菜单等操作,可以让渲染进程给主进程发送消息,主进程接到消息后再完成相应的操作;
也可以通过渲染进程的 remote 模块来完成相应操作。
这两种方法背后的实现机制是一样的。

进程调试

调试主进程

使用 vscode 打开项目,

在根目录创建.vscode/launch.json配置文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "调试主进程",
            "type": "node",
            "request": "launch",
            "cwd": "${workspaceRoot}",
             "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
             "windows": {
              "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
            },
            "args": ["."],
            "outputCapture": "std"
        }
    ]
}

其中,name 是配置名称,此名称将会显示在调试界面的启动调试按钮旁边
type 是调试环境,此处调试环境是 Node.js 环境
runtimeExecutable 指向的是批处理文件,该批处理文件用于启动 Electron

${workspaceRoot}是正在进程调试的程序的工作目录的绝对路径
args 是启动参数(此处的值是主进程程序路径的简写形式,填写./index.js亦可)

为了演示调试效果,我们在主进程程序win.loadFile('index.html')语句处增加一个断点

打开左边菜单栏的“调试与运行”,点击“调试主进程”

程序启动后会停止在断点所在行。当鼠标移动到 win 对象时,界面会悬浮显示 win 对象的属性

开发者也可以在 VSCode 的“调试控制台”下方输入栏输入 win,然后按下 Enter 键,调试控制台也会显示 win 对象的详细属性

如果想控制程序的运行,可以通过调试控制器,使程序单步进入某函数的执行逻辑,单步跳过某函数的执行逻辑,重启调试,停止调试等

如果主进程包含大量的业务逻辑,建议开发者直接使用调试模式启动应用,这样有利于随时在主进程业务代码中下断点进行调试

调试渲染进程

程序运行后,保持窗口处于激活状态,同时按下 Ctrl+Shift+I 快捷键(Mac 系统下快捷键为 option+Command+I)即可打开渲染进程的调试窗口

也可以启动窗口后,点击左上角 View -> Toggle Developer Tools 即可启动
不过还是建议记住快捷键,因为默认的窗口菜单对于用户意义不大,开发者往往需要禁用掉 Electron 提供的窗口菜单,此时如果开发者没有记住快捷键将非常麻烦

这其实就是 Chrome 浏览器的开发者工具,开发者可以使用它来调试与界面相关的 HTML、CSS 和 JavaScript 代码

如果你希望项目启动时即打开开发者工具,可以在主进程 win 对象 loadFile 之后,使用如下代码打开该工具

1
win.webContents.openDevTools()

该代码的含义时调用 win 对象的 webContents 属性,使其打开调试工具。
webContents 时 Electron 中一个非常重要的对象,负责渲染和控制页面

界面代码更新后,可以通过 Ctrl + R 快捷键刷新页面(Mac 系统下快捷键为 Command + R)。

进程互访

渲染进程访问主进程对象

修改index.jswebPreferences配置:

1
2
3
4
5
webPreferences: {
    nodeIntegration: true,
    contextIsolation: false,
    enableRemoteModule: true
}

在 index.html 中增加如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<button id="openDevToolsBtn">
    打开开发者工具
</button>
<script src="./objRender.js"></script>
<script>
    let { remote } = require('electron')
    document.querySelector("#openDevToolsBtn").addEventListener('click', function() {
      remote.getCurrentWindow().webContents.openDevTools()
    })
</script>

require 是 Node.js 模块加载指令,因为我们开启了 nodeIntegration,所以这里可以加载任何你安装的 Node.js 模块。
在此我们加载了 Electron 内部的 remote 模块。渲染进程可以通过这个模块访问主进程的模块、对象和方法。

重点

remote 对象的属性和方法(包括类型的构造函数)都是主进程的属性和方法的映射。
在通过 remote 访问它们时,Electron 内部会帮你构造一个消息,这个消息从渲染进程传递给主进程,主进程完成相应的操作,得到操作结果,再把操作结果以远程对象的形式返回给渲染进程(如果返回的结果是字符串或数字,Electron 会直接复制一份结果,返回给渲染进程)。
在渲染进程中持有的远程对象被回收后,主进程中相应的对象也将被回收。渲染进程不应超量全局存储远程对象,避免造成内存泄漏。

在上面的示例中,我们先通过 getCurrentWindow 方法得到了当前窗口,再通过当前窗口的 webContents 对象打开了开发者工具。
我们还可以通过另一种更简单的方法来访问当前窗口的 webContents 对象,如下所示:

1
let webContents = remote.getCurrentWebContents()

渲染进程访问主进程类型

除了常用的 getCurrentWindow 方法和 getCurrentWebContents 方法外,你还可以通过 remote 对象访问主进程的 app、BrowerWindow 等对象和类型。
接下来,我们在渲染进程创建一个新的窗口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<button id="makeNewWindow">
    创建一个新窗口
</button>
<script src="./objRender.js"></script>
<script>
    let { remote } = require('electron')
    document.querySelector("#openDevToolsBtn").addEventListener('click', function() {
      remote.getCurrentWindow().webContents.openDevTools()
    })
    let win = null
    document.querySelector("#makeNewWindow").addEventListener('click', function() {
        win = new remote.BrowserWindow({
            webPreferences: {
                nodeIntegration: true
            }
        })
        win.loadFile('index.html')
    })
</script>

makeNewWindow 是页面中新增加的一个按钮的 id,如你所见,通过 remote 创建新窗口与在主进程中创建窗口并无太大差别

重点

虽然上述代码看起来就像简单地在渲染进程中创建了一个 BrowsetWindow 对象一样,然而背后的逻辑并不简单。
创建 BrowserWindow 的过程依然在主进程中进行,是由 remote 模块通知主进程完成相应操作的,主进程创建了 BrowserWindow 对象的形式返回给渲染进程。
这些工作都是 Electron 帮开发者完成的,开发者因此可以更简单地访问主进程的类型,但与此同时也带来了很多不容易排查出的问题。

渲染进程访问主进程自定义内容

以上内容介绍的是在渲染进程中通过 remote 访问 Electron 提供的内部对象和类型,那么是否可以通过 remote 模块访问用户自己定义的对象或类型呢?

先在工程内新建一个mainModel.js文件,代码如下:

1
2
3
4
5
6
7
8
9
let { BrowserWindow } = require('electron')
exports.makeWin = function() {
  let win = new BrowserWindow({
    webPreferences: {
      nodeIntegration: true
    }
  })
  return win
}

此模块导出了一个创建窗口的函数,再在 index.html 中增加如下代码:

1
2
3
<button id="makeNewWindow2">
    创建一个新窗口2
  </button>
1
2
3
4
5
6
let mainModel = remote.require('./mainModel')
    let win2 = null
    document.querySelector('#makeNewWindow2').addEventListener('click', () => {
      win2 = mainModel.makeWin()
      win2.loadFile('index.html')
    })

makeNewWindow2 亦是页面中的一个按钮,点击此按钮,依然会打开一个新窗口

由于使用了 remote.require 加载 mainModel.js,而 mainModel.js 中创建窗口的逻辑其实是在主进程中运行的,因此如果去掉 remote,直接用 require 加载 mainModel.js,则会提示如下错误:

1
BrowserWindow is not a constructor

因为此时虽然可以正确地记载 mainModel.js,但是此操作是在渲染进程中进行的,然而渲染进程是不能直接访问 BrowserWindow 类型的,因此窗口无法创建,并给出了报错

主进程访问渲染进程对象

因为渲染进程是由主进程创建的,所以主进程可以很方便地访问渲染进程的对象与类型,比如创建一个窗口之后,马上控制这个窗口加载一个 URL 路径 winObj.webContents.loadURL('...')

除此之外,主进程还可以访问渲染进程的刷新网页接口、打印网页内容接口等。

由于主进程内没有类似 remote 的模块,所以在主进程内加载渲染进程的自定义内容也就无从谈起了,一般情况下也不会有这样的业务需求。

进程间消息传递

渲染进程向主进程发送消息

渲染进程使用 Electron 内置的 ipcRenderer 模块向主进程发送消息,我们通过在 index.html 页面上加入如下代码来演示这项功能

1
2
3
<button id="sendMsg1">
  渲染进程向主进程发送消息
</button>
1
2
3
4
let { ipcRenderer } = require('electron')
document.querySelector("#sendMsg1").addEventListener('click', () => {
  ipcRenderer.send('msg_render2main', {name: 'param1'}, {name: 'param2'})
})

ipcRenderer.send 方法的第一个参数是消息管道的名称,主进程会根据该名称接收消息
后面两个对象是随消息传递的数据。该方法可以传递任意多个数据对象

重点

无论是渲染进程给主进程发送消息,还是主进程给渲染进程发送消息,其背后的原理都是进程间通信。
此通信过程中随消息发送的 json 对象会被序列化和反序列化,所以 json 对象中包含的方法和原型链上的数据不会被传送

主进程通过 ipcMain 接收消息,代码如下:

index.js中写入

1
2
3
4
5
electron.ipcMain.on('msg_render2main', (event, param1, param2) => {
  console.log(param1)
  console.log(param2)
  console.log(event.sender)
})

ipcMain.on 方法的第一个参数为消息管道的名称,与渲染进程发送消息的管道名称一致。
第二个参数为接收消息的方法(此处我们使用了一个匿名箭头函数),其中的第一个参数包含消息发送者的相关信息,后面的参数就是消息数据,可以有多个消息数据(此案例中国呢有两个)
param1、param2 与发送过来的消息数据相同,event.sender 是渲染进程的 webContents 对象实例

最终主进程调试控制台会显示如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{ name: 'param1' }
{ name: 'param2' }
EventEmitter {
  isDestroyed: [Function: isDestroyed],
  destroy: [Function: destroy],
  getBackgroundThrottling: [Function: getBackgroundThrottling],
  setBackgroundThrottling: [Function: setBackgroundThrottling],
  getProcessId: [Function: getProcessId],
  getOSProcessId: [Function: getOSProcessId],
  equal: [Function: equal],
  _loadURL: [Function: _loadURL],
  downloadURL: [Function

如果在主进程中设置了多处监听同一管道的代码,当该管道有消息发来时,则多处监听事件都会被触发

以上传递消息的方式是异步传送,如果你的消息需要主进程同步处理,那么可以通过 ipcRenderer.sendSync 方法发送消息,但由于以此方式发送消息会阻塞渲染进程,所以主进程应尽量迅速地完成处理工作,如果做不到,那还是应该考虑使用异步消息

重点

阻塞 JavaScript 的执行线程是非常危险的,因为 JavaScript 本身就是单线程运行,一旦某个方法阻塞了这个仅有的线程,JavaScript 的运行就停滞了,只能等着这个方法退出。
假设此时预期需要有一个 setTimeout 事件或 setInterval 事件被执行,那么此预期也落空了。
这可能使你的业务处于不可知的异常当中。

JavaScript 语言本身以“异步编程”著称,因此我们应该尽量避免用它的同步方法和长耗时方法,避免造成执行线程阻塞

主进程向渲染进程发送消息

如果我们想在主进程中通过控制渲染进程的 webContents 来向渲染进程发送消息,需要在主进程 index.js 文件中增加如下代码:

1
2
3
electron.ipcMain.on('msg_render2main', (event, param1, param2) => {
  win.webContents.send('msg_main2render', param1, param2)
})

这段代码依然是在监听渲染进程发来的消息,唯一不同的是,在渲染进程发来消息后,主进程将控制 webContents 给渲染进程发送消息

webContents.send 方法的第一个参数是管道名称,后面可以跟多个消息数据对象。
其形式与渲染进程给主进程发送消息形式很相似

渲染进程依旧使用 ipcRenderer 接收消息。
ipcRenderer.on 的第一个参数是管道名称,须和主进程发送消息的管道名称相一致。
在渲染进程中增加接收消息的代码如下:

1
2
3
4
5
6
ipcRenderer.on('msg_main2render', (event, param1, param2) => {
      console.log(param1)
      console.log(param2)
      console.log(event.sender)
      console.log(22333)
    })

运行程序,渲染进程给主进程发送消息后,渲染进程也马上接到主进程发来的消息

当前页面无论增加多少个消息监听函数,一旦主进程发来消息,这些消息监听函数都会被触发。
然而如果我们打开新窗口,并让新窗口加载同样的页面,设置同样的消息监听函数,当主进程再发送消息时,新窗口却不会触发监听事件,这是因为我们在向渲染进程发送消息时,使用的时 win.webContents.send。
这就明确地告诉 Electron 只给 win 所代表的渲染进程发送消息

如果我们需要在多个窗口的渲染进程中给主进程发送同样的消息,消息发送完成之后,需要主进程响应消息给发送者,也就是发送消息给对应的渲染进程,该如何处理呢?

我们知道主进程接收消息事件的 event.sender 就代表着发送消息的渲染进程的 webContents,所以可以通过这个对象来给对应的窗口发送消息,代码如下:

1
2
3
ipcMain.on('msg_render2main', (event, param1, param2) => {
  event.sender.send('msg_main2render', param1, param2)
})

除了上面的方法外,还可以直接使用 event.reply 方法,响应消息给渲染进程(与以上方法底层逻辑相同)

1
2
3
ipcMain.on('msg_render2main', (event, param1, param2) => {
  event.reply('msg_main2render', param1, param2)
})

如果渲染进程传递的是同步消息,可以直接设置 event 的 returnValue 属性响应消息给渲染进程。
需要注意的是,以这种方式返回数据给渲染进程,渲染进程是不需要监听的,当消息发送调用成功时,返回值即为主进程设置的 event.returnValue 的值,代码如下:

1
2
// returnValue 为主进程的返回值
let returnValue = ipcRenderer.sendSync('msg_render2main', {name: 'param1'}, {name: 'param2'})

渲染进程之间消息传递

如果一个程序有多个窗口,并要在窗口之间传递消息,可以通过主进程中转,即窗口 A 先把消息发送给主进程,主进程再把这个消息发送给窗口 B,这就完成了窗口 A 和窗口 B 的通信。
因为窗口的创建工作往往是由主进程完成的,所以主进程持有所有窗口的实例,通过主进程中转窗口间消息非常常见。

但如果你在窗口 A 的渲染进程中知道窗口 B 的 webContents 的 id,就可以直接从窗口 A 发送消息给窗口 B,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// win1窗口发送消息的代码
document.querySelector('#sendMsg2').addEventListener('click', _ => {
  ipcRenderer.sendTo(win2.webContents.id, 'msg_render2render', {name: 'param1'}, {name: 'param2'})
})
// win2 窗口接收消息的代码
ipcRenderer.on('msg_render2render', (event, param1, param2) => {
  console.log(param1)
  console.log(param2)
  console.log(event.sender)
})

ipcRenderer.sendTo 的第一个参数设置为目标窗口的 webContents.id,第二个参数才是管道名称

注意,在发送消息之前,你应该先打开 win2 所代表的窗口。
接收消息的监听函数与接收主进程消息的监听函数是一样的。

remote 模块的局限性

Electron 团队提供 remote 模块给开发者,主要目的是降低渲染进程和主进程互访的难度,这个目的确实达到了,但也带来了很多问题。
归纳起来主要分为以下四点

性能损耗大

前文我们提到通过 remote 模块可以访问主进程的对象、类型、方法,但这些操作都是跨进程的,跨进程操作在性能上的损耗是进程内操作的几百倍甚至上千倍。
假设你在渲染进程中通过 remote 模块创建了一个 BrowserWindow 对象,不仅你创建这个对象的过程很耗时,后面你是用这个对象的过程也很耗时。
小到更新这个对象的属性,大到使用这个对象的方法,所有操作都是跨进程的,这种累积性的性能损耗,影响有多大可想而知。

制造混乱

假设我们在渲染进程中通过 remote 模块使用了主进程的某个对象,得到的是这个对象的映射。
虽然它看起来像是真正的对象,但实际上是一个代理对象。
首先,这个对象原型链上的属性不会被映射到渲染进程的代理对象上。
其次,类似 NaN、Infinity 这样的值不会被正确地映射到渲染进程,如果一个主进程方法返回一个 NaN 值,那么渲染进程通过 remote 模块访问这个方法将会得到"undefined"

存在安全问题

因为 remote 模块底层是通过 IPC 管道与主进程通信的,那么假设你的应用需要加载第三方网页,即使这些网页运行在安全沙箱内,恶意代码仍可能通过原型污染攻击来模拟 remote 模块的远程消息以获取访问主进程模块的权利,从而逃离沙箱的控制,导致安全问题出现

扩展

Electron 官方团队也意识到了 remote 模块带来的问题,目前正在讨论把 remote 模块标记为“不赞成”,从而逐步把它从 Electron 内部移除,但由于需要考虑兼容性等因素,这势必是一个漫长的过程。
在实际生产中使用 remote 模块尤其需要注意其带来的诸多问题。

新的 remote

https://github.com/electron/remote