{T}

如何管控应用的窗口(下)?

在上一节中,我们介绍了一个困扰业界很久的问题:窗口加载过慢的问题,并且介绍了解决这个问题的一种方案(窗口池方案),然而这个方案存在几个缺点,所以本节我们将详细分析另外一种方案。

我们知道 Electron 是允许使用 window.open 的方式打开一个子窗口,通过这种方式打开的子窗口不会创建新的进程,效率非常高,可以在几百毫秒内就为用户呈现窗口内容

虽然这个方案可以应对一般的多窗口应用的需求,但对于一些复杂的需求却需要很多额外的处理才能满足需求,比如:系统设置子窗口,当用户完成某一项设置之后,要通知父窗口做出相应的改变。这是常见的父子窗口通信的需求。

本节我们就讲解如何实现 window.open 创建子窗口的方案,以及如何解决这个方案带来的窗口间通信的问题。

window.open 解决方案

Electron 允许渲染进程通过window.open打开一个新窗口,但这需要做一些额外的设置。

首先需要为主窗口的webContents注册setWindowOpenHandler方法。

ts
//src\main\CommonWindowEvent.ts
mainWindow.webContents.setWindowOpenHandler((param) => {
  return { action: "allow", overrideBrowserWindowOptions: yourWindowConfig };
});

我们在上面的代码中使用setWindowOpenHandler方法的回调函数返回一个对象,这个对象中action: "allow"代表允许窗口打开,如果你想阻止窗口打开,那么只要返回{action: "deny"}即可。

返回对象的overrideBrowserWindowOptions属性的值是被打开的新窗口的配置对象。

在渲染进程中打开子窗口的代码如下所示:

ts
//src\renderer\Component\BarLeft.vue
window.open(`/WindowSetting/AccountSetting`);

window.open打开新窗口之所以速度非常快,是因为用这种方式创建的新窗口不会创建新的进程。这也就意味着一个窗口崩溃会拖累其他窗口跟着崩溃(主窗口不受影响)。

使用window.open打开的新窗口还有一个问题,这类窗口在关闭之后虽然会释放掉大部分内存,但有一小部分内存无法释放(无论你打开多少个子窗口,全部关闭之后总会有那么固定的一小块内存无法释放),这与窗口池方案的内存损耗相当。

这个问题可能与 Electron 的这个 Issue 有关:window.open with nativeWindowOpen option causes memory leak

同样使用这个方案也无法优化应用的第一个窗口的创建速度。而且<webview>BrowserView慢的问题无法使用这个方案解决(这类需求还是应该考虑“池”方案)。

但是通过window.open打开的新窗口更容易控制,这是这个方案最大的优点。接下来我们就介绍如何使用这个方案控制子窗口。

子窗口的标题栏消息

在上一节中,我们自定义了主窗口的标题栏BarTop.vue,我们知道标题栏组件需要监听主进程发来的windowMaximized消息和windowUnmaximized消息,子窗口当然也希望复用这个组件,然而子窗口的窗口对象是在 Electron 内部创建的,不是我们开发者创建的,没有子窗口的窗口对象,我们该如何使用上一节介绍的regWinEvent方法为子窗口注册最大化和还原事件呢?

这就需要用到 app 对象的browser-window-created事件,代码如下:

ts
//src\main\mainEntry.ts
app.on("browser-window-created", (e, win) => {
  CommonWindowEvent.regWinEvent(win);
});

每当有一个窗口被创建成功后,这个事件就会被触发,主窗口和使用window.open创建的子窗口都一样,所以之前我们为主窗口注册事件的代码CommonWindowEvent.regWinEvent(mainWindow)也可以删掉了。这个事件的第二个参数就是窗口对象。

动态设置子窗口的配置

虽然我们可以在渲染进程中用window.open方法打开一个子窗口,但这个子窗口的配置信息目前还是在主进程中设置的(overrideBrowserWindowOptions),大部分时候我们要根据渲染进程的要求来改变子窗口的配置,所以最好的办法是由渲染进程来设置这些配置信息。

