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 属性

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