今天我们来聊一聊 Electron 中的多窗口应用。Electron 作为一个流行的前端架构,可以让我们利用已有的前端技术栈,快速高效地开发基于单页面应用的 Windows 和 Mac 客户端。常用的 Angular、React 等框架,已经很好地提供了单页面应用的解决方案,给我们提供了一些规范,帮助我们提高代码的可维护性和复用性。
但是也因为这些框架是为网页应用设计的,所以他们主打的方向也是单页面应用,并没有给我们提供任何多页面应用的蓝图。然而在 Electron 应用中,如果我们想给用户提供更接近 Native 的体验, 多窗口应用在合适的情景下可以大幅提升用户的体验和生产力。
Electron 进程框架回顾
在谈论多窗口应用之前,我们首先快速复习一下 Electron 与其他 Native 解决方案的不同之处。由于 Electron 是基于 Chromium 这个浏览器引擎,所以进程模型也是与之相同的,即:进程类型分为"主进程"和"渲染进程""渲染进程",且每一个窗口都是单独的渲染进程。

这节课我们主要关注主进程与渲染进程的交互。其中:
- 主进程
- 负责控制整个应用的生命周期以及状态
- 可以使用 BrowserWindow 等模块
- 渲染进程
- 负责渲染单个窗口的 UI
- 无法使用 BrowserWindow 等模块
子窗口管理
通过上面的回顾我们就很快地发现了几个问题:
-
窗口之间的通信全部都是跨进程通信,且窗口之间是独立的进程,因此它们之间的数据无法直接共享。
-
创建、关闭子窗口等操作都需要在主进程进行,但是触发创建独立窗口的业务则是在渲染进程中发生的。
-
因为窗口是在主进程创建的,所以渲染进程最初是无法获取新渲染进程或父渲染进程的 WebContentsId 的。
于是在开发多窗口应用前,应该首先对窗口的创建、销毁等关键接口进行封装,以提高后续的开发效率。整体架构为 WindowServer 与 WindowClient。
- WindowServer
- 存在于主进程
- 实际创建、销毁新的渲染进程
- 为父渲染进程与子渲染进程提供彼此的 WebContentsId,以供通信使用
- WindowClient
-
存在于渲染进程中
-
封装窗口创建、关闭、发送消息等接口
-
在收到上述接口调用时,将指令转发给 WindowServer,让 WindowServer 进行相关操作
一些可供参考的比较有用的接口包括:
-
plain
interface IWindow {
get onInit(): Observable<void>;
sendMessage(message: any): void;
close(): void;
get onMessage(): Observable<any>;
get OnClose(): Observable<void>;
}
WindowSdkClient {
get parentWindow(): IWindow;
createWindow(): void;
}
可以看到,这些都是较为常用的接口。封装了发送与接收消息、关闭窗口与窗口关闭事件等接口。SdkClient,即渲染进程中的部分,封装创建新子窗口的流程以及获取父窗口的对象。
创建子窗口的流程图

对照上面的时序图来看,由于 Electron 中窗口的管理只能在主进程中进行。所以创建新窗口的流程需要:
- 根渲染进程发送消息,通知主进程创建子窗口。
- 主进程创建窗口后,向根渲染进程反馈子窗口的 WebContentsId(后续发送消息与识别消息来源需要使用)。这里需要注意,此时还无法进行通信,因为只是创建了子窗口,子窗口中的渲染进程还未初始化成功。
- 子渲染进程初始化成功后,通知主进程自己已初始化,并可以接受消息。
- 主进程通知子渲染进程自己父(根)渲染进程的 WebContentsId。
- 之后双方即可进行通信了。这里需要注意,父渲染进程收到子渲染进程第一条消息之前,不可向子渲染进程发送消息,因为无法保证子渲染进程已初始化。
多窗口数据同步
有了以上的窗口管理模块后,基于 Electron 的多窗口应用开发会顺利一些,但是紧接着要面对的问题就是 数据同步 了。
由于每个窗口都是自己的渲染进程,所以数据无法直接共享,需要通过跨进程通信发送给目标窗口。若有数据需要在多个窗口之间同步,也需要跨进程通信将新的数据发送到目标窗口。而进程与进程之间又是完全异步的,所以随意地开发可能会引入预期之外的同步问题。
为了应对上述问题,我在这里提出一个多窗口应用的主要原则: 状态与数据只在根窗口做改变。
就像 Chromium 通过主进程集中管理资源,或是 Redux 通过 Store 集中管理数据更新,为了可扩展性和可维护性,我们的多窗口应用也应该以根窗口(也就是最初的 SPA 应用窗口)的数据为准。
继续找 Redux 的例子来讲,如果子窗口需要对数据进行改动,应该将改动请求(类比 Redux Action)发送到主窗口。主窗口收到消息后,再更新数据。然后把最新的数据同步回各子窗口。子窗口就只负责相应数据的变化而展示 UI。
这种策略把数据通信的方向变成了"子窗口向根窗口"与"根窗口向子窗口"两种,所以可以有效降低多窗口通信的维护成本。同时因为所有数据处理都是在根窗口进行的,这种策略可以避免大部分数据同步问题而引起的 bug。
上述的策略可以应用在一些已有的技术架构之上,使业务组件的多窗口化迁移变得相对无痛。以 Angular/NgRx 为例,可以通过封装 store.dispatch、createFeatureSelector、createSelector 以及 Action 来实现子窗口的自动注册、Action 的自动转发以及 store 数据的自动同步。

