场景
由于 electron 应用分为主进程、渲染进程,所以它们之间需要通信。而 electron 本身实现了一个简单的 event emitter 通信模型,虽然能用,但并不足够好。
问题
- 基于字符串和约定进行通信本质上和当下前后端通信差不多,没有利用同构优势
- 使用起来没有任何限制,意味着很难维护(非强制性的约定基本上都很难生效)
思考
那么一共 electron 进程通信有哪些情况呢?
- 渲染进程 => 主进程
- 主进程 => 渲染进程
- 渲染进程 => 渲染进程
而其中最常用的便是 渲染进程 => 主进程
其实吾辈也看过许多 electron 进程通信的 封装库 或者类似场景的 rpc 实现 comlink,但最终还是决定使用接口 + 主进程实现 + 渲染层根据接口生成 Client 的方式实现。
最终,吾辈选择了接口 + 实现类的基本模式
实现渲染进程 => 主进程
首先在创建 libs 目录用以存放通用模块(非业务),然后创建三个模块
electron_ipc_type
: 一些需要引入的类型electron_ipc_main
: 主进程封装electron_ipc_renderer
: 渲染层封装
此处使用 rollup 进行打包
大致实现
electron_ipc_type: 通用的基本接口定义,必须包含一个 namespace
属性
export interface BaseDefine<T extends string> {
namespace: T
}
electron_ipc_main: 封装主进程实现相关代码,主要保证类型安全
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: 渲染进程
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
,里面包含一些渲染进程与主进程之间共享的类型,下面是一个简单的示例
// HelloDefine.ts
export interface HelloDefine extends BaseDefine<'HelloApi'> {
hello(name: string): string
}
在主进程中使用 class 实现它并注册
// main.ts
class HelloApi {
async hello(e: IpcMainInvokeEvent, name: string) {
return `hello ${name}`
}
}
const ipcMainProvider = new IpcMainProvider()
ipcMainProvider.register<HelloDefine>('HelloApi', new HelloApi())
在渲染进程中创建客户端对象并使用
const helloApi = IpcRendererClient.gen<HelloDefine>('HelloApi')
const str = await helloApi.hello('liuli')
实现主进程 => 渲染进程
由于吾辈的 ui 层框架使用了 react,所以基于 class 的模式在此并不适用,需要使用某种变通的方式(吾辈仍然不愿意放弃将 class 作为命名空间的想法)。
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)
}
}
/**
* 转换为一个渲染进程可以调用的 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 注册它
const ipcRendererProvider = new IpcRendererProvider<HelloApiDefine>('HelloApi')
ipcRendererProvider.useIpcProvider('hello', async (e, name) => {
return `hello ${name}`
})
在主进程生成客户端实例调用它
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 在逐渐发展,但目前仍然没有足够好用,所以吾辈不得不进行了封装。