electron中集中管理和分发electronIPC调用

在 electron 开发中,如果需要从渲染进程中调用主进程,最新版 electron 一般需要通过 preload.js 实现 bridge 桥接。进而通过 ipc 通信机制实现俩进程的通信。其实这是一种类似于手机端 webview 和 native 的 bridge 通信机制。

但每次声明一个方法都要写一次 preload.js 和 main.js 里面的代码会很麻烦。我们应该设计一种通用的调用方法,从而避免每次新增一个方法都要去 preload 和 main.js 中去编写代码。

思路

具体思路就是,我们只在 preload 中设计一个叫做 invoke 的 api,然后其声明如下:

1
2
3
4
5
6
7
8
9
10
11
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("electronAPI", {
invoke: async (name, data) => {
const res = await ipcRenderer.invoke("invoke", {
name,
data,
});
return res;
},
});

如此一来,我们页面中的渲染进程中,就可以 通过这个唯一的 invoke 函数来调用所有主进程方法。例如像如下方法来调用不同的方法:

1
2
3
4
5
6
7
8
9
// 对窗口进行相关操作(例如关闭、最小化等)
window.electronAPI.invoke("processWindow", {
op: "close",
});
window.electronAPI.invoke("processWindow", {
op: "minimize",
});
// 退出 electron程序
window.electronAPI.invoke("exit");

主进程集中管理“具体实现”

有了统一的函数,仅仅是解决了调用方以及 preload 中声明方法的麻烦。但我们依然避免不了在主进程中要对所有的被调方法要全部实现一遍,这个是无法避免的。

但为了更好的管理和维护,我们应当尽可能集中和优雅的把所有方法的具体实现给管理起来,而不是直接编写到 main.js 中,这会导致 bridge 的各种实现逻辑全部散落在 main.js 中,导致 main.js 不可维护。

此处我采用的解决方案就是采用一个统一的 jsbridge.js 文件来管理所有的被调方法。当 ipcMain 收到渲染层的调用时,就将调用分发给 jsbridge.js 中对应的函数即可。

先看下 main.js 中的核心逻辑:

1
2
3
4
5
6
7
8
import { app } from "electron";
import initBridge from "./main-process/bridge.js";

app.whenReady().then(() => {
createWindow();
// 通过 initBridge 初始化 ipcMain 的监听。
initBridge(app);
});

下面我们再来看 bridge.js 的实现:

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
import { getMachineId as getMachineIdLib } from "./machine";
import { BrowserWindow, ipcMain } from "electron";

let app = null;
// 核心分发逻辑
export default function initBridge(mainApp) {
app = mainApp;
ipcMain.handle("invoke", async (event, payload) => {
if (bridgeApis[payload?.name]) {
return bridgeApis[payload.name](
payload.data,
BrowserWindow.fromWebContents(event.sender).id
);
} else {
return "方法不存在";
}
});
}

// 这里是具体函数的实现
const bridgeApis = {
getMachineId() {
// ... 具体api实现
},
exit() {
// ... 具体api实现
},
processWindow(requestData, windowId) {
// ... 具体api实现
},
};

像上面这样编写代码,我们就把 bridge 的所有实现都集中到了 bridgeApis 这个对象当中。甚至为了更简洁,我们可以将 bridgeApis 也抽离到一个 bridge-apis.js 单独文件中,会看起来更有条例。

窗口和 app 实例传递

如果某个 api 需要对 app 实例或 windows 窗口进行处理,那么我们 ipcMain 收到调用后,需要将调用者的窗口对象以及 app 实例传递给对应的 bridgeApis 里的处理函数。如果你在 ipcMain.handle("invoke", (event) => {}) 这里直接将 event 或 event.sender 对象传递给其他函数,那么 electron 会提示报错“can not clone the object”。

原因是 event 或 event.sender 对象属于复杂对象,无法被直接传递。这里的解决办法如上文代码所示,就是利用 BrowserWindow.fromWebContents(event.sender).id 从 sender 转成 window 对象,然后从 window 对象上拿下来 id。之后,我们再把窗口 id 传给对应 api 的处理函数。

接下来,在对应 api 处理函数中,再用 const win = BrowserWindow.fromId(windowId); 从 id 转回窗口对象,从而对窗口对象进行相关处理。

至于 app 实例对象,我们直接在 initBridge 的时候,就已经闭包存储到了 bridge.js 模块当中,所以各个 jsbridge 方法都是可以拿到闭包内的 app 对象进行处理的。

关于自定义标题栏

顺带说一下 electron 中如何设置透明区域,以及自定义标题栏。

在某些场景下,我们可能迫不得已需要自定义标题栏,例如你希望在窗口的某些部分设置“透明效果”,基于此你需要在 main 进程中创建 window 的时候就把窗口设置成 “transparent”。然而一旦 transparent,则意味着窗口原始的标题栏就看不到了。于是你只能退而求其次,将原始窗口标题栏隐藏,并自己实现一个标题栏。

下面我们看一下实现自定义标题栏步骤:

  1. main.js 中配置窗口的属性,隐藏掉原始标题栏。
1
2
3
4
5
6
const createWindow = () => {
const mainWindow = new BrowserWindow({
frame: false, // 禁用原始标题栏
transparent: true, // 如果需要窗口透明,就设置透明
});
};
  1. 在视图层代码中,自定义实现顶部标题栏样式。

我用的是 vue.js 实现视图,所以我得代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div class="page-app">
<!-- 下面是标题栏 -->
<div class="titlebar">
<div class="logo-title">
<img src="./assets/images/logo-circle.png" alt="" @click="goToAbout" />
<span>我的应用</span>
</div>
<div class="tools">
<button @click="invokeApi('minimize')">-</button>
<button @click="invokeApi('maximize')"></button>
<button @click="invokeApi('close')">×</button>
</div>
</div>
<!-- 下面是标题栏下方主体容器 -->
<div class="content">
<RouterView />
</div>
</div>

最后,呈上最终效果: