使用Typescript构架Electron的IPC响应与请求

Electron的工作方式非常简单。有两种不同的进程-主进程(Main Process)渲染进程(Renderer Process)。始终只保持有一个主进程,这是您Electron应用程序的入口。可以有任意数量的渲染器进程,这些进程负责渲染您的应用程序。

这些进程间的通信是通过IPC(进程间通信)完成的。听起来可能很复杂,但这只是异步请求-响应模式的一个好听的名字。

渲染器与主进程之间的通信在后台发生的事情基本上只是事件调度。例如,假设您的应用程序应显示有关其运行系统的信息。这可以通过一个简单的命令uname -a来完成,该命令显示您的内核版本。但是您的应用程序本身无法此执行命令,因此需要主进程。在Electron应用程序中,您的应用程序可以访问渲染器进程(ipcRenderer)。

以下是将要发生的事情:

  1. 您的应用程序将利用ipcRenderer向主进程发送事件。这些事件称为Electron内部的通道。

  2. 如果主进程正确的注册了事件侦听器(用于侦听刚刚调度的事件),则可以为该事件正确的运行代码。

  3. 完成所有操作后,主进程可以为结果发出另一个事件(在我们的示例中为内核版本)。

4. 现在整个工作流程都以相反的方式进行,渲染器流程需要为主流程中分派的事件实现一个侦听器。

  1. 当渲染器进程收到包含我们所需信息的适当事件时,在界面上显示该信息。

最终,整个过程可以看作是一个简单的请求-响应模式,有点像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/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容