本章主要內容:
- 使用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
,sunos
或win32
。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.platform
是darwin
,則應用程序在macOS上運行。 - 在監聽應用程序的
windows-all-closed
事件的函數中,返回false從而防止應用程序退出。 - 在macOS上,當用戶單擊dock圖標時,應用程序會觸發
activate
事件。 activate
事件包含一個名為hasVisibleWindows
的布爾值,作為傳遞給回調函數的第二個參數。
如果當前有窗口打開,則為true
;如果沒有窗口,則為false
。我們可以用它來決定是否應該打開一個新窗口。