我们为上一节介绍的CommonWindowEvent类的regWinEvent方法增加一段逻辑,代码如下:

ts
//src\main\CommonWindowEvent.ts
//注册打开子窗口的回调函数
win.webContents.setWindowOpenHandler((param) => {
  //基础窗口配置对象
  let config = {
    frame: false,
    show: true,
    webPreferences: {
      nodeIntegration: true,
      webSecurity: false,
      allowRunningInsecureContent: true,
      contextIsolation: false,
      webviewTag: true,
      spellcheck: false,
      disableHtmlFullscreenWindowResize: true,
      nativeWindowOpen: true,
    },
  };
  //开发者自定义窗口配置对象
  let features = JSON.parse(param.features);
  for (let p in features) {
    if (p === "webPreferences") {
      for (let p2 in features.webPreferences) {
        config.webPreferences[p2] = features.webPreferences[p2];
      }
    } else {
      config[p] = features[p];
    }
  }
  if (config["modal"] === true) config.parent = win;
  //允许打开窗口,并传递窗口配置对象
  return { action: "allow", overrideBrowserWindowOptions: config };
});

在这段代码中,config对象和主窗口的config对象基本上是一样的,所以最好把它抽象出来,我们这里为了演示方便没做这个工作。

param参数的features属性是由渲染进程传过来的,是一个字符串,这里我们把它当作一个 JSON 字符串使用,这个字符串里包含着渲染进程提供的窗口配置项,这些配置项与 config 对象提供的基础配置项结合,最终形成了子窗口的配置项。

如果配置项中modal属性的值为true的话,说明渲染进程希望子窗口为一个模态窗口,这时我们要为子窗口提供父窗口配置项:parent,这个配置项的值就是当前窗口。

之所以把这段逻辑放置在CommonWindowEvent类的regWinEvent方法中,就是希望更方便地为应用内的所有窗口提供这项能力,如果你不希望这么做,也可以把这段逻辑放置在一个独立的方法中。

现在渲染进程中打开子窗口的代码可以变成这样了:

ts
//src\renderer\Component\BarLeft.vue
let openSettingWindow = () => {
  let config = { modal: true, width: 2002, webPreferences: { webviewTag: false } };
  window.open(`/WindowSetting/AccountSetting`, "_blank", JSON.stringify(config));
};

window.open方法的第三个参数官方定义为一个逗号分割的 key-value 列表,但这里我们把它变成了一个 JSON 字符串,这样做主要是为了方便地控制子窗口的配置对象。

使用window.open打开新窗口速度非常快,所以这里我们直接让新窗口显示出来了config.show = true。如果你需要在新窗口初始化时完成复杂耗时的业务逻辑,那么你也应该手动控制新窗口的显示时机。就像我们控制主窗口一样。

封装子窗口加载成功的事件

现在我们遇到了一个问题:不知道子窗口何时加载成功了,注意这里不能单纯地使用window对象的onload事件或者 document 对象的DOMContentLoaded事件来判断子窗口是否加载成功了。因为这个时候你的业务代码(比如从数据库异步读取数据的逻辑)可能尚未执行完成。

所以,我们要自己封装一个事件,在我们的业务代码真正执行完成时,手动发射这个事件,告知主窗口:“现在子窗口已经加载成功啦,你可以给我发送消息了!”

在封装这个事件前,我们先来把window.open打开子窗口的逻辑封装到一个Promise对象中,如下代码所示:

ts
//src\renderer\common\Dialog.ts
export let createDialog = (url: string, config: any): Promise<Window> => {
  return new Promise((resolve, reject) => {
    let windowProxy = window.open(url, "_blank", JSON.stringify(config));
    let readyHandler = (e) => {
      let msg = e.data;
      if (msg["msgName"] === `__dialogReady`) {
        window.removeEventListener("message", readyHandler);
        resolve(windowProxy);
      }
    };
    window.addEventListener("message", readyHandler);
  });
};

在这段代码中,我们把渲染进程的一些工具方法和类放置在src\renderer\common\目录下(注意,有别于src\common\目录)。

当渲染进程的某个组件需要打开子窗口时,可以使用Dialog.ts提供的createDialog方法。

在这段代码中,我们把window.open的逻辑封装到一个Promise对象中, 通过window.open打开子窗口后,当前窗口马上监听message事件,子窗口有消息发送给当前窗口时,这个事件将被触发。

我们在message事件的处理函数中完成了下面三个工作。

  1. e.data 里存放着具体的消息内容,我们把它格式化成一个 JSON 对象。
  2. 如果这个 JSON 对象的msgName属性为__dialogReady字符串,我们就成功resolve
  3. Promise对象成功resolve之前要移除掉message事件的监听函数,避免内存泄漏(如果不这么做,用户每打开一个子窗口,就会注册一次message事件)。

window.open方法返回的是目标窗口的引用,我们可以使用这个引用对象向目标窗口发送消息,或执行其他相关操作。

Dialog.ts并非只导出了createDialog这么一个方法,它还导出了dialogReady方法,代码如下所示:

ts
//src\renderer\common\Dialog.ts
export let dialogReady = () => {
  let msg = { msgName: `__dialogReady` };
  window.opener.postMessage(msg);
};

这个方法是为子窗口服务的,当子窗口完成了必要的业务逻辑之后,就可以执行这个方法,通知父窗口自己已经加载成功。

这个方法通过window.opener对象的postMessage方法向父窗口发送了一个消息,这个消息的内容是一个 JSON 对象,这个 JSON 对象的msgName属性为__dialogReady字符串。

父窗口收到子窗口发来的这个消息后,将触发message事件,也就会执行我们在createDialog方法中撰写的逻辑了。

父子窗口互相通信

我们可以使用 createDialog 方法返回的对象向子窗口发送消息,代码如下所示:

ts
//src\renderer\Component\BarLeft.vue
let config = { modal: true, width: 800, webPreferences: { webviewTag: false } };
let dialog = await createDialog(`/WindowSetting/AccountSetting`, config);
let msg = { msgName: "hello", value: "msg from your parent" };
dialog.postMessage(msg);

想要接收子窗口发来的消息,只要监听 window 对象的 message 事件即可,代码如下所示:

ts
//src\renderer\Component\BarLeft.vue
window.addEventListener("message", (e) => {
  console.log(e.data);
});

子窗口发送消息给父窗口的代码如下所示:

ts
window.opener.postMessage({ msgName: "hello", value: "I am your son." });

子窗口接收父窗口发来的消息的代码,与父窗口接收消息的代码相同,我们就不再赘述了。

相对于使用 ipcRender 和 ipcMain 的方式完成窗口间通信来说,使用这种方式完成跨窗口通信有以下几项优势:

  • 消息传递与接收效率都非常高,均为毫秒级;
  • 开发更加简单,代码逻辑清晰,无需跨进程中转消息。

总结

我们通过本节内容详细介绍了如何使用 window.open 方案管控子窗口。

本节一共分为五部分:首先我们介绍了如何使用 window.open 创建子窗口(同时还介绍了 window.open 方案的一些不足),接着我们介绍了如何为子窗口注册标题栏事件(实际上系统中所有的窗口都可以这种方式注册标题栏事件),然后我们介绍了如何动态设置子窗口的配置(开发者可以根据不同的业务创建不同的子窗口),再然后我们介绍了如何封装子窗口加载成功的事件(为父子窗口通信奠定了基础),最后我们介绍了父子窗口间如何通信(以及这种通信方式的优势)。

学会了如何管控应用的窗口,我就要学习如何管控应用的数据了,接下来我们将讲解如何为应用引入 pinia 库并使用它管控应用的数据状态。

源码

本节示例代码请通过如下地址自行下载:

源码仓储