第五章-处理多窗口 | Electron实战

14,344 阅读13分钟

本章主要内容:

  • 使用JavaScript Set数据结构跟踪多个窗口
  • 促进主进程和多个渲染器进程之间的通信
  • 使用Node APIs检查应用程序运行在那个平台上

现在,当Fire Sale启动时,它为UI创建一个窗口。当该窗口关闭时,应用程序退出。虽然这种行为完全可以接受,但我们通常希望能够打开多个独立的窗口。在本章中,我们将Fire Sale从一个单窗口应用程序转换为一个支持多个窗口的应用程序。在此过程中,我们将探索新的Electron APIs以及一些最近添加的JavaScript。我们还将探讨在将一个主进程配置为与一个渲染器进程通信,并对其进行重构以管理可变数量的渲染器进程时出现的问题的解决方案。本章末尾的完整代码可以在http://tinyurl.com/y4z9oj69。 然而我们从第4章-使用本机文件对话框和帮助进程间通讯的分支开始。

图5.1 在第四章中,我们建立了主进程和一个渲染进程之间的通信。

图5.2 在本章中,我们将更新Fire Sale以支持多个窗口并促进他们之间的沟通。

我们首先实例化一个Set数据结构,该结构于2015年添加到JavaScript中,跟踪用户的所有窗口。接下来,我们创建一个函数来管理单个窗口的生命周期。在这之后,我们修改在第4章中创建的函数,以提示用户选择一个文件并打开它以指向正确的窗口。此外,我们还将处理一些常见的突发情况和沿途出现的其他问题,比如互相遮挡的窗口。


创建和管理多个窗口

Sets 是JavaScript的一个新的数据结构,是在ES2015规范中添加的。Set是唯一元素的集合;数组中可以有重复的值。我选择使用set而不是数组,因为这样更容易删除元素。这个清单显示了如何用JavaScript创建一个Set

列表5.1 创建一个跟踪新窗口的集合: ./app/main.js

const windows = new Set();

对于数组,我们要么找到窗口的索引并删除它,要么创建一个没有该窗口的数组。这两种方法都不像调用Set上的delete方法并将引用传递给要删除的窗口那样简单。

有了跟踪应用程序所有窗口的数据结构,下一步是将创建BrowserWindow(列表5.2)从应用程序的"ready"事件监听器移到它自己的函数中。

const createWindow = exports.createWindow = () => {
    let newWindow = new BrowserWindow({
      show: false,
      webPreferences: {
        // WebPreferences中的nodeIntegrationInWorker选项设置为true
        nodeIntegration: true
      }
    });

    newWindow.loadFile('app/index.html');

    newWindow.once('ready-to-show', () => {
      newWindow.show();
    });

    newWindow.on('closed', () => {
      windows.delete(newWindow); //从已关闭的窗口Set中移除引用
      newWindow = null;
    });

    windows.add(newWindow); //将窗口添加到已打开时设置的窗口
    return newWindow;
};

这个createWindow()函数创建一个BrowserWindow实例并将其添加到我们在清单5.1中创建的一组窗口中。接下来,我们重复前面几章中创建新窗口的步骤。关闭窗口将其从集合中移除,最后,我们返回对刚刚创建的窗口的引用,我们下一章需要这个参考资料。

当应用程序准备好,调用新的createWindow()函数,如下面的清单所示。应用程序应该以与实现此更改之前相同的方式启动,但它也为在其他上下文中创建额外的窗口奠定了基础。

列表5.3 在应用程序就绪时创建窗口: ./app/main.js

app.on('ready',	() => {
    createWindow();
});

应用程序像以前一样启动,但是如果您尝试单击Open File按钮,您会注意到它已经坏了。这是因为我们仍然在一些地方引用mainWindow。它在dialog.showOpenDialog()中引用,以在macOS中将对话框显示为工作表。最重要的是,在从文件系统读取文件内容并将其发送到窗口之后,openFile()中引用了它。


主进程和多个窗口之间的通信

拥有多个窗口会引发一个问题:我们将文件路径和内容发送到那个窗口?为了支持多个窗口,这两个函数必须引用应该显示对话框的窗口和发送内容,如图5.3所示。

图5.3 要确定要将文件的内容发送到那个窗口,渲染器进程在与调用getFileFromUser()的主进程通信时必须发送对自身的引用。

在清单5.4中,让我们重构getFileFromUser()函数,以接受一个给定的窗口作为一个参数,而不是总是假设范围中有一个mainWindow实例。

列表5.4 重构getFileFromUser()以处理特定的窗口: ./app/main.js

const getFileFromUser  = exports.getFileFromUser   = (targetWindow) => { //获取对浏览器窗口的引用,以确定应该显示文件对话框的窗口,然后加载用户选择的文件。
    const files = dialog.showOpenDialog(targetWindow, { //showopendialog()获取对浏览器窗口对象的引用。
      properties: ['openFile'],
      filters: [
        { name: 'Text Files', extensions: ['txt'] },
        { name: 'Markdown Files', extensions: ['md', 'markdown'] }
      ]
    });
  
    if (files) { openFile(targetWindow, files[0]); } // openFile()函数作用是:获取对浏览器窗口对象的引用,以确定那个窗口应该接受用户打开的文件的内容。
  };

在代码清单中,我们修改了getFileFromUser(),将对窗口的引用作为参数。我避免命名参数窗口,因为它可能与浏览器中的全局对象混淆。在用户选择了一个文件之后,除了文件路径之外,我们还将targetWindow传递给openFile(),如下所示。

列表5.5 重构openFile()以处理特定的窗口: ./app/main.js

 const openFile = exports.openFile = (targetWindow, file) => { // 接受对浏览器窗口对象的引用
    const content = fs.readFileSync(file).toString();
    targetWindow.webContents.send('file-opened', file, content); // 将文件的内容发送到提供的浏览器窗口
  };

将对当前窗口的引用传递给主进程

从文件系统读取文件内容之后,我们将文件的路径和内容作为第一个参数传入并发送到窗口。这就提出了一个问题:我们如何获得对窗口的引用。

使用remote模块从渲染器进程调用getFileFromUser(),以便与主进程通信。正如我们在前一章中看到的,remote模块包含对所有模块的引用,否则这些模块只对主进程可用。原来remote还有一些其他方法,尤其是remote还有一些其他方法,尤其是remote.getCurrentWindow(),它返回对调用它的BrowserWindow实例,如下所示。

列表5.6 在渲染器进程中获取对当前窗口的引用: ./app/renderer.js

const currnetWindow = remote.getCurrentWindow();

现在我们有了对窗口的引用,完成该特性的最后一步是将它传递给getFileFromUser()。这让主进程中的函数知道它们正在使用的是什么浏览器窗口。

openFileButton.addEventListener('click', () => {
  mainProcess.getFileFromUser(currnetWindow);
});

当我们在第三章中为UI实现Markup时,我们包括了一个New File按钮。我们现在在主进程中实现并导入createWindow()函数,我们也可以很快地把那个按钮连接起来。

列表5.8 向newFileButton添加监听器: ./app/renderer.js

newFileButton.addEventListener('click', ()=> {
  mainProcess.createWindow();
})

我们可以在主进程中对多个窗口的实现做一些增强,但是我们已经完成了本章的渲染器进程。下面是app/renderer.js中文件的所有代码。

列表5.9 newFileButton在渲染器进程中的实现: ./app/renderer.js

const { remote, ipcRenderer } = require('electron');
const mainProcess = remote.require('./main.js')
const currnetWindow = remote.getCurrentWindow();

const marked = require('marked');

const markdownView = document.querySelector('#markdown');
const htmlView = document.querySelector('#html');
const newFileButton = document.querySelector('#new-file');
const openFileButton = document.querySelector('#open-file');
const saveMarkdownButton = document.querySelector('#save-markdown');
const revertButton = document.querySelector('#revert');
const saveHtmlButton = document.querySelector('#save-html');
const showFileButton = document.querySelector('#show-file');
const openInDefaultButton = document.querySelector('#open-in-default');

const renderMarkdownToHtml = (markdown) => {
htmlView.innerHTML = marked(markdown, { sanitize: true });
};
markdownView.addEventListener('keyup', (event) => {
const currentContent = event.target.value;
renderMarkdownToHtml(currentContent);
});
newFileButton.addEventListener('click', () => {
mainProcess.createWindow();
});
openFileButton.addEventListener('click', () => {
mainProcess.getFileFromUser(currentWindow);
});
ipcRenderer.on('file-opened', (event, file, content) => {
markdownView.value = content;
renderMarkdownToHtml(content);
});

