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

本文最后更新于:2021年4月8日 下午

场景

由于 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 在逐渐发展,但目前仍然没有足够好用,所以吾辈不得不进行了封装。


electron 开发经验之谈系列-渲染、主进程通信
https://blog.rxliuli.com/p/76393a60949c47c7add910df0206734c/
作者
rxliuli
发布于
2021年1月5日
许可协议