Electron的工作方式非常简单。有两种不同的进程-主进程(Main Process)和渲染进程(Renderer Process)。始终只保持有一个主进程,这是您Electron应用程序的入口。可以有任意数量的渲染器进程,这些进程负责渲染您的应用程序。
这些进程间的通信是通过IPC(进程间通信)完成的。听起来可能很复杂,但这只是异步请求-响应模式的一个好听的名字。
渲染器与主进程之间的通信在后台发生的事情基本上只是事件调度。例如,假设您的应用程序应显示有关其运行系统的信息。这可以通过一个简单的命令uname -a
来完成,该命令显示您的内核版本。但是您的应用程序本身无法此执行命令,因此需要主进程。在Electron应用程序中,您的应用程序可以访问渲染器进程(ipcRenderer)。
以下是将要发生的事情:
您的应用程序将利用
ipcRenderer
向主进程发送事件。这些事件称为Electron内部的通道。如果主进程正确的注册了事件侦听器(用于侦听刚刚调度的事件),则可以为该事件正确的运行代码。
完成所有操作后,主进程可以为结果发出另一个事件(在我们的示例中为内核版本)。
4. 现在整个工作流程都以相反的方式进行,渲染器流程需要为主流程中分派的事件实现一个侦听器。
- 当渲染器进程收到包含我们所需信息的适当事件时,在界面上显示该信息。
最终,整个过程可以看作是一个简单的请求-响应模式,有点像HTTP – 只不过是异步的。我们将通过某个指定的频道发起请求,并在某个指定的频道上收到对此的回复。
多亏了TypeScript,我们可以将整个逻辑抽象成一个干净分离且正确封装的应用程序中,在这个应用程序中,我们为主进程中的单个通道定义了单独的类,并利用Promise简化异步请求。再说一遍,这听起来比实际情况复杂得多!
用TypeScript引导电子应用程序
我们需要做的第一件事是用TypeScript引导我们的Electron应用程序。我们的package.json
是:
{
"name": "electron-ts",
"version": "1.0.0",
"description": "Yet another Electron application",
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"start": "npm run build && electron ./dist/electron/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Kevin Hirczy <https://nehalist.io>",
"license": "MIT",
"devDependencies": {
"electron": "^7.1.5",
"typescript": "^3.7.3"
}
}
接下来我们要添加的是Typescript配置,tsconfig.json
:
{
"compilerOptions": {
"target": "es5",
"noImplicitAny": true,
"sourceMap": true,
"moduleResolution": "node",
"outDir": "dist",
"baseUrl": "."
},
"include": [
"src/**/*"
]
}
我们的源文件将位于src
目录中,所有文件都将构建到dist
目录中。我们将把src
目录分成两个单独的目录,一个用于Electron,一个用于我们的应用程序。整个目录结构如下所示:
src/
app/
electron/
shared/
index.html
package.json
tsconfig.json
我们的index.html将被Electron加载,非常简单(目前):
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello World!</title>
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"/>
</head>
<body>
Hello there!
</body>
</html>
我们要实施的第一个文件是Electron的主文件。 该文件将实现Main类,该类负责初始化我们的Electron应用程序:
// src/electron/main.ts
import {app, BrowserWindow, ipcMain} from 'electron';
class Main {
private mainWindow: BrowserWindow;
public init() {
app.on('ready', this.createWindow);
app.on('window-all-closed', this.onWindowAllClosed);
app.on('activate', this.onActivate);
}
private onWindowAllClosed() {
if (process.platform !== 'darwin') {
app.quit();
}
}
private onActivate() {
if (!this.mainWindow) {
this.createWindow();
}
}
private createWindow() {
this.mainWindow = new BrowserWindow({
height: 600,
width: 800,
title: `Yet another Electron Application`,
webPreferences: {
nodeIntegration: true // 使在index.html中可以使用`require`
}
});
this.mainWindow.webContents.openDevTools();
this.mainWindow.loadFile('../../index.html');
}
}
// 走起!
(new Main()).init();
运行npm start
现在应该启动Electron应用程序并显示index.html
:
接下来我们要实现的是如何处理IPC通道。
通道处理
基于SoC概念,我们将为每个通道实现一个类。这些类将负责传入的请求。在上面的例子中,我们有一个SystemInfoChannel
负责收集系统数据。如果你想使用某些工具,例如使用Vagrant控制虚拟机,就创建一个VagrantChannel
等。
每个通道都将有一个名称和一个处理传入请求的方法,因此我们为此创建一个接口:
// src/electron/IPC/IpcChannelInterface.ts
import {IpcMainEvent} from 'electron';
export interface IpcChannelInterface {
getName(): string;
handle(event: IpcMainEvent, request: any): void;
}
有个棘手的事情,在很多情况下,any
类型意味着设计上的存在缺陷,我们不想拥有这种缺陷。因此,让我们花点时间考虑一下request
的类型。
请求是从渲染进程中发出的。发送请求时可能需要知道两件事:
- 我们的频道可以接受参数
- 该使用哪个通道来响应
两者都是可选的,我们可以创建一个发送请求的接口。此接口将在Electron和我们的应用程序之间共享:
export interface IpcRequest {
responseChannel?: string;
params?: string[];
}
现在我们可以回到IpcChannelInterface
,为我们的request
添加适当的类型:
handle(event: IpcMainEvent, request: IpcRequest): void;
接下来我们需要注意的是如何将频道添加到我们的主进程中。最简单的方法是将通道数组添加到Main类的init方法中。这些频道将由我们的ipcMain进程注册:
public init(ipcChannels: IpcChannelInterface[]) {
app.on('ready', this.createWindow);
app.on('window-all-closed', this.onWindowAllClosed);
app.on('activate', this.onActivate);
this.registerIpcChannels(ipcChannels);
}
registerIpcChannels方法只有一行:
private registerIpcChannels(ipcChannels: IpcChannelInterface[]) {
ipcChannels.forEach(channel => ipcMain.on(channel.getName(), (event, request) => channel.handle(event, request)));
}
这里发生的事情是,传递到init
方法的通道将注册到主进程,并由它们的响应通道类处理。
为了更容易理解,让我们从上面的示例中快速实现系统信息的类:
import {IpcChannelInterface} from "./IpcChannelInterface";
import {IpcMainEvent} from 'electron';
import {IpcRequest} from "../../shared/IpcRequest";
import {execSync} from "child_process";
export class SystemInfoChannel implements IpcChannelInterface {
getName(): string {
return 'system-info';
}
handle(event: IpcMainEvent, request: IpcRequest): void {
if (!request.responseChannel) {
request.responseChannel = `${this.getName()}_response`;
}
event.sender.send(request.responseChannel, { kernel: execSync('uname -a').toString() });
}
}
通过将此类的实例添加到我们的Main
类的init
调用中,我们现在注册了我们的第一个通道处理程序:
(new Main()).init([
new SystemInfoChannel()
]);
现在,每次在system-info
通道上发生请求时,SystemInfoChannel
都会处理该请求,并通过在内核上响应(在responseChannel
上)来正确处理该请求。
到目前为止,我们已经完成了以下工作:
到目前为止看起来不错,但我们仍然缺少应用程序实际完成工作的部分,例如发送收集内核版本的请求。
从我们的应用程序发送请求
为了利用干净的主流程的IPC架构,我们需要在应用程序中实现一些逻辑。 为了简单起见,我们的用户界面将仅具有一个用于向主进程发送请求的按钮,该按钮将返回我们的内核版本。
我们所有与IPC相关的逻辑都将放在一个简单的服务– IpcService
类中:
// src/app/IpcService.ts
export class IpcService {
}
使用此类时,我们要做的第一件事是确保我们可以访问ipcRenderer
。
如果您想知道为什么我们需要这样做,那是因为不这样做,直接打开index.html
文件时,没有可用的ipcRenderer
。
让我们添加一个可以正确初始化ipcRenderer
的方法:
private ipcRenderer?: IpcRenderer;
private initializeIpcRenderer() {
if (!window || !window.process || !window.require) {
throw new Error(`Unable to require renderer process`);
}
this.ipcRenderer = window.require('electron').ipcRenderer;
}
当我们试图从主流程请求某些内容时,将调用此方法-这是我们需要实现的下一个方法:
// 如果ipcRenderer不可用,请尝试将其初始化
if (!this.ipcRenderer) {
this.initializeIpcRenderer();
}
// 如果没有responseChannel让我们自动生成它
if (!request.responseChannel) {
request.responseChannel = `${channel}_response_${new Date().getTime()}`
}
const ipcRenderer = this.ipcRenderer;
ipcRenderer.send(channel, request);
// 该方法返回一个`promise`,当响应到达时将调用`resolve`。
return new Promise(resolve => {
ipcRenderer.once(request.responseChannel, (event, response) => resolve(response));
});
}
使用泛型使我们有可能获得有关我们将从请求中得到的信息 - 否则,如果它是未知的,我们将不得不成再做一个转换方法,以获取有关我们的类型的正确信息。
在响应到达时从send
方法解析promise
,使得使用async/await
语法成为可能。通过使用一次而不是在我们的ipcRenderer
上使用,我们确保不会监听到此指定通道上的其他事件。
现在,我们整个IpcService
应该看起来像这样:
// src/app/IpcService.ts
import {IpcRenderer} from 'electron';
import {IpcRequest} from "../shared/IpcRequest";
export class IpcService {
private ipcRenderer?: IpcRenderer;
public send<T>(channel: string, request: IpcRequest): Promise<T> {
// 如果ipcRenderer不可用,请尝试将其初始化
if (!this.ipcRenderer) {
this.initializeIpcRenderer();
}
// 如果没有responseChannel让我们自动生成它
if (!request.responseChannel) {
request.responseChannel = `${channel}_response_${new Date().getTime()}`
}
放在一起
现在,我们已经在主进程中创建了一个用于处理传入请求的体系结构,并实现了一种发送此类服务的服务,现在我们可以将所有内容放在一起!
我们要做的第一件事是扩展我们的index.html
以包括一个用于请求我们的信息的按钮和一个显示它的位置:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello World!</title>
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"/>
</head>
<body>
<button id="request-os-info">Request OS Info</button>
<div id="os-info"></div>
<script>
require('./dist/app/app.js');
</script>
</body>
</html>
所需的app.js
尚不存在-我们来创建它。 请记住,引用的路径是内置文件-我们将实现TypeScript文件(位于src/app/
中)!
// src/app/app.ts
import {IpcService} from "./IpcService";
const ipc = new IpcService();
document.getElementById('request-os-info').addEventListener('click', async () => {
const t = await ipc.send<{ kernel: string }>('system-info');
document.getElementById('os-info').innerHTML = t.kernel;
});
我们完成了! 乍一看似乎并不令人印象深刻,但是现在单击按钮,就将请求从渲染进程发送到我们的主进程,该主进程将请求委托给负责的通道类,并最终以我们的内核版本进行响应。
当然,诸如错误处理之类的事情需要在这里完成,但是这个概念允许为Electron应用程序提供一种非常干净且易于遵循的通信策略。
可以在GitHub上找到此方法的完整源代码。
本文章原文地址: https://blog.logrocket.com/electron-ipc-response-request-architecture-with-typescript/