你第一個Electron應用 | Electron in Action(中譯)

三升水發表於2019-06-21

效果演示:

 

本章主要內容

  • 構造並設定Electron應用

  • 生成package.json,通過開發用Electron配置其工作

  • 在你的專案中預先構建Electron版本

  • 配置你的package.json去啟動主程式

  • 從主程式生成渲染程式

  • 利用Electron沙盒,限制寬鬆的優點構建通常在瀏覽器無法構建的功能

  • 使用Electron的內建模組來回避一些常見的問題

在第一章中,我們從高的層次上,討論了什麼是Electron。說到底這本書叫做《Electron實戰》,對吧?在本章中,我們通過從頭開始設定和構建一個簡單的應用程式來管理書籤列表,從而學習Electron的基本知識。該應用程式將利用只有在現代的瀏覽器中才能使用的特性。

在上一章的高層次討論中,我提到了Electron是一個類似於Node的執行時。這仍然是正確的,但是我想回顧下這一點。Electron不是一個框架——它不提供任何框架,也沒有關於如何構造應用程式或命名檔案的嚴格規則,這些選擇都留給了我們這些開發者。好的一面是,它也不強制執行任何約定,而且在入手之前,我們不需要多少概念上的樣板資訊去學習。

 

構建書籤列表應用程式

 

