前阵子将排课系统的一些功能,提供给 solar 编辑器使用,solar 为 基于 Cocos Creator,而 Cocos Creator 是基于 Electron 进行开发的,所以学习了一些关于 Electron IPC 通信的相关知识,在这里做一个总结。
文章的开始,先让我们来了解下 Electron
是什么
什么是Electron?
Electron
官网只有一句简单的话:
使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。
简单点讲,就是有了 Electron
,我们就可以用前端技术来写 web
页面,它可以转化为一个桌面应用。
除此之前,Electron
还有其他的一些特性。
- 基于 Chromium 和 NodeJS
- 兼容 Mac、Windows 和 Linux,可以构建出三个平台的应用程序。
Electron 能做啥?
Electron
基于 Chromium 和 NodeJS,类似一个小型的 Chrome 的浏览器,Electron
可以将你写的 web
页面(html文件)本地化,然后打包成一个桌面应用程序。它同时还是跨平台的,提供了许多功能与原生系统进行交互。
由于是基于 Chromium的,所以写 Electron,从此与前端兼容性无缘(真香)。Node版本也是固定的,无需考虑版本兼容问题(除非升级大版本)。
所以作为前端开发人员来说,想开发一款桌面端应该,Electron
是再适合不过了。
Electron
官网还举了一些使用 Electron
进行开发的应用,大名鼎鼎的 VSCode 就是基于 Electron
Electron 上手
学啥不得先来个 hello world
呢?
创建HTML
在 Electron
中,每个窗口都可以加载本地或者远程URL,这里我们先创建一个本地的 HTML
文件。
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
We are using Electron <span id="electron-version"></span>
</body>
</html>
这里你可能会注意到, span
标签里面是空文本,后面我们会动态插入 Electron
的版本。
创建入口文件
类似于 NodeJS
启动服务,Electron
启动也需要一个入口文件,这里我们创建 index.js
文件。
在这个入口文件里,需要去加载上面创建的 HTML
文件,那么如何加载呢?
Electron
提供了两个模块:
app
模块,它控制应用程序的事件生命周期。BrowserWindow
模块,它创建和管理应用程序 窗口。
入口文件是 Node
环境,所以可以通过 CommonJS
模块规范来导入 Electron
的模块。
同时添加一个 createWindow()
方法来将 index.html
加载进一个新的BrowserWindow
实例。
// index.js
const { app, BrowserWindow } = require('electron');
function createWindow () {
const win = new BrowserWindow({
width: 800,
height: 600
})
win.loadFile('index.html')
}
那么在什么时候调用 createWindow
方法来打开窗口呢?
在 Electron 中,只有在 app
模块的 ready
事件被激发后才能创建浏览器窗口。可以通过使用 app.whenReady()
API来监听此事件。
// index.js
app.whenReady().then(() => {
createWindow()
})
这样一来就可以通过以下命令打开 Electron
应用程序了!
# 这里会自动去找package.json的main字段对应的文件运行
# 当然 你也可以将命令放进 script 里面
npx electron .
运行完打开的应用程序如下图所示。
管理窗口的声明周期
虽然现在可以打开一个浏览器窗口,但还需要一些额外的模板代码使其看起来更像是各平台原生的。 应用程序窗口在每个OS下有不同的行为,Electron
将在 app 中实现这些约定的责任交给开发者们。
可以使用 process.platform
属性来为不同的操作系统做处理。
关闭所有窗口时退出应用(Windows & Linux)
在Windows和Linux上,关闭所有窗口通常会完全退出一个应用程序。
app
模块可以监听所有窗口关闭的事件 window-all-closed
,在事件回调里可以调用 app.quit()
退出应用。
// index.js
app.on('window-all-closed', function () {
// darwin 为 macOS
if (process.platform !== 'darwin') app.quit()
})
没有窗口打开则打开一个新窗口(macOS)
用过 macOS 的人应该都知道,一个应用没有窗口打开的时候,也是可以继续运行的,这时如果打开应用程序,就会打开新的窗口。
app
模块可以监听应用激活事件 activate
,在事件回调里可以判断当前窗口数量来确定需不需要打开一个新的窗口。
因为窗口无法在 ready
事件前创建,你应当在你的应用初始化后仅监听 activate
事件。 通过在您现有的 whenReady()
回调中附上您的事件监听器来完成这个操作。
// index.js
app.whenReady().then(() => {
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
预加载脚本
前面讲到我们会在 HTML
文件中插入 Electron
的版本号。然而,在 index.js
主进程中,是不能编辑 DOM
的,因为它无法访问到渲染进程 document
上下文,它们存在于完全不同的进程中。
这时候,预加载脚本就可以派上用场了。 预加载脚本在渲染进程加载之前加载,并有权访问两个 渲染进程全局 (例如 window
和 document
) 和 NodeJS 环境。
创建预加载脚本
创建一个名为 preload.js
的新脚本如下:
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector);
if (element) element.innerText = text;
}
replaceText('electron-version', process.versions.electron);
})
我们需要在初始化 BrowserWindow
实例的时候,传入该预加载脚本。
// 在文件头部引入 Node.js 中的 path 模块
const path = require('path')
// 修改现有的 createWindow() 函数
function createWindow () {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
// ...
然后重新启动程序,就可以看到 Electron
的版本了。
Electron的流程模型
前面讲到了主进程、渲染进程等概念性知识,初学者可能会对此比较迷惑,不过,进行Electron
,对这一块内容的掌握是至关重要的,后面的 IPC
进程通信,也与此有关。
实际上,Electron
继承了来自 Chromium
的多进程架构,作为前端工程师,对于浏览器进程架构有所了解,也是非常有必要的。
主进程
每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点,比如上面的 index.js
。 主进程在 Nodejs
环境中运行,这意味着它具有 require
模块和使用所有 NodeJS API 的能力。
主进程一般包括以下三大块:
- 窗口管理:使用
BrowserWindow
模块创建和管理应用窗口。类的每个实例创建一个应用程序窗口,且在单独的渲染器进程中加载一个网页。 - 应用生命周期: 主进程可以使用
Electron
提供的app
模块来控制应用程序的生命周期。 - 原生API:
Electron
有着多种控制原生桌面功能的模块,例如菜单、对话框以及托盘图标。
渲染进程
每个打开的 BrowserWindow
都会生成一个单独的渲染进程。渲染进程负责渲染网页实际的内容。因此,渲染进程中运行的代码,几乎跟我们编写的** Web
代码别无二致。**
除此之外,渲染进程也无法直接访问 require
或其他 NodeJS API。
注意:实际上渲染进程可以生成一个完整的 NodeJS 环境以便于开发。 在过去这是默认的,但如今此功能考虑到安全问题已经被禁用。
预加载脚本
前面上手的时候已经讲过预加载脚本了,预加载(preload)脚本会在渲染进程网页内容开始加载之前执行,并且可以访问NodeJS API。由于预加载脚本与渲染器共享同一个全局 Window
接口,因此它通过在 window
全局中暴露任意您的网络内容可以随后使用的 API 来增强渲染器。
不过我们不能在预加载脚本中直接给 window
挂载变量,因为 contextIsolation
是默认的。
window.myAPI = { desktop: true }
console.log(window.myAPI) // => undefined
Electron
这样做是为了将预加载脚本与渲染进程的主要运行环境隔离开来的,以避免泄漏任何具特权的 API 到网页内容代码中。(比如有些人会把 ipcRenderer.send
的方法暴露给 web端,这将允许网站发送任意的 IPC 消息)
我们也可以关闭 contextIsolation
,不过不建议这么做。
new BrowserWindow({
// ...
webPreferences: {
// ...
contextIsolation: false
}
})
最好使用 contextBridge
模块来安全地实现交互:
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
desktop: true
})
console.log(window.myAPI)
// => { desktop: true }
Electron IPC 通信
Electron
有主进程和渲染进程,之间会有许多通信,这样就涉及到了进程间通信(IPC,InterProcess Communication)
在 Electron
中,主进程和渲染进程之间进行通信,只要是用到以下两个模块:
ipcMain
ipcMain
是一个EventEmitter
的实例。 当在主进程中使用时,它处理从渲染器进程(网页)发送出来的异步和同步信息。 从渲染器进程发送的消息将被发送到该模块。ipcRenderer
ipcRenderer
是一个EventEmitter
的实例。 你可以使用它提供的一些方法从渲染进程 (web 页面) 发送同步或异步的消息到主进程。 也可以接收主进程回复的消息。
渲染进程给主进程发送消息,主进程回复
1. 普通脚本监听
普通脚本引入 electron
的 ipcRenderer
模块,实现发送消息。
在 HTML 文件添加 renderer.js
脚本
const { ipcRenderer } = require('electron')
ipcRenderer.on('main-message-reply', (event, arg) => {
console.log(arg);
});
ipcRenderer.send('message-from-renderer', '渲染进程发送消息过来了');
在 index.js
入口文件引入 ipcMain
模块,并修改 BrowserWindow
的实例化参数,开启渲染进程的 NodeJS
环境
const { ipcMain } = require('electron')
// ...
function createWindow() {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
// 这里开启后 渲染进程就可以用 NodeJS 环境
// 可以引如 Electron 相关模块
nodeIntegration: true,
contextIsolation: false,
},
});
mainWindow.loadFile('index.html');
}
// ...
ipcMain.on('message-from-renderer', (event, arg) => {
console.log(arg);
// 接收到消息后可以回复
event.reply('main-message-reply', '主进程回复了')
})
启动应用,可以在命令行看到渲染进程发过来的消息了。
然后渲染进程收到主进程的回复。
2. 预加载脚本暴露接口
在预加载脚本中,可以暴露一些全局的接口给到渲染进程,然后渲染进程调用,从而达到通信的目的。这种方式类似于微信SDK,不用侵入到前端脚本去监听事件,较为安全。
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
// 这里暴露一个全局myAPI变量
contextBridge.exposeInMainWorld('myAPI', {
getMessage(args) {
ipcRenderer.send('message-from-proload', args);
consoloe.log('前端调用了:', args)
}
})
renderer.js
直接调用暴露出来的接口。
// renderer.js
window.myAPI.getMessage('postMessage');
index.js
主进程监听预加载脚本发送过来的信息。
// ...
ipcMain.on('message-from-proload', (event, arg) => {
console.log(arg);
// 接收到消息后可以回复
event.reply('main-message-reply', '主进程回复了')
})
主进程给渲染进程发送消息
将 renderer.js
改为如下代码,监听主进程发送过来的消息。
const { ipcRenderer } = require("electron");
ipcRenderer.on("message", (event, arg) => {
console.log("主进程主动推消息了:", arg);
});
主进程往渲染进程发送消息,需要用到 webContents。
webContents
是一个EventEmitter,负责渲染和控制网页,是 BrowserWindow
对象的一个属性。
修改一下 index.js
文件。
function createWindow() {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true,
contextIsolation: false,
},
});
const contents = mainWindow.webContents;
mainWindow.loadFile('index.html');
contents.openDevTools(); //打开调试工具
contents.on("did-finish-load", () => {
//页面加载完成触发的回调函数
contents.send("main-message-reply", "我看到你加载完了,给你发个信息");
});
}
运行应用,就可以在渲染进程中打开看到消息了
以上的通信方式均为异步,不过Electron
也提供了同步的通信方式,但是同步的方式会阻塞代码的执行,最好都使用异步通信。同步用法在这里不多作介绍。
ipcMain
和 ipcRenderer
模块还有一些其他的通信API,不过大抵都是类似的通信方式,需要了解的同学可以自行去查阅文档。
最后
到这里文章的介绍就差不多了,不过在实际写代码的时候,感觉 Electron
的原生 IPC 通信机制,写起来还是有点繁琐。VScode的事件通信机制,听闻封装得比较好,后面有时间再去读读它的源码,写一篇文章看看(技术债欠着,逃