electron 开发经验之谈系列-渲染、主进程通信

本文最后更新于:2021年3月21日 早上

场景

由于 electron 应用分为主进程、渲染进程,所以它们之间需要通信。而 electron 本身实现了一个简单的 event emitter 通信模型,虽然能用,但并不足够好。

参考: https://www.electronjs.org/docs/api/ipc-renderer

问题

  • 基于字符串和约定进行通信本质上和当下前后端通信差不多,没有利用同构优势
  • 使用起来没有任何限制,意味着很难维护(非强制性的约定基本上都很难生效)

思考

那么一共 electron 进程通信有哪些情况呢?

  • 渲染进程 => 主进程
  • 主进程 => 渲染进程
  • 渲染进程 => 渲染进程

而其中最常用的便是 渲染进程 => 主进程

其实吾辈也看过许多 electron 进程通信的 封装库 或者类似场景的 rpc 实现 comlink,但最终还是决定使用接口 + 主进程实现 + 渲染层根据接口生成 Client 的方式实现。

最终,吾辈选择了接口 + 实现类的基本模式

设计图.drawio.svg

实现渲染进程 => 主进程

首先在创建 libs 目录用以存放通用模块(非业务),然后创建三个模块

  • electron_ipc_type: 一些需要引入的类型
  • electron_ipc_main: 主进程封装
  • electron_ipc_renderer: 渲染层封装

此处使用 rollup 进行打包

大致实现

electron_ipc_type: 通用的基本接口定义,必须包含一个 namespace 属性

1
2
3
export interface BaseDefine<T extends string> {
namespace: T;
}

electron_ipc_main: 封装主进程实现相关代码,主要保证类型安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
type FilteredKeys<T, U> = {
[P in keyof T]: T[P] extends U ? P : never;
}[keyof T];

/**
* 转换为一个主进程可以实现的接口
*/
export type IpcMainDefine<T> = {
[P in FilteredKeys<T, (...args: any[]) => void>]: (
e: IpcMainInvokeEvent,
...args: Parameters<T[P]>
) => Promise<ReturnType<T[P]>>;
};

export class IpcMainProvider {
private readonly clazzMap = new Map<string, object>();

/**
* 计算主进程监听的 key
* @param namespace
* @param method
* @private
*/
private static getKey<T>(namespace: string, method: PropertyKey) {
return namespace + "." + method.toString();
}

register<T extends BaseDefine<string>>(
namespace: T["namespace"],
api: IpcMainDefine<T>
): IpcMainDefine<T> {
const instance = ClassUtil.bindMethodThis(api);
const methods = ClassUtil.scan(instance);
methods.forEach((method) => {
const key = IpcMainProvider.getKey(namespace, method);
ipcMain.handle(key, instance[method] as any);
console.log("Register ipcMain.handle: ", key);
});
this.clazzMap.set(namespace, instance);
return instance;
}

unregister<T extends BaseDefine<string>>(
namespace: T["namespace"],
api: IpcMainDefine<T>
): void {
const methods = ClassUtil.scan(api);
methods.forEach((method) => {
const key = IpcMainProvider.getKey(namespace, method);
ipcMain.removeHandler(key);
});
this.clazzMap.delete(namespace);
}
}

electron_ipc_renderer: 渲染进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export type FilteredKeys<T, U> = {
[P in keyof T]: T[P] extends U ? P : never;
}[keyof T];

/**
* 转换为一个渲染进程可以调用的 Proxy 对象
*/
export type IpcRendererDefine<T> = {
[P in FilteredKeys<T, (...args: any[]) => void>]: (
...args: Parameters<T[P]>
) => Promise<ReturnType<T[P]>>;
};

export class NotElectronEnvError extends Error {}

export class IpcRendererClient {
/**
* 生成一个客户端实例
* @param namespace
*/
static gen<T extends BaseDefine<string>>(
namespace: T["namespace"]
): IpcRendererDefine<T> {
return new Proxy(Object.create(null), {
get(target: any, api: string): any {
const key = namespace + "." + api;
return function (...args: any[]) {
const ipcRenderer = IpcRendererClient.getRenderer();
if (!ipcRenderer) {
throw new NotElectronEnvError("当前你不在 electron 进程中");
}
return ipcRenderer.invoke(key, ...args);
};
},
});
}

/**
* 获取 electron ipc renderer 实例
*/
static getRenderer(): IpcRenderer | null {
if (!isElectron()) {
return null;
}
return window.require("electron").ipcRenderer as IpcRenderer;
}
}

使用

在 apps 下创建一个模块 shared_type,里面包含一些渲染进程与主进程之间共享的类型,下面是一个简单的示例

1
2
3
4
// HelloDefine.ts
export interface HelloDefine extends BaseDefine<"HelloApi"> {
hello(name: string): string;
}

在主进程中使用 class 实现它并注册

1
2
3
4
5
6
7
8
9
10
// main.ts

