本文最后更新于:2021年2月19日 上午
场景
由于 electron 应用分为主进程、渲染进程,所以它们之间需要通信。而 electron 本身实现了一个简单的 event emitter 通信模型,虽然能用,但并不足够好。
参考: https://www.electronjs.org/docs/api/ipc-renderer
问题
- 基于字符串和约定进行通信本质上和当下前后端通信差不多,没有利用同构优势
- 使用起来没有任何限制,意味着很难维护(非强制性的约定基本上都很难生效)
思考
那么一共 electron 进程通信有哪些情况呢?
- 渲染进程 => 主进程
- 主进程 => 渲染进程
- 渲染进程 => 渲染进程
而其中最常用的便是 渲染进程 => 主进程
其实吾辈也看过许多 electron 进程通信的 封装库 或者类似场景的 rpc 实现 comlink,但最终还是决定使用接口 + 主进程实现 + 渲染层根据接口生成 Client 的方式实现。
最终,吾辈选择了接口 + 实现类的基本模式
设计图.drawio
实现渲染进程 => 主进程
首先在创建 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>();
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];
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 {
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); }; }, }); }
static getRenderer(): IpcRenderer | null { if (!isElectron()) { return null; } return window.require("electron").ipcRenderer as IpcRenderer; } }
|
使用
在 apps 下创建一个模块 shared_type
,里面包含一些渲染进程与主进程之间共享的类型,下面是一个简单的示例
1 2 3 4
| export interface HelloDefine extends BaseDefine<"HelloApi"> { hello(name: string): string; }
|
在主进程中使用 class 实现它并注册
1 2 3 4 5 6 7 8 9 10
|
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); }
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
|
export type IpcClientDefine<T extends object> = { [P in FunctionKeys<T>]: ( ...args: Parameters<T[P]> ) => Promise<ReturnType<T[P]>>; };
export class IpcMainClient {
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 在逐渐发展,但目前仍然没有足够好用,所以吾辈不得不进行了封装。