改进创建新窗口的体验

在实现上一章中的事件监听器之后单击new File按钮,您可能会对它是否正常工作感到困惑。您可能已经注意到窗口周围的阴影变暗了,或者您可能单击并拖动了新窗口,并显示了下面的前一个窗口。

我们现在遇到的一个小问题是,每个新窗口都出现在与第一个窗口相同的默认位置,并且完全遮住了它。更明显的是,如果新窗口与前一个窗口稍微偏移,就会创建新窗口,如图5.4所示。这个清单显示了如何偏移窗口。

清单5.10 基于当前焦点窗口偏移新窗口: ./app/main.js

const createWindow = exports.createWindow = () => {
    let x,y;

    const currentWindow = BrowserWindow.getFocusedWindow(); //获取当前活动的浏览器窗口。

    if(currentWindow) { //如果上一步中有活动窗口,则根据当前活动窗口的右下方设置下一个窗口的坐标
      const [ currentWindowX, currentWindowY ] = currentWindow.getPosition();
      x = currentWindowX + 10;
      y = currentWindowY +10;
    }

    let newWindow = new BrowserWindow({
      x,
      y, 
      show: false,
      webPreferences: {
        // WebPreferences中的nodeIntegrationInWorker选项设置为true
        nodeIntegration: true
      }
    }); //创建新窗口,首先使用x和y坐标隐藏它。如果上一步中代码运行了,则设置这些值;如果没有运行,则未定义这些值,在这种情况下,将在默认位置创建窗口。

    newWindow.loadFile('app/index.html');

    newWindow.once('ready-to-show', () => {
      newWindow.show();
    });

    newWindow.on('closed', () => {
      windows.delete(newWindow);
      newWindow = null;
    });

    windows.add(newWindow);
    return newWindow;
};

除了使用new关键字实例化实例外,BrowserWindow模块还有自己的方法。我们可以使用BrowserWindow.getFocusedWindow()获得对用户当前正在使用的窗口的引用。当应用程序第一次准备好并调用createWindow()时,没有一个焦点窗口,`BrowserWindow.getFocusedWindow()返回undefined。如果有一个窗口,我们调用它的getWindow()方法,该方法返回一个此窗口的x和y坐标的数组。我们将把这些值存储在条件块之外的两个变量中,并将它们传递给BrowserWindow构造函数。如果它们仍然是未定义的(例如,没有焦点窗口),那么Electron将使用缺省值,就像我们实现此功能之前所做的那样。图5.4显示了与第一个窗口相比的第二个窗口偏移量。

图5.4 新窗口偏移当前窗口

这不是实现此功能的唯一方法。或者,您可以跟踪初始的x和y位置,并在每个新窗口上增加这些值。或者,您可以为默认的x和y值添加一点随机性,这样每个窗口都是稍微偏移量。我把这些方法留给读者作为练习。


结合macOS

在macOS中,即使所有的窗口都关闭了,许多(但不是所有)应用程序仍然保持打开状态。例如,如果您关闭了Chrome中的所有窗口,应用程序在dock中仍然出于活动状态,并且仍然出现在应用程序切换器中。Fire Sale不能做到这点。

在前几张章中,这可能是可以接受的。我们只有一个窗口,无法创建其他窗口。在本节中,我们只允许应用程序在macOS中保持打开状态。默认情况下,当Electron触发它的window-all-closed事件时,它将退出应用程序。如果我们想要阻止这种行为,我们必须监听这个事件,并且在macOS上运行时有条件地阻止它关闭。

列表5.11 在关闭所有窗口时保持应用程序的活动状态: ./app/main.js

app.on('window-all-closed', () => {
  if(process.platform === 'darwin') { //检查应用程序是否在macOS上运行
    return false; //如果是,则返回false以防止默认操作
  }
 app.quit(); //如果不是,则退出应用程序
});

process对象由Node提供,不需要配置全局可用。process.platform返回当前执行应用程序的平台名称。在截至写作时间点,process.platform返回七个字符串之一: aix,darwin,freebsd,linux,openbsd,sunoswin32。Darwin是构建macOS的UNIX操作系统。在清单5.11中,我们检查了是否process.platform等于darwin,如果是,则应用程序正在macOS上运行,我们希望返回false以阻止默认操作的发生。

保持应用程序的活动是成功的一半,如果用户单击dock中的应用程序而没有打开窗口,会发生什么?在这种情况下,Fire Sale应该打开一个新窗口并显示给用户,如下所示。

图5.12 在应用程序打开时创建一个窗口,但没有窗口: ./app/main.js

app.on('activate', (event, hasVisibleWindows) => { //Electron提供了hasVisibleWindows参数,它将是一个布尔值。
    if(!hasVisibleWindows) { createWindow(); } //如果用户激活应用程序时没有可见窗口,则创建一个。
});

activate事件将两个参数传递给提供的回调函数。第一个是event对象,第二个是布尔值,如果任何窗口都可见,则返回true;如果所有窗口都关闭,则返回false.对于后者,我们调用本章前面编写的createWindow()函数。

activate事件只在macOS上触发,但是有很多原因可以解释为什么您可能选择让您的应用程序在Windows或Linux上保持打开状态,特别是如果应用程序正在运行后台进程,而您希望继续运行这些进程,即使该窗口被关闭。另一种可能性是,您的应用程序可以隐藏,或者使用全局快捷方式显示,或者从托盘或菜单栏中显示。我们将在后面的章节中实现这些。

通过这两个额外的事件,我们将Fire Sale从单窗口应用程序转换为支持多窗口的应用。这个清单显示了主进程当前状态的代码。

列表5.13 在主进程中实现多个窗口: ./app/main.js

const{ app, BrowserWindow,dialog } = require('electron');
const fs = require('fs');

const windows = new Set();

app.on('ready', () => {
   createWindow();
});

app.on('window-all-closed', () => {
  if(process.platform === 'darwin') {
    return false;
  }
});

app.on('activate', (event, hasVisibleWindows) => {
    if(!hasVisibleWindows) { createWindow(); }
});

const createWindow = exports.createWindow = () => {
    let x,y;

    const currentWindow = BrowserWindow.getFocusedWindow();

    if(currentWindow) {
      const [ currentWindowX, currentWindowY ] = currentWindow.getPosition();
      x = currentWindowX + 10;
      y = currentWindowY +10;
    }

    let newWindow = new BrowserWindow({
      x,
      y, 
      show: false,
      webPreferences: {
        // WebPreferences中的nodeIntegrationInWorker选项设置为true
        nodeIntegration: true
      }
    });

    newWindow.loadFile('app/index.html');

    newWindow.once('ready-to-show', () => {
      newWindow.show();
    });

    newWindow.on('closed', () => {
      windows.delete(newWindow);
      newWindow = null;
    });

    windows.add(newWindow);
    return newWindow;
};


const getFileFromUser  = exports.getFileFromUser   = (targetWindow) => {
    const files = dialog.showOpenDialog(targetWindow, {
      properties: ['openFile'],
      filters: [
        { name: 'Text Files', extensions: ['txt'] },
        { name: 'Markdown Files', extensions: ['md', 'markdown'] }
      ]
    });
  
    if (files) { openFile(targetWindow, files[0]); } // A
  };
  
  const openFile = (targetWindow, file) => {
    const content = fs.readFileSync(file).toString();
    targetWindow.webContents.send('file-opened', file, content); // B
  };

总结

  • 当创建具有多个窗口的Electron应用程序时,我们不能硬编码主进程发送数据的窗口。
  • 我们可以使用Electron的remote模块向渲染器进程中的窗口请求对自身的引用,并在与主进程通信时发送该引用。
  • macOS上的应用程序并不总是在所有窗口都关闭时退出,我们可以使用Node的process对象来确定应用程序在那个平台上运行。
  • 如果process.platformdarwin,则应用程序在macOS上运行。
  • 在监听应用程序的windows-all-closed事件的函数中,返回false从而防止应用程序退出。
  • 在macOS上,当用户单击dock图标时,应用程序会触发activate事件。
  • activate事件包含一个名为hasVisibleWindows的布尔值,作为传递给回调函数的第二个参数。 如果当前有窗口打开,则为true;如果没有窗口,则为false。我们可以用它来决定是否应该打开一个新窗口。