class HelloApi {
async hello(e: IpcMainInvokeEvent, name: string) {
return `hello ${name}`;
}
}
const ipcMainProvider = new IpcMainProvider();

ipcMainProvider.register<HelloDefine>("HelloApi", new HelloApi());

在渲染进程中创建客户端对象并使用

1
2
3
const helloApi = IpcRendererClient.gen<HelloDefine>("HelloApi");

const str = await helloApi.hello("liuli");

实现主进程 => 渲染进程

由于吾辈的 ui 层框架使用了 react,所以基于 class 的模式在此并不适用,需要使用某种变通的方式(吾辈仍然不愿意放弃将 class 作为命名空间的想法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
type IpcRendererProviderDefine<
T extends BaseDefine<string>,
P extends FunctionKeys<T> = FunctionKeys<T>
> = [
type: P,
callback: (e: any, ...args: Parameters<T[P]>) => Promise<ReturnType<T[P]>>
];

type IpcRendererProviderHooksDefine<
T extends BaseDefine<string>,
P extends FunctionKeys<T> = FunctionKeys<T>
> = [
type: P,
callback: (e: any, ...args: Parameters<T[P]>) => Promise<ReturnType<T[P]>>,
deps?: DependencyList
];

/**
* 在渲染层管理提供者
*/
export class IpcRendererProvider<T extends BaseDefine<any>> {
private apiMap = new Map<string, (...args: any[]) => any>();

constructor(private namespace: T["namespace"]) {}

register(...[type, callback]: IpcRendererProviderDefine<T>) {
const ipcRenderer = IpcRendererClient.getRenderer();
if (ipcRenderer === null) {
console.warn("不在 electron 环境,取消注册: ", type);
return;
}
const key = this.namespace + "." + type;
console.log("IpcRendererProvider.register: ", key);
const listener = async (event: any, id: string, ...args: any[]) => {
try {
console.log("IpcRendererProvider.listener: ", event, id, args);
const res = await callback(event, ...(args as any));
await ipcRenderer.send(id, null, res);
} catch (e) {
await ipcRenderer.send(id, e);
}
};
ipcRenderer.on(key, listener);
this.apiMap.set(key, listener);
}

unregister(type: IpcRendererProviderDefine<T>[0]) {
const ipcRenderer = IpcRendererClient.getRenderer();
if (ipcRenderer === null) {
return;
}
const key = this.namespace + "." + type;
ipcRenderer.off(key, this.apiMap.get(key)!);
this.apiMap.delete(key);
}

/**
* react 中的注册钩子,自动管理清理的操作
* @param type
* @param callback
* @param deps
*/
useIpcProvider(
...[type, callback, deps = []]: IpcRendererProviderHooksDefine<T>
) {
useEffect(() => {
this.register(type, callback);
return () => this.unregister(type);
}, deps);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 转换为一个渲染进程可以调用的 Proxy 对象
*/
export type IpcClientDefine<T extends object> = {
[P in FunctionKeys<T>]: (
...args: Parameters<T[P]>
) => Promise<ReturnType<T[P]>>;
};

/**
* 客户端
*/
export class IpcMainClient {
/**
* 生成一个客户端实例
* @param namespace
* @param win
*/
static gen<T extends BaseDefine<string>>(
namespace: T["namespace"],
win: BrowserWindow
): IpcClientDefine<T> {
return new Proxy(Object.create(null), {
get<K extends FunctionKeys<T>>(target: any, api: K): any {
const key = namespace + "." + api;
return function (...args: any[]) {
return new Promise<ReturnType<T[K]>>((resolve, reject) => {
const id = Date.now() + "-" + Math.random();
ipcMain.once(id, (event, err, res) => {
console.log("callback: ", err, res);
if (err) {
reject(err);
return;
}
resolve(res);
});
console.log("send: ", key, id, args);
win.webContents.send(key, id, ...(args as any));
});
};
},
});
}
}

使用

在渲染进程使用 hooks 注册它

1
2
3
4
5
const ipcRendererProvider = new IpcRendererProvider<HelloApiDefine>("HelloApi");

ipcRendererProvider.useIpcProvider("hello", async (e, name) => {
return `hello ${name}`;
});

在主进程生成客户端实例调用它

1
2
3
4
5
const helloApi = IpcMainClient.gen<HelloApiDefine>(
"HelloApi",
new BrowserWindow()
);
const str = await helloApi.hello("liuli");

约定俗成

  • shared_type 模块中的接口定义总是 *Define 形式,且实现的 BaseDefine<T> 泛型参数是 *Api 形式
  • main 模块中实现的 class 总是 *Api 形式
  • renderer 模块中获取的 client 实例总是 *Api 小写驼峰形式
  • 实现 BaseDefine<T> 传入的命名空间参数不应该重复

总结

electron 本身的进程通信 api 在逐渐发展,但目前仍然没有足够好用,所以吾辈不得不进行了封装。