场景

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

参考: https://www.electronjs.org/docs/api/ipc-rendereropen in new window

问题

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

思考

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

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

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

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

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

设计图.drawio.svg

实现渲染进程 => 主进程

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