第五章-處理多窗口 | Electron實戰

  • 2019 年 10 月 3 日
  • 筆記

本章主要內容:

  • 使用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。我們可以用它來決定是否應該打開一個新窗口。