讓我們從構建一個簡單而又有些幼稚的Electron應用程式開始,來加強我們已經介紹過的所有內容的理解。我們的應用程式接受url。當使用者提供URL時,我們獲取URL引用的頁面的標題,並將其儲存在應用程式的localStorage中。最後,顯示應用程式中的所有連結。您可以在GitHub上找到本章的完整原始碼(https://github.com/electron-in-action/bookmarker)。

  在此過程中,我們將指出構建Electron應用程式的一些優點,例如,可以繞過對伺服器的需求,使用最前沿的web api,這些web api並不廣泛支援所有瀏覽器,因為這些APIs是在現代版本的Chromium中實現。圖2.1是我們在本章構建的應用程式的效果圖。

圖2.1 我們在本章中構建的應用程式效果圖

 

  當使用者希望將網站URL儲存並新增到輸入欄位下面的列表中時,應用程式向網站傳送一個請求來獲取標記。成功接收到標記後,應用程式獲取網站的標題,並將標題和URL新增到網站列表中,該列表儲存在瀏覽器的localStorage中。當應用程式啟動時,它從localStorage讀取並恢復列表。我們新增了一個帶有命令的按鈕來清除localStorage,以防出現錯誤。因為這個簡單的應用程式旨在幫助您熟悉Electron,所以我們不會執行高階操作,比如從列表中刪除單個網站。

 

搭建Electron應用

 

應用程式結構的定義取決於您的團隊或個人處理應用程式的方式。許多開發人員採用的方法略有不同。觀察學習一些更成熟的電子應用程式,我們可以辨別出共同的模式,並在本書中決定如何處理我們的應用程式。

出於我們的目的,為了讓本書檔案結構達成一致。做出一下規定,我們有一個應用程式目錄,其中儲存了所有的應用程式程式碼。我們還有一個package.json將儲存依賴項列表、關於應用程式的後設資料和指令碼,並宣告Electron應該在何處查詢主程式。在安裝了依賴項之後,最終會得到一個由Electron為我們建立的node_modules目錄,但是我們不會在初始設定中包含它

就檔案而言,讓我們從應用程式中的兩個檔案開始:main.jsrenderer.js。它們是帶有標識的檔名,因此我們可以跟蹤這兩種型別的程式。我們在本書中構建的所有應用程式的開始大致遵循圖2.2中所示的目錄結構。(如果你在執行macOS,你可以通過安裝brew install tree使用tree命令。)

圖2.2 我們第一個Electron應用的檔案結構樹

 

建立一個名為“bookmarker”的目錄,並進入此目錄。您可以通過從命令列工具執行以下兩個命令來快速建立這個結構。當你使用npm init之後,你會生成一個package.json檔案。

mkdir app
touch app/main.js app/renderer.js app/style.css app/index.html

 

Electron本身不需要這種結構,但它受到了其他Electron應用程式建立的一些最佳實踐的啟發。Atom將所有應用程式程式碼儲存在一個app目錄中,將所有樣式表和其他資產(如影象)儲存在一個靜態目錄中。LevelUI在頂層有一個index.js和一個client.js,並將所有依賴檔案儲存在src目錄中,樣式表儲存在styles目錄中。Yoda將所有檔案(包括載入應用程式其餘部分的檔案)儲存在src目錄中。app、src和lib是存放應用程式大部分程式碼的資料夾的常用名稱,style、static和assets是存放應用程式中使用的靜態資產的目錄的常用名稱。

 

package.json

 

package.json清單用於許多甚至說大多數Node專案。此清單包含有關專案的重要資訊。它列出了後設資料,比如作者的姓名以及他們的電子郵件地址、專案是在哪個許可下發布的、專案的git儲存庫的位置以及檔案問題的位置。它還為一些常見的任務定義了指令碼,比如執行測試套件或者與我們的需求相關的構建應用程式。package.json檔案還列出了用於執行和開發應用程式的所有依賴項。

理論上,您可能有一個沒有package.json的Node專案。但是,當載入或構建應用程式時,Electron依賴於該檔案及其主要屬性來確定從何處開始。

npm是Node附帶的包管理器,它提供了一個有用的工具幫助生成package.json。在前面建立的“bookmarker”目錄中執行npm init。如果您將提示符留空,npm將冒號後面括號中的內容作為預設內容。您的內容應該類似於圖2.3,當然,除了作者的名字之外。

在package.json中,值得注意的是main條目。這裡,你可以看到我將它設定為"./app/main.js"。基於我們如何設定應用程式。你可以指向任何你想要的檔案。我們要用的主檔案恰好叫做main.js。但是它可以被命名為任何東西(例如,sandwich.js、index.js、app.js)。

圖2.3 npm init 提供一系列提示並設定一個package.json檔案

 

下載和安裝Electron在我們的專案

我們已經建立了應用程式的基本結構,但是卻找不到Electron。從原始碼編譯Electron需要一段時間,而且可能很乏味。因此我們根據每個平臺(macOS、Windows和Linux)以及兩種體系結構(32位和64位)預先構建了electronic版本。我們通過npm安裝Electron。

下載和安裝電子很容易。在您執行npm init之前,在你的專案目錄中執行以下命令:

npm install electron --save-dev

此命令將在你的專案node_modules目錄下下載並安裝Electron(如果您還沒有目錄,它還會建立目錄)。--save-dev標誌將其新增到package.json的依賴項列表中。這意味著如果有人下載了這個專案並執行npm install,他們將預設獲得Electron。

 

漫談electron-prebuilt

假如您瞭解Electron的歷史,您可能會看到部落格文章、文件,甚至本書的早期版本,其中提到的是electron-prebuilt,而不是electron。在過去,前者是為作業系統安裝預編譯版Electron的首選方法。後者是新的首選方法。從2017年初開始,不再支援electron-prebuilt

 

npm還允許您定義在package.json中執行公共指令碼的快捷方式。當您執行package.json定義的指令碼時。npm自動新增node_modules到這個路徑。這意味著它將預設使用本地安裝的Electron版本。讓我們向package.json新增一個start指令碼。

列表2.1  向package.json新增一個啟動指令碼

{                                                        +
"name": "bookmarker",                                   |當我們執行npm start
"version": "1.0.0",                                     |npm將會執行什麼指令碼
"description": "Our very first Electron application",   |
"main": "./app/main.js",                                 |
"scripts": {                                             |
"start": "electron .",                           <------+
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Steve Kinney",
"license": "ISC",
"dependencies": {
"electron": "^2.0.4"
}
}

現在,當我們執行npm start時,npm使用我們本地安裝的版本Electron去啟動Electron應用程式。你會注意到似乎沒有什麼事情發生。在你的終端中,應參考以下程式碼:

>bookmarker@1.0.0  start /Users/stevekinney/Projects/bookmarker
>electron .

您還將在dock或工作列中看到一個新應用程式(我們剛剛設定的Electron應用程式),如圖2.4所示。它被簡稱為“Electron”,並使用Electron的預設應用程式圖示。在後面的章節中,我們將看到如何定製這些屬性,但是目前預設值已經足夠好了。我們所有的程式碼檔案都是完全空白的。因此,這個應用程式還有很多操作需要去做,但是它確實存在並正確啟動。我們認為這是一場暫時的勝利。在windows上關閉應用程式的所有視窗或選擇退出應用程式選單終止程式。或者,您可以在Windows命令提示符或終端中按Control-C退出應用程式。按下Command-Period將終止macOS上的程式。

圖2.4 dock上的應用程式就是我們剛建立的電子應用

處理主程式

現在我們有了一個Electron應用,如果我們真的能讓它做點什麼,那就太好了。如果你還記得第一章,我們從可以建立一個或多個渲染器程式的主程式開始。我們首先通過編寫main.js程式碼,邁出我們應用程式的第一步。

要處理Electron,我們需要匯入electron庫。Electron附帶了許多有用的模組,我們在本書中使用了這些模組。第一個—也可以說是最重要的——是app模組。

列表2.2 新增一個基本的主程式: ./app/main.js


const {app} = require('electron');     +
app.on('ready', () => {           <---+ 在應用程式完全
console.log('Hello from Electron');   + 啟後立即呼叫
});

app是一個處理應用程式生命週期和配置的模組。我們可以使用它退出、隱藏和顯示應用程式,以及獲取和設定應用程式的屬性。app模組還可以執行事件-包括before-quit, window -all-closed,

browser-window-blur, 和browser-window-focus-當應用程式進入不同狀態時。

在應用程式完全啟動並準備就緒之前,我們無法處理它。幸運的是,app觸發了一個ready事件。這意味著在做任何事之前,我們需要耐心等待並監聽應用程式啟動ready事件。在前面的程式碼中,我們在控制檯列印日誌,這是一件無需Electron就可以輕鬆完成的事情,但是這段程式碼強調了如何偵聽ready事件。

 

建立渲染器程式

我們的主程式與其他Node程式非常相似。它可以訪問Node的所有內建庫以及由Electron提供的一組特殊模組,我們將在本書中對此進行探討。但是,與任何其他Node程式一樣,我們的主程式沒有DOM(文件物件模型),也不能呈現UI。主程式負責與作業系統互動,管理狀態,並與應用程式中的所有其他流程進行協調。它不負責呈現HTML和CSS。這就是渲染器程式的工作。參與整個Electron主要功能之一是為Node程式建立一個GUI。

主程式可以使用BrowserWindow建立多個渲染器程式。每個BrowserWindow都是一個單獨的、惟一的渲染器器程式,包括一個DOM,訪問Chromium web APIs,以及Node內建模組。訪問BrowserWindow模組的方式與訪問app模組的方式相同。

 

列表2.3 引用BrowserWindow模組: ./app/main.js

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

 

您可能已經注意到BrowserWindow模組以大寫字母開頭。根據標準JavaScript約定,這通常意味著我們用new關鍵字將其呼叫為建構函式。我們可以使用這個建構函式建立儘可能多的渲染器程式,只要我們喜歡,或者我們的計算機可以處理。當應用程式就緒時,我們建立一個BrowserWindow例項。讓我們按照以下方式更新程式碼。

 

列表2.4 生成一個BrowserWindow: ./app/main.js

                                                    +
const {app, BrowserWindow} = require('electron');   |在我們的應用程式中建立一個
let mainWindow = null;                         <----+window物件的全域性引用
app.on('ready', () => {                 +         +
console.log('Hello from Electron.');   |當應用程式準備好時,
mainWindow = new BrowserWindow(); <----+建立一個瀏覽器視窗
});                                     +並將其分配給全域性變數

我們在ready事件監聽器外宣告瞭mainWindow。JavaScript使用函式作用域。如果我們在事件監聽器中宣告mainWindow, mainWindow將進行垃圾回收,因為分配給ready事件的函式已經執行完畢。如果被垃圾回收,我們的窗戶就會神祕地消失。如果我們執行這段程式碼,我們會在螢幕中央看到一個不起眼的小視窗,如圖2.5所示。

一個沒有載入HTML文件的空BrowserWindow

 

這是一扇視窗,並什麼好看的。下一步是將HTML頁面載入到我們建立的BrowserWindow例項中。所有BrowserWindow例項都有一個web content屬性,該屬性具有幾個有用的特性,比如將HTML檔案載入到渲染器程式的視窗中、從主程式向渲染器程式傳送訊息、將頁面列印為PDF或印表機等等。現在,我們最關心的是將內容載入到我們剛剛建立的那個無聊的視窗中。

  我們需要載入一個HTML頁面,因此在您專案的app目錄中建立index.html。讓我們將以下內容新增到HTML頁面,使其成為一個有效的文件。

 

列表2.5 建立index.html: ./app/index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self' 'unsafe-inline';
connect-src *
"
>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Bookmarker</title>
</head>
<body>
<h1>Hello from Electron</h1>
</body>
</html>

這很簡單,但它完成了工作,併為構建打下了良好的基礎。我們將以下程式碼新增到app/main.js中,以告訴渲染器程式在我們之前建立的視窗中載入這個HTML文件。

列表2.6 將HTML文件載入到主視窗: ./app/main.js

我們使用file://protocol_dirname變數,該變數在Node中全域性可用。_dirname是Node程式正在執行的目錄的完整路徑。在我的例子中,_dirname擴充套件為/Users/stevekinney/Projects/bookmarker/app

現在,我們可以使用npm start啟動應用程式,並觀察它載入新的HTML檔案。如果一切順利,您應該會看到類似於圖2.6的內容。

 

從渲染程式載入程式碼

從渲染器程式載入的HTML檔案中,我們可以像在傳統的基於瀏覽器的web應用程式中一樣載入可能需要的任何其他檔案-即<script><link>標籤。

Electron與我們習慣的瀏覽器不同之處在於我們可以訪問所有Node——甚至是我們通常認為的“客戶端”。這意味著,我們可以使用require甚至Node-only物件和變數,比如_dirnameprocess模組。同時,我們還有所有可用的瀏覽器APIs。只能在客戶端的工作和只能在服務端做的工作的分工開始消失不見。

圖2.6 一個帶有簡單HTML文件的瀏覽器視窗

讓我們來看看實際情況。在傳統的瀏覽器環境中_dirname不可用,在Node中documentalert是不可用的。但在Electron,我們可以無縫地將它們結合在一起。讓我們在頁面上新增一個按鈕。

列表2.7 新增一個按鈕到HTML文件: ./app/index. html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF+8">
<meta http+equiv="Content+Security+Policy" content=" default+src 'self'; script+src 'self' 'unsafe+inline';connect+src *">
<meta name="viewport" content="width=device+width,initial+scale=1">
<title>Bookmarker</title>
</head>
<body>
<h1>Hello from Electron</h1>
<p>
<button class="alert">Current Directory</button>     <---+
</p>                                                     |這是我們
</body>                                                  |的新按鈕
</html>                                                  +

 

現在,我們已經有了按鈕,讓我們新增一個事件監聽器,它將提醒我們執行應用程式的當前目錄。

<script>
  const button = document.querySelector('.alert');
  button.addEventListener('click', () =^ {
  alert(__dirname);             <------+單擊按鈕時,
  });                                  |使用瀏覽器警告顯示
</script>                              |Node全域性變數
                                       +

alert()僅在瀏覽器中可用。_dirname僅在Node中可用。當我們點選按鈕時,我們被處理成Node和Chromium在一起工作,甜美和諧,如圖2.7所示。

圖2.7 在渲染器程式的上下文中,BrowserWindow執行JavaScript。

 

在渲染器程式中引用檔案

在HTML檔案中編寫程式碼顯然有效,但是不難想象,我們的程式碼量可能會增長到這種方法不再可行的地步。我們可以新增帶有src屬性的指令碼標記來引用其他檔案,但是這很快就會變得很麻煩。

這就是web開發變得棘手的地方。雖然模組被新增到ECMAScript規範中,目前沒有瀏覽器具有模組系統的工作實現。在客戶端上,我們可以考慮使用一些構建工具,如Browserify (http://browserify.org)或模組bundlerwebpack,也可以使用任務執行器,如GulpGrunt

我們可以使用Node的模組系統,而不需要額外的配置。讓我們移除<script>標籤中的所有程式碼到-現在是空的-app/renderer.js檔案中。現在我們可以用一個<script>標記去引用renderer.js檔案去替代之前的內容。

列表2.9 從renderer.js載入JavaScript: ./app/index.html

                            +
<script>                    |使用Node的require函式
  require('./renderer'); <--+將額外的JavaScript模組
</script>                   |載入到渲染器程式中
                            +

如果我們啟動應用程式,您將看到它的功能沒有改變。一切都照常進行。這在軟體開發中很少發生。在繼續之前,讓我們先體驗一下這種感覺。

 

在渲染器程式中新增樣式

當我們在Electron應用程式中引用樣式表時,很少會發生意外。稍後,我們將討論如何使用Sass而不是Electron。 在電子應用程式中新增樣式表與在傳統web應用程式中新增樣式表沒有多大不同。儘管如此,一些細微差別還是值得討論的。

讓我們從將style.css檔案新增到應用程式目錄開始。我們將以下內容新增到style.css中。

列表2.10 新增基礎樣式: ./app/style.css

html {
  box+sizing: border+box;
}
*, *:before, *:after {
  box+sizing: inherit;        +使用頁面所執行
}                             |的作業系統的
body, input {                 |預設系統字型
  font: menu;          <------+
}

最後一項宣告可能看起來有點陌生。它是Chromium獨有的,允許我們在CSS中使用系統字型。這種能力對於使我們的應用程式與其原生本機程式相適應非常重要。在macOS上,這是使用San Francisco的唯一方法,該系統字型附帶El Capitan 10.11及以後版本。

在Electron應用程式中使用CSS,這是我們應該考慮的另一個重要的區別。我們的應用程式將只在應用程式附帶的Chromium版本中執行。我們不必擔心跨瀏覽器支援或相容性考慮。正如在第1章中提到的,電子與相對較新版本的Chromium一起釋出。這意味著我們可以自由地使用flexbox和CSS變數等技術。

我們像在傳統瀏覽器環境中一樣引用新樣式表,然後將以下內容新增到index.html<head>部分。 我將包含連結到樣式表的HTML標記—因為,在我作為web開發人員的20年裡,我仍然不記得如何第一次嘗試就做到這一點。

列表2.11 在HTML文件中引用樣式表: ./app/index.html

<link rel="stylesheet" href="style.css" type="text/css">

 

實現使用者介面

我們首先使用UI所需的標記更新index.html。

列表2.12 為應用程式的UI新增標記: ./app/index.html

<h1>Bookmarker</h1>
<div class="error-message"></div>
<section class="add-new-link">
  <form class="new-link-form">
    <input type="url" class="new-link-url" placeholder="URL"size="100"
     required>
    <input type="submit" class="new-link-submit" value="Submit" disabled>
  </form>
</section>

<section class="links"></section>
<section class="controls">
  <button class="clear-storage">Clear Storage</button>
</section>

 

我們有一個用於新增新連結的部分,一個用於顯示所有精彩連結的部分,以及一個用於清除所有連結並重新開始的按鈕。你的應用程式中的<script>標籤應該和我們在本章早些時候討論時一樣,但是以防萬一,我在下方給出程式碼:

<script>
  require('./renderer');
</script>

 

標記就緒後,我們現在可以將注意力轉向功能。讓我們清除app/renderer.js中的所有內容,重新開始。在我們一起學習的過程中,我們將需要處理新增到標記中的一些元素,所以讓我們首先查詢這些選擇器並將它們快取到變數中。將以下內容新增到app/renderer.js

列表2.13 快取DOM元素選擇器: ./app/renderer.js

const  linksSection = document.querySelector('.links');
const	errorMessage = document.querySelector('.error-message');
const	newLinkForm = document.querySelector('.new-link-form');
const	newLinkUrl = document.querySelector('.new-link-url');
const	newLinkSubmit = document.querySelector('.new-link-submit');
const	clearStorageButton = document.querySelector('.clear-storage');

 

回顧清單2.12,您會注意到在標記中我們將input元素的type屬性設定“url”。如果內容不匹配有效的URL模式,Chromium將把該欄位標記為無效。不幸的是,我們無法訪問Chrome或Firefox中內建的錯誤訊息彈出框。這些彈出視窗不是Chromium web模組的一部分,因此也不是Electron的一部分。現在,我們在預設情況下禁用start按鈕,然後在每次使用者在URL輸入框內中鍵入字母時檢查是否有一個有效的URL語法。

如果使用者提供了一個有效的URL,那麼我們將開啟submit按鈕並允許他們提交URL。讓我們將這段程式碼新增到app/renderer.js中。

 

列表2.14 新增事件監聽器以啟用submit按鈕

newLinkUrl.addEventListener('keyup', () => {
  newLinkSubmit.disabled = !newLinkUrl.validity.valid;    <------+
});                        當使用者在輸入欄位中敲入url時               |
                           通過使用Chromium ValidityState API     |
                           來確定輸入是不是有效,如果是這樣,從        +
                submit按鈕中移除disable屬性

 

現在也是新增一個協助函式來清除URL欄位內容的好時機。在理想的情況下,只要成功儲存了連結,就會呼叫這個函式。

列表2.15 新增幫助函式來清除輸入框: ./app/renderer.js

                                   +
const clearForm= () => {           |通過設定新連線輸入框為空
  newLinkUrl.value = null;    <----+來清除該欄位
};                                 |
                                   +

 

當使用者提交一個連結,我們希望瀏覽器請求URL,然後把獲取回覆體,解析它,找到title元素,得到標題的文字元素,儲存書籤的標題和URL在localStorage,和then-finally-update書籤的頁面。

 

在Electron實現跨域請求

你可能感覺到,也可能沒有感覺到,你脖子後面的一些毛髮開始豎起來。你甚至可能對自己說:“這個計劃不可能行得通。您不能向第三方伺服器發出請求。瀏覽器不允許這樣做。”

通常來說,你是對的。在傳統的基於瀏覽器的應用程式中,不允許客戶端程式碼向其他伺服器發出請求。通常,客戶端程式碼向伺服器發出請求,然後將請求代理給第三方伺服器。當它返回時,它將響應代理回客戶機。我們在第一章中討論了這背後的一些原因。

Electron具有Node伺服器的所有功能,以及瀏覽器的所有功能。這意味著我們可以自由地發出跨源請求,而不需要伺服器。

在Electron中編寫應用程式的另一個好處是我們可以使用正在興起的Fetch API來向遠端伺服器發出請求。Fetch API免去了手工設定XMLHttpRequest的麻煩,併為處理我們的請求提供了一個良好的、基於承諾的介面。在撰寫本文時,主要瀏覽器對Fetch的支援有限。也就是說,它在當前版本的Chromium中有完整的支援,這意味著我們可以使用它。

我們向表單新增一個事件偵聽器,以便在表單有動作時,立即執行提交。我們沒有伺服器,所以需要確保避免發出請求的預設操作。我們通過防止預設操作來做到這一點。我們還快取URL輸入欄位的值,以便將來使用。

 

列表2.16 向submit按鈕新增事件偵聽器: ./app/renderer.js

newLinkForm.addEventListener('submit', (event) => {
  event.preventDefault();              <-----+告訴Chromium不要觸發HTTP請求,
                                             |這是表單提交的預設操作
  const url = newLinkUrl.value;  <--+        |
                                    |        +
// More code to come...             |獲取新連結輸入框中的URL欄位,
});                                 +我們很塊就會用到這個值。

 

Fetch API作為全域性可用的fetch變數。抓取的URL返回一個promise物件,該物件將在瀏覽器完成時被實現 獲取遠端資源。使用這個promise物件,我們可以根據是否獲取網頁、影象或其他型別的內容來處理不同的響應。在本例中,我們正在獲取一個網頁,因此我們將響應轉換為文字。我們從事件監聽器中的以下程式碼開始。

列表2.17 使用Fetch API請求遠端資源./app/renderer.js

fetch(url)	//使用Fetch API獲取提供的URL的內容
.then(response => response.text());	//將響應解析為純文字

 

Promises是鏈式的,我們可以使用先前承諾的返回值,並將另一個呼叫附加到then。此外,response.text()本身返回一個promise。我們的下一步將是獲取接收到的大塊標記,並解析它來遍歷它並找到title元素。

解析回覆報文

Chromium提供了一個解析器,它將為我們做這件事,但是我們需要例項化它。在app/renderer的頂部。我們建立了一個DOMParser例項,並將其儲存起來供以後使用。

列表2.18 例項化一個DOMParser: ./app/renderer.js

const parser = new DOMParser(); //建立一個DOMParser例項。我們將在獲取所提供URL的文字內容後使用此方法。

 

讓我們設定一對幫助函式來解析響應併為我們找到標題。

列表2.19 新增用於解析響應和查詢標題的函式: ./app/renderer.js

const parseResponse = (text) => {
	return parser.parseFromString(text, 'text/html'); //從URL獲取HTML字串並將其解析為DOM樹。
}
const findTitle = (nodes) =>{
	return nodes.querySelector('title').innerText;	//遍歷DOM樹以找到標題節點。
}

 

現在我們可以將這兩個步驟新增到我們的處理鏈中。

列表2.20 解析響應並在獲取頁面時查詢標題: ./app/renderer.js

fetch(url)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle);

 

此時,app/renderer.js中的程式碼看起來是這樣的。

const parser = new DOMParser();
const linksSection = document.querySelector('.links');
const errorMessage = document.querySelector('.error-message');
const newLinkForm = document.querySelector('.new-link-form');
const newLinkUrl = document.querySelector('.new-link-url');
const newLinkSubmit = document.querySelector('.new-link-submit');
const clearStorageButton = document.querySelector('.clear-storage');

newLinkUrl.addEventListener('keyup', () => {
	newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});

newLinkForm.addEventListener('submit', (event) => {
	event.preventDefault();
	const url = newLinkUrl.value;
	fetch(url)
		.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
});

const clearForm = () => {
	newLinkUrl.value = null;
}

const parseResponse = (text) => {
	return parser.parseFromString(text, 'text/html');
}

const findTitle = (nodes) => {
	return nodes.querySelector('title').innerText;
}

 

使用web storage APIs儲存響應

localStorage是一個簡單的鍵/值儲存,內建在瀏覽器中並持久儲存之間的會話。您可以在任意鍵下儲存簡單的資料型別,如字串和數字。讓我們設定另一個幫助函式,它將從標題和URL生成一個簡單的物件,使用內建的JSON庫將其轉換為字串,然後使用URL作為鍵儲存它。

圖2.22 建立一個函式來在本地儲存中儲存連結: ./app/renderer.js

const storeLink = (title, url) => {
		localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
};

 

我們的新storeLink函式需要標題和URL來完成它的工作,但是前面的處理只返回標題。我們使用一個箭頭函式將對storeLink的呼叫封裝在一個匿名函式中,該匿名函式可以訪問作用域中的url變數。如果成功,我們也清除表單。

圖2.23 儲存連結並在獲取遠端資源時清除表單: ./app/renderer.js

                                            
fetch(url)                                  
 .then(response => response.text())         
 .then(parseResponse)                       |
 .then(findTitle)                           |將標題和URL儲存到localStorage
 .then(title => storeLink(title, url))  <---+
 .then(clearForm);

 

顯示請求結果

儲存連結是不夠的。我們還希望將它們顯示給使用者。這意味著我們需要建立功能來遍歷儲存的所有連結,將它們轉換為DOM節點,然後將它們新增到頁面中。

讓我們從從localStorage獲取所有連結的能力開始。如果你還記得,localStorage是一個鍵/值儲存。我們可以使用物件。獲取物件的所有鍵。我們必須為自己提供另一個幫助函式來將所有連結從localStorage中取出。這沒什麼大不了的,因為我們需要將它們從字串轉換回實際物件。讓我們定義一個getLinks函式。

圖2.24 建立用於從本地儲存中獲取連結的函式: ./app/renderer.js

                                       
                                       
const getLinks = () => {               |
                                       |獲取當前儲存在localStorage中的所有鍵的陣列
  return Object.keys(localStorage) <---+
    .map(key => JSON.parse(localStorage.getItem(key)));   <----+
}                                                              |對於每個鍵,獲取其值
                                                               |並將其從JSON解析為JavaScript物件
                                                               

 

接下來,我們將這些簡單的物件轉換成標記,以便稍後將它們新增到DOM中。我們建立了一個簡單的convertToElement 幫助函式,它也可以處理這個問題。需要指出的是,我們的convertToElement函式有點幼稚,並且不嘗試清除使用者輸入。理論上,您的應用程式很容易受到指令碼注入攻擊。這有點超出了本章的範圍,所以我們只做了最低限度的渲染這些連結到頁面上。我將把它作為練習留給讀者來確保這個特性的安全性。

列表2.25 建立一個從連結資料建立DOM節點的函式: ./app/renderer.js

const convertToElement = (link) => {
	return `
<div class="link">
<h3>${link.title}</h3>
<p>
<a href="${link.url}">${link.url}</a>
</p>
</div>
`;
};

 

最後,我們建立一個renderLinks()函式,它呼叫getLinks,連線它們,使用convertToElement()轉換集合,然後替換頁面上的linksSection元素。

列表2.26 建立一個函式來呈現所有連結並將它們新增到DOM中: ./app/renderer.js

const renderLinks = () => {
	const linkElements = getLinks().map(convertToElement).join('');	//將所有連結轉換為HTML元素並組合它們
	linksSection.innerHTML = linkElements;	//用組合的連結元素替換links部分的內容
};

 

現在我們可以往處理鏈上新增最後一步。

列表2.27 獲取遠端資源後呈現連結: ./app/renderer.js

fetch(url)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
	.then(title => storeLink(title, url))
	.then(clearForm)
	.then(renderLinks);

 

當頁面初始載入時,我們還通過在頂層範圍內呼叫renderLinks()來呈現所有連結。

列表2.28 載入和渲染連結: ./app/renderer.js

renderLinks();	//一旦頁面載入,就呼叫我們之前建立的renderLinks()函式

 

使用promise與將功能分解為命名的幫助函式相協調的一個優點是,我們的程式碼通過獲取外部頁面、解析它、儲存結果和重新對連結列表進行排序的過程非常清楚。

最後一件事,我們需要完成我們的簡單應用程式的所有功能安裝的方法是連線“清除儲存”按鈕。我們在localStorage上呼叫clear方法,然後在linksSection中清空列表。

列表2.29 編寫清除儲存按鈕: ./app/renderer.js

clearStorageButton.addEventListener('click', () => {
	localStorage.clear();	//清空localStorage中的所有連結
	linksSection.innerHTML = '';    //從UI上移除所有連結
});

 

有了Clear Storage按鈕,似乎我們已經具備了大部分功能。我們的應用程式現在看起來如圖2.8所示。此時,呈現器過程的程式碼應該如清單2.30所示。

列表2.30 獲取、儲存和呈現連結的渲染器程式: ./app/renderer.js

const parser = new DOMParser();
const linksSection = document.querySelector('.links');
const errorMessage = document.querySelector('.error-message');
const newLinkForm = document.querySelector('.new-link-form');
const newLinkUrl = document.querySelector('.new-link-url');
const newLinkSubmit = document.querySelector('.new-link-submit');
const clearStorageButton = document.querySelector('.clear-storage');
const newLinkUrl.addEventListener('keyup', () => {
const newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});
newLinkForm.addEventListener('submit', (event) => {
	event.preventDefault();
    
const url = newLinkUrl.value;
    
fetch(url)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
	.then(title => storeLink(title, url))
	.then(clearForm)
	.then(renderLinks);
});

clearStorageButton.addEventListener('click', () => {
	localStorage.clear();
	linksSection.innerHTML = '';
});

const clearForm = () => {
	newLinkUrl.value = null;
}

const parseResponse = (text) => {
	return parser.parseFromString(text, 'text/html');
}

const findTitle = (nodes) => {
	return nodes.querySelector('title').innerText;
}

const storeLink = (title, url) => {
	localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
}

const getLinks = () => {
	return Object.keys(localStorage)
		.map(key => JSON.parse(localStorage.getItem(key)));
}

const convertToElement = (link) => {
	return `<div class="link"><h3>${link.title}</h3>
		<p><a href="${link.url}">${link.url}</a></p></div>`;
}

const renderLinks = () => {
	const linkElements = getLinks().map(convertToElement).join('');
	linksSection.innerHTML = linkElements;
}

renderLinks();

 

錯誤的請求路徑

到目前為止,一切似乎都運轉良好。我們的應用程式從外部頁面獲取標題,在本地儲存連結,在頁面上呈現連結,並在需要時從頁面中清除它們。

但是如果出了什麼問題呢?如果我們給它一個無效連結會發生什麼?如果請求超時會發生什麼?我們將處理兩種最可能的情況:當使用者提供一個URL,該URL通過了輸入欄位的驗證檢查,但實際上並不有效;當URL有效,但伺服器返回400或500級錯誤時。

我們新增的第一件事是處理任何錯誤的能力。我們需要提供一個捕獲異常的方法,當出現錯誤的時候,進行呼叫。我們在這個事件中定義了另一個幫助方法。

圖2.31 顯示錯誤訊息: ./app/renderer.js

const handleError = (error, url) => {                     +如果獲取連結失敗,
  errorMessage.innerHTML = `                             |則設定錯誤訊息元素的內容
  There was an issue adding "${url}": ${error.message}   |         +
  `.trim();                                         <----+         |
  setTimeout(() => errorMessage.innerText = null, 5000);      <----+5秒後清除錯誤訊息
}                                                                   +

 

我們可以把它加到鏈上。我們使用另一個匿名函式傳遞帶有錯誤訊息的URL。這主要是為了提供更好的錯誤訊息。如果不希望在錯誤訊息中包含URL,則沒有必要這樣做。

圖2.32 在獲取、解析和呈現連結時捕獲錯誤: ./app/renderer.js

fetch(url)
 .then(response => response.text())
 .then(parseResponse)                          +
 .then(findTitle)                              |
 .then(title => storeLink(title, url))         |如果此處理鏈中的任何錯誤拒絕或丟擲錯誤
 .then(clearForm)                              |則捕獲錯誤並將其顯示在UI中
 .then(renderLinks)                            |
 .catch(error => handleError(error, url));  <--+

 

我們還在前面新增了一個步驟,用於檢查請求是否成功。如果是,它將請求傳遞給處理鏈中的下一個操作。如果沒有成功,那麼我們將丟擲一個錯誤,這將繞過處理鏈中的其餘操作,並直接跳到handleError()步驟。這裡有一個我沒有處理的異常情況:如果Fetch API不能建立網路連線,那麼它返回的承諾將被完全拒絕。我把它作為練習留給讀者來處理,因為我們在這本書中有很多內容要講,而且頁數有限。響應。如果狀態碼在400或500範圍內,response.ok將為false。

圖2.33 驗證來自遠端伺服器的響應: ./app/renderer.js

                                                    +
                                                    |如果響應成功,則將其
const validateResponse = (response) => {            |傳遞給下一個處理鏈
 if (response.ok) { return response; }        <-----+
 throw new Error(`Status code of ${response.status} +
  ${response.statusText}`);           <-----+
}                                            |如果請求收到400或500系列響應
                                             +則引發錯誤。

 

如果沒有錯誤,此程式碼將傳遞響應物件。但是,如果出現錯誤,它會丟擲一個錯誤,handleError()會捕捉到這個錯誤並相應地進行處理。

圖2.34 在處理鏈中新增validateResponse(): ./app/renderer.js

fetch(url)
	.then(validateResponse)
	.then(response => response.text())
	.then(parseResponse)
	.then(findTitle)
    .then(title => storeLink(title, url))
	.then(clearForm)
	.then(renderLinks)
	.catch(error => handleError(error, url));

 

一個意想不到的錯誤

我們還沒有走出困境——如果一切順利的話,我們還有一個問題。如果單擊應用程式中的一個連結會發生什麼?也許並不奇怪,它指向了那個連結。我們的Electron應用程式的Chromium部分認為它是一個web瀏覽器,所以它做了web瀏覽器最擅長的事情—它進入頁面。

只是我們的應用程式並不是真正的web瀏覽器。它缺少後退按鈕或位置欄等重要功能。如果我們點選應用程式中的任何連結,我們就會幾乎被困在那裡。我們唯一的選擇是關閉應用程式,重新開始。

解決方案是在真正的瀏覽器中開啟連結。但這引出了一個問題,哪個瀏覽器?我們如何知道使用者將什麼設定為預設瀏覽器?我們當然不想做任何僥倖的猜測,因為我們不知道使用者安裝了什麼瀏覽器,而且沒有人喜歡看到錯誤的應用程式僅僅因為他們點選了一個連結就開始開啟。 Electron隨shell模組一起載運,shell模組提供了一些與之相關的功能,高階桌面整合。shell模組可以詢問使用者的作業系統他們更喜歡哪個瀏覽器,並將URL傳遞給要開啟的瀏覽器。讓我們從引入Electron開始,並在app/renderer.js的頂部儲存對其shell模組的引用。

列表2.35 引用Electron的shell 模組: ./app/renderer.js

const {shell} = require('electron');

 

我們可以使用JavaScript來確定我們希望在應用程式中處理哪些url,以及我們希望將哪些url傳遞給預設瀏覽器。在我們的簡單應用程式中,區別很簡單。我們希望所有的連結都在預設瀏覽器中開啟。這個應用程式中正在新增和刪除連結,因此我們在linksSection元素上設定了一個事件監聽器,並允許單擊事件彈出。如果目標元素具有href屬性,我們將阻止預設操作並將URL傳遞給預設瀏覽器。

列表2.36 在預設瀏覽器中開啟連結: ./app/renderer.js

                                                     +
                                                    |通過查詢href屬性
                                                    |檢查被單擊的元素是否為連結
linksSection.addEventListener('click', (event) => { |
if (event.target.href) {                       <---+
  event.preventDefault();                   <----+
  shell.openExternal(event.target.href); <--+     |如果它不是一個連線,
}                                           |     |不開啟
Uses Electron’s shell module                 |     +
});                 在預設瀏覽器中使用Electorn   |
                    開啟連結                   +

 

通過相對簡單的更改,我們的程式碼的行為就像預期的那樣。單擊連結將在使用者的預設瀏覽器中開啟該頁。我們有一個簡單但功能齊全的桌面應用程式了。

我們完成的程式碼應該如下面的程式碼示例所示。你可能以不同的順序使用您的功能。

列表2.37 完成的應用程式: ./app/renderer.js

const {shell} = require('electron');

const parser = new DOMParser();

const linksSection = document.querySelector('.links');
const errorMessage = document.querySelector('.error-message');
const newLinkForm = document.querySelector('.new-link-form');
const newLinkUrl = document.querySelector('.new-link-url');
const newLinkSubmit = document.querySelector('.new-link-submit');
const clearStorageButton = document.querySelector('.clear-storage');

newLinkUrl.addEventListener('keyup', () => {
newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});

newLinkForm.addEventListener('submit', (event) => {
event.preventDefault();

const url = newLinkUrl.value;

fetch(url)
  .then(response => response.text())
  .then(parseResponse)
  .then(findTitle)
  .then(title => storeLink(title, url))
  .then(clearForm)
  .then(renderLinks)
  .catch(error => handleError(error, url));
});

clearStorageButton.addEventListener('click', () => {
localStorage.clear();
linksSection.innerHTML = '';
});

linksSection.addEventListener('click', (event) => {
if (event.target.href) {
  event.preventDefault();
  shell.openExternal(event.target.href);
}
});


const clearForm = () => {
newLinkUrl.value = null;
}

const parseResponse = (text) => {
return parser.parseFromString(text, 'text/html');
}

const findTitle = (nodes) => {
return nodes.querySelector('title').innerText;
}

const storeLink = (title, url) => {
localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
}

const getLinks = () => {
return Object.keys(localStorage)
              .map(key => JSON.parse(localStorage.getItem(key)));
}

const convertToElement = (link) => {
return `<div class="link"><h3>${link.title}</h3>
        <p><a href="${link.url}">${link.url}</a></p></div>`;
}

const renderLinks = () => {
const linkElements = getLinks().map(convertToElement).join('');
linksSection.innerHTML = linkElements;
}

const handleError = (error, url) => {
errorMessage.innerHTML = `
  There was an issue adding "${url}": ${error.message}
`.trim();
setTimeout(() => errorMessage.innerText = null, 5000);
}

const validateResponse = (response) => {
if (response.ok) { return response; }
throw new Error(`Status code of ${response.status} ${response.statusText}`);
}

renderLinks();

 

相關文章