这样做的一大好处是处理跨窗口通信的逻辑都封装在底层状态管理的代码中。只要利用好职能分离的原则,就可以顺利地将根渲染进程的组件迁移到子渲染进程中。可能只需要调整一下整体布局就可以完成了。
数据传输开销
Electron 环境下有以下一些跨进程通信的方法:
- Electron IPC
- Electron 提供的跨进程通信,可以直接使用
- 效率相比之下较低
- 传输二进制数据会先序列化成字符串
- 渲染进程 <-> 渲染进程 的数据传输,实际上通过主进程的中转了
- WebSocket
- 可以直接传输二进制数据,但是会对数据进行 XOR 校验
- 可以直接放到根窗口,去除主进程中转的环节
- 数据会走一趟设备的网卡
- 增加额外的封装和状态管理
- Node childProcess.pipe
- 二进制数据可以直接传输,无需序列化
- 属于 NodeJS 的 API,渲染进程里使用需要特别开启 Node API 的选项
- 开发成本更高一些,需要进一步封装
- 渲染进程之间传输数据同样需要从主进程中转
- Shared Memory
-
Node 自身不提供进程之间的共享内存
-
需要利用 Node Addon,在 C++ 中调用系统的 Shared Memory API
- 可以使用 Boost 等支持多平台的第三方库(需要确认是否支持自己发布的所有平台)
-
增添复杂同步逻辑,开发和维护难度较大
-
对于数据量大的情况,这是效率最高的方法
原则上,跨窗口通信如果只是同步应用状态,只要数据量不是大得离谱,Electron IPC 即使是相比之下性能最差的那个,但是也完全够用。
-
如果因为需要复用固有代码、引擎之类的,导致视频等大块数据无法从子窗口获取,那就可以考虑后面的几个选项。其开发封装层的难度是从易到难的。
WebSocket 和 Node childProcess.pipe 都只是解决了 Electron IPC 序列化的问题,没有解决跨进程通信的 overhead。但是如果需要传输的数据不算太大,如一个低分辨率视频,还是可以考虑的。
但是如果数据量传输超过一两个低分辨率视频的量,则需要认真考虑利用 Node Addon 来使用跨进程的共享内存。截至目前,还没有可以使用的第三方库,所以这就需要自己去开发一套了(可以考虑封装一下 Boost 等较成熟的 C++ 跨平台 Shared Memory 方案)。但即使开发完成了,对这一块内存的管理也会是一个容易出错的地方,需要多注意。
总结
以上就是我这次和你分享的内容,下面我们做一个简单的总结吧!
多窗口应用在桌面程序中的合理应用可以有效提高用户体验与工作效率。 但是这同时也为我们的开发带来了相应的困难,把 JavaScript 原本最大的原则之一,也就是"顺序执行"打破了,变成了跨进程的异步运行。但是如果使用上面介绍的状态同步框架,就可以再次把平行运行的两个进程掰回到顺序执行的模式。如此可以大幅降低状态同步而引入的一系列问题。同时也可以减少代码对多窗口的适配,实现一套代码在多个窗口中运行的效果,可以提高代码的可维护性与可复用性。
