[譯] 超越瀏覽器:從 web 應用到桌面應用

超越瀏覽器:從 web 應用到桌面應用

一開始我是個 web 開發者,現在我是個全棧開發者,但從未想過在桌面上有所作為。我熱愛 web 技術,熱愛這個無私的社區,熱愛它對于開源的友好,嘗試挑戰極限。我熱愛探索好看的網站和強大的應用。當我被指派做桌面應用任務的時候,我非常憂慮和害怕,因為那看起來很難,或者至少不一樣。

這并不吸引人,對吧?你需要學一門新的語言,甚至三門?想象一下過時的工作流,古舊的工具,沒有任何你喜歡的有關 web 的一切。你的職業發展會被怎樣影響呢?

別慌,深呼吸,現實情況是,作為 web 開發者,你已經擁有開發現代桌面應用所需的一切技能,得益于新的強大的 API,你甚至可以在桌面應用中發揮你最大的潛能。

本文將會介紹使用 NW.jsElectron 開發桌面應用,包括它們的優劣,以及如何使用同一套代碼庫來開發桌面、web 應用,甚至更多。

為什么?

首先,為什么會有人開發桌面應用?任何現有的 web 應用(不同于網站,如果你認為它們是不同的)都可能適合變成一個桌面應用。你可以圍繞任何可以從與用戶系統集成中獲益的 web 應用構建桌面應用;例如本地通知、開機啟動、與文件的交互等。有些用戶單純更喜歡在自己的電腦中永久保存一些 app,無論是否聯網都可以訪問。

也許你有個想法,但只能用作桌面應用,有些事情只是在 web 應用中不可能實現(至少還有一點,但更多的是這一點)。你可能想要為公司內部創建一個獨立的功能性應用程序,而不需要任何人安裝除了你的 app 之外的任何內容(因為內置 Node.js )。也許你有個有關 Mac 應用商店的想法,也許只是你的一個個人興趣的小項目。

很難總結為什么你應該考慮開發桌面應用,因為真的有很多類型的應用你可以創建。這非常取決于你想要達到什么目的,API 是否足夠有利于開發,離線使用將多大程度上增強用戶體驗。在我的團隊,這些都是毋庸置疑的,因為我們在開發一個聊天應用程序。另一方面來說,一個依賴于網絡而沒有任何與系統集成的桌面應用應該做成一個 web 應用,并且只做 web 應用。當用戶并不能從桌面應用中獲得比在瀏覽器中訪問一個網址更多的價值的時候,期待用戶下載你的應用(其中自帶瀏覽器以及 Node.js)是不公平的。

比起描述你個人應該建造的桌面應用及其原因,我更希望的是激發一個想法,或者只是激發你對這篇文章的興趣。繼續往下讀來看看用 web 技術構造一個強大的桌面應用是多么簡單,以及在創建過程中你應該付出什么。

NW.js

桌面應用已經有很長一段時間了,我知道你沒有很多時間,所以我們跳過一些歷史,從 2011 年的上海開始。來自 Intel 開源技術中心的 Roger Wang 開發了 node-webkit,一個概念驗證的 Node.js 模塊,這個模塊可以讓用戶創建一個 WebKit 內核的瀏覽器窗口并直接在 <script> 中調用 Node.js 模塊。

經過一段時間的開發以及將內核從 WebKit 轉換到 Chromium(Google Chrome 基于這個開源項目開發),一個叫 Cheng Zhao 的實習生加入了這個項目。不久就有人意識到一個基于 Node.js 和 Chromium 運行的應用是一個很好的建造桌面應用的框架。于是這個項目變得頗受歡迎。

注意:node-webkit 后來更名為 NW.js,是因為項目不再使用 Node.js 以及 WebKit,所以需要改一個更通用的名字。Node.js 的替換選擇是 io.js (Node.js fork 版本),Chromium 也已經從 WebKit 轉為它自己的版本 —— Blink。

所以,如果現在去下載一個 NW.js 應用,實際上是下載了 Chromium、Node.js,以及真正的 app 的代碼。這不僅意味著桌面應用也可以使用 HTML、CSS、JavaScript 來寫,也意味著 app 可以直接使用所有 Node.js 的 API(比如讀取或寫入硬盤),而對于終端用戶,沒有比這更好的選擇了。這看起來非常強大,但是它是怎么實現的呢?我們先來了解一下 Chromium。

Chromium diagram
Chromium diagram

Chromium 有一個主要的后臺進程,每個標簽頁也會有自己的進程。你可能注意到 Google Chrome 在 Windows 的任務管理器或者 macOS 的活動監視器上總是至少存在兩個進程。我并沒有嘗試在這里安排穿插主后臺進程相關的內容,但是它包括了 Blink 渲染引擎、V8 JavaScript 引擎(也構建了 Node.js )以及一些從原生 API 抽象出來的平臺 API。每個獨立的標簽頁或渲染的過程都可以使用 JavaScript 引擎、CSS 解析器等,但為了提高容錯性,它們又和主進程是完全隔離的。渲染進程與主進程之間是用進程間通信(IPC)來進行通訊。

NW.js diagram
NW.js diagram

大致上這就是一個 NW.js app 的結構,它和 Chromium 基本一致,除了每個窗口也可以訪問 Node.js。現在,你可以訪問 DOM,可以訪問其他腳本、npm 安裝的模塊,或者 NW.js 提供的內置的模塊。你的 app 默認只有一個窗口,但從這一個窗口,可以生成其他窗口。

創建一個應用很簡單,只需要一個 HTML 文件和一個 package.json 文件,就像你平時使用 Node.js 時那樣。你可以使用 npm init --yes 新建一個默認的。一般來說,package.json 會指定一個 JavaScript 文件作為模塊的入口(也就是使用 main 屬性),但是如果是 NW.js,你需要去編輯一下 main 指向你的 HTML 文件。

{
  "name": "example-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.html",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Example app</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <h1>Hello, world!</h1>
  </body>
</html>

只要你安裝好了 nw(通過 npm install -g nw),你就可以在項目目錄下執行 nw . 啟動 app,然后就可以看到下圖。

Example app screenshot
Example app screenshot

就是這么簡單。NW.js 初始化了第一個窗口,加載了你的 HTML 文件,雖然這看起來并沒有什么,但接下來就是你來添加標簽及樣式了,就和在 web 應用中一樣。

你可以憑自己喜好去掉窗口欄,構建自己的框架模板。你可以有半透明或全透明的窗口,可以有隱藏窗口或者更多。我最近嘗試使用 NW.js 做了Clippy(Office 助手)。能在 macOS 和 Windows 10 上看到它有種奇妙的滿足感。

Screenshot of clippy.desktop on macOS
Screenshot of clippy.desktop on macOS

現在你可以寫 HTML,CSS 和 JavaScript 了,你可以使用 Node.js 讀寫硬盤、執行系統命令、生成其他可執行文件等等。設想一下,你甚至可以通過 WebRTC 造一個多玩家的輪盤賭游戲,隨機刪除其他人的文件。

Bar graph showing the number of modules per major package manager
Bar graph showing the number of modules per major package manager

你不僅可以使用 Node.js 的 API,還有所有 npm 的包,現在已經有超過 35 萬個了。例如,auto-launch 是我們在 Teamwork.com 做的開源包,用來開機啟動 NW.js 或者 Electron 應用。

如果你需要做一些偏底層的事,Node.js 也有原生的模塊,能讓你使用 C 或者 C++ 創建模塊。

總之,NW.js 高效封裝了原生的 API,讓你可以簡單地與桌面環境集成。比如你有一個任務欄圖標,使用系統默認應用打開一個文件或者 URL 之類的。你需要做的是使用 HTML5 notification 的 API 觸發一個通知:

new Notification('Hello', {
  body: 'world'
});

Electron

你可能認出來了,下圖是 GitHub 開發的編輯器,Atom。不管你是否使用 Atom,它的出現對于桌面應用都是一個顛覆者。GitHub 從 2013 年開始開發 Atom,后來 Cheng Zhao 加入,fork 了 node-webkit 作為基礎,后來以 atom-shell 為名開源。

Atom screenshot
Atom screenshot

注意:對于 Electron 只是 node-webkit 的 fork,還是一切從頭重新做的,是很有爭議的。但無論哪種方式,最終都成為終端用戶的一個分支,因為 API 幾乎完全一致。

在開發 Atom 的過程中,GitHub 改進了一些方案,也解決了很多 bug。2015年,atom-shell 正式更名為 Electron。它的版本已經更新到 1.0 以上(譯注:最新正式版本為v1.3.14),并且因為 GitHub 的推行,它已經真正發展壯大了。

Logos of projects that use Electron
Logos of projects that use Electron

和 Atom 一樣,其他用 Electron 開發的有名項目包括 Slack、Visual Studio Code、 Brave、HyperTerm、Nylas,真的是在做著一些尖端的東西。Mozilla Tofino 也是其中很有趣的一個,它是 Mozilla( FireFox 的公司)的一個內部項目,目標是徹底優化瀏覽器。你沒看錯,Mozilla 的團隊選擇了 Electron (基于 Chromium )來做這個實驗。

Electron 有什么不同呢?

那么 Electron 和 NW.js 有什么不同?首先,Electron 沒有 NW.js 那么面向瀏覽器,Electron app 的入口是一個在主進程中運行的腳本。

Electron architecture diagram
Electron architecture diagram

Electron 團隊修補了 Chromium 以便嵌入多個可以同時運行的 JavaScript 引擎,所以當 Chromium 發布新版本的時候,他們不需要做任何事。

注意:NW.js 與 Chromium 的綁定不太一樣,造成了 NW.js 經常被指責不如 Electron 那樣緊跟 Chromium。然而,整個 2016 年,NW.js 每次在 Chromium 發布主要版本之后的 24 小時內發布新版本,這很大程度也歸功于團隊組織轉型。

回到主進程的話題,你的應用默認是沒有窗口的,但是你可以從主進程開啟任意多個窗口,每個窗口和 NW.js 一樣有自己的渲染進程。

那么當然,創建一個 Electron app,你需要的只是一個 JavaScript 文件(現在暫時只是個空文件)以及一個 package.json 文件指向它。然后你只需要執行 npm install --save-dev electron,以及 electron . 來啟動你的 app。

{
  "name": "example-app",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
// main.js 文件,現在是空的

沒有什么會發生,因為你的 app 沒有默認窗口。接下來你可以和 NW.js 應用一樣打開任意多個窗口,每個都有各自的渲染進程。

// main.js
const {app, BrowserWindow} = require('electron');
let mainWindow;

app.on('ready', () => {
  mainWindow = new BrowserWindow({
    width: 500,
    height: 400
  });
  mainWindow.loadURL('file://' + __dirname + '/index.html');
});
<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Example app</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <h1>Hello, world!</h1>
  </body>
</html>

你可以在這個窗口中加載遠程 URL,但是一般來說你會在本地創建 HTML 文件并加載它,當當當當~加載出來啦!

Screenshot of example Electron app
Screenshot of example Electron app

在 Electron 提供的內置模塊中,像在前面例子中使用的 appBrowserWindow,大多只能要么在主進程要么在某個渲染進程中使用。比方說,你只能在主進程中管理你的所有窗口,自動更新或者其他。你可能想在主進程中點擊一個按鈕觸發一些事件,因此 Electron 為 IPC 提供了一些內置方法?;旧夏憧梢杂|發任意的事件,然后在另一端監聽它們。這樣,你就可以在某一個渲染進程中捕獲 click 事件,通過 IPC 發出事件信息給主進程,主進程捕獲后執行相關操作。

Electron 有著不同的進程,你需要稍微不同地組織你的 app,但這不算什么。為什么人們使用 Electron 而不是 NW.js?這其中有影響力的因素,它的流行造就了許多相關的工具和模塊。 Electron 的文檔更好懂,最重要的是,Electron 的 bug 更少,并且有更好的 API。

Electron 的文檔非常棒,這值得再強調一下。拿 Electron API Demos app 來說,這是個 Electron app,它可以交互式的演示出你可通過 Electron 的 API 做到什么。比如新建窗口,它不僅提供了 API 的描述以及示例代碼,甚至點擊按鈕的確可以執行代碼并打開新的窗口。(下圖就是 Electron API Demos app 的截圖)

A screenshot of the Electron API Demos app
A screenshot of the Electron API Demos app

如果你通過 Electron 的 bug 追蹤器提交問題,你可以在幾天之內得到回復。我曾經見過 NW.js 有經過三年都未修復的 bug,我并不是堅決反對他們這么做,開發開源項目采用的語言和使用這個項目的開發者了解的語言如此的不同,是非常難維護的。NW.js 和 Electron 主要是用 C++ (以及少部分 Objective C++)寫的,但是使用這兩個項目的人寫的是 JavaScript。我非常感激 NW.js 給我們的幫助。

Electron 彌補了 NW.js API 上的一些不足。比如,你可以綁定全局的鍵盤快捷鍵,這樣即使你的 app 并沒有獲取焦點,鍵盤事件也可以被捕獲。曾經我在 NW.js 的應用中碰到過一個 API 的漏洞,就是我在 Windows 上可以綁定 Control + Shift + A 快捷鍵達到預期目的,但是實際上到了 Mac 上綁定的快捷鍵是 Command + Shift + A,這個的確是有意而為之的,但是仍然很奇怪。沒有任何方法可以在 Mac 上綁定 Control 鍵。另外,如果想綁定 Command 鍵,在 Mac 上的確沒問題,而到了 Windows 和 Linux 上綁定的卻是 Windows 鍵。Electron 的團隊發現了這些問題(我猜是在給 Atom 添加快捷鍵的時候),然后他們很快更新了他們自己的全局快捷鍵(globalShortcut)API,以上遇到的情況就可以正常工作了。公平起見,NW.js 修復了前一個問題,但一直沒有修復后一個。

還有其他一些不同的地方。比如說,之前原生的 notification 通知,在最近的 NW.js 版本中,變成了 Chrome 風格的了。這種通知不會進入到 Mac OS X 或者 Windows 10 的通知中心里面,但是在 npm 上有方便使用的模塊解決。如果你想做一些有趣的有關音頻或視頻的東西,建議使用 Electron,因為有些解碼器和 NW.js 不兼容。

Electron 還添加了一些新的 API,更加多地與桌面端的集成,并且內置了自動升級,我稍后會談到。

但是感覺如何呢?

感覺很好,當然,它并不是原生的?,F在大多數桌面應用并不會長得像資源管理器或者 Finder,所以用戶并不介意或者意識到用戶界面背后是 HTML。你愿意的話,你可以使之更像原生應用,但是我并不認為那樣會讓用戶體驗更好。比如,你可以在用戶將鼠標懸停在按鈕上時,不讓光標變成手,一般原生的桌面應用都是這樣做的,但是這樣做有什么好的嗎?當然也有像 Photon Kit 這樣的類似 Bootstrap 的 CSS 框架,可以做出 macOS 風格的組件。(下圖是 Photon Kit 做出的組件 demo)

Photon app example screenshot
Photon app example screenshot

性能

性能表現如何呢?會很慢或者延遲嗎?其實你的 app 本質上來說仍然是 web 應用,所以它會和在 Google Chrome 中運行的 web app 非常類似。你可能會創造出高性能的或者反應遲緩的 app,但是沒關系,你已經有分析并提升性能的技能了。app 基于 Chromium 最好的其中一點就是你可以使用它的開發者工具。你可以在 app 內調試或者遠程調試,Electron 團隊也開發了一款開發者工具的插件叫 Devtron 來監控一些 Electron 特定的信息。

不過,你的桌面應用可以比 web 應用的性能更高。因為你可以創建一個工作窗口,一個用于執行耗能昂貴工作的隱藏窗口。因為每個進程都是孤立的,所以任何在這個窗口中進行的計算或者處理不會影響到其他可見窗口的渲染進程,上下滾動等等。

記住你總可以生成系統指令、可執行文件,或者原生代碼,如果真的需要的話(你不會真的這么做的)。

分發

NW.js 和 Electron 都支持很多平臺,包括 Windows,Mac 和 Linux。Electron 不支持 Windows XP 和 Vista,但 NW.js 支持。將 NW.js 應用上線到 Mac App Store 有些棘手,你必須繞幾個彎子。而 Electron 支持直接的 Mac App Strore 兼容的版本,和普通的版本一樣,只是某些模塊你無法訪問,比如自動更新(因為你的 app 會通過 Mac App Store 進行更新所以可以接受)。

Electron 甚至支持 ARM 版本,所以你的 app 可以在 Chromebook 或者樹莓派上運行,最終,Google 可能會逐步淘汰 Chrome 封裝應用 (Packaged App),但是 NW.js 仍然支持將應用程序移植到 NW.js 應用,并且仍然可以訪問相同的 Chromium API。

雖然 32 位和 64 位的版本都支持,所以你完全可以使用 64 位的 Mac 和 Windows 應用。但是,為了兼容,32 位和 64 位 Linux 應用程序是都需要的。

假如 Electron 勝出,你想發行一個 Electron 應用。有一個很不錯的 Node.js 包叫 electron-packager 可以幫你將 app 打包成一個 .app 或者 .exe 文件。也有其他幾個類似的項目,包括交互式的一步一步告訴你該怎么做。不過,你應該用 electron-builder,它以 electron-packager 為基礎,添加了其他幾個相關的模塊,生成的是 .dmg 文件和 Windows 安裝包,并且為你處理好了代碼簽名的問題。這很重要,如果沒有這一步,你的應用將會被操作系統認為是不可信的,你的應用程序可能會觸發防毒軟件的運行,Microsoft SmartScreen 可能會嘗試阻止用戶啟動你的應用。

關于代碼簽名的令人討厭的事情是,你必須單獨為某個平臺簽名你的應用程序,比如在 Mac 上簽名 Mac 應用,在 Windows 簽名 Windows 應用。因此,如果你很在乎發行桌面應用的話,就必須為每個發行版本分別構建適用于不同平臺的應用(以及分別簽名)。

這可能會感到不夠自動化很繁瑣,特別是如果你習慣于在 web 上創建。幸運的是,electron-builder 被創造出來完成這些自動化工作。我說的是持續集成工具例如 Jenkins、CodeShip、Travis-CIAppVeyor(Windows 集成)等。這些工具可以讓你按一個按鈕或者每次更新代碼到 GitHub 時重新構建你的桌面應用。

自動更新

NW.js 沒有支持自動更新,但是由于我們可以隨意使用 Node.js,我們可以做任何事情。開源模塊可以幫你實現,比如 node-webkit-updater 可以下載并替換為更新版本的 app。當然你也可以自己造輪子。

通過 autoUpdater API,Electron 自帶支持自動更新。但是它不支持 Linux 系統,所以我們建議發布你的 app 到 Linux 包管理器。不必擔心,這在 Linux 上很常見。autoUpdater API 使用非常簡單,給定一個 URL 就可以調用 checkForUpdates 方法。因為它是事件驅動,所以你可以訂閱 update-downloaded 事件,一旦該事件觸發,就調用 restartAndInstall 方法來下載新版本 app 并且重啟。你可以監聽一些其他的事件,將自動更新和用戶界面很好的捆綁起來。

注意:你可以使用多個更新渠道,比如 Google Chrome 和 Google Chrome Canary。

API 背后的邏輯可就沒這么簡單了。它是基于 Squirrel 更新框架,用來區分 Mac 和 Windows 平臺,對應的軟件分別是 Squirrel.MacSquirrel.Windows。

Mac 上的 Electron app 和更新有關的代碼非常簡單,但是你還是需要一個簡單的服務器。一旦你調用 autoUpdater 模塊中的 checkForUpdates 的方法,它會訪問服務器。如果沒有更新,服務器返回 204(“No Content”);如果有更新,則返回 200 和一個包含 .zip 文件 URL 的 JSON。再回到客戶端 app,Squirrel 知道接下來該怎么做:它會下載 .zip,解壓然后觸發相應的事件。

Windows 平臺上 app 的更新需要更多點功夫。你不一定需要一臺服務器。你可以把靜態文件部署在某些地方,比如亞馬遜的 AWS S3,或者甚至放在本地機器,可以方便測試。雖然 Mac 平臺上的 Squirrel 和 Windows 平臺上的 Squirrel 有些不同,但是依然有折中的辦法來實現更新,比如給每個平臺都分別部署一個服務器,或者把更新文件放在 S3 或者其他地方。

Squirrel.Windows 有些很不錯的特性是 Squirrel.Mac 所沒有的。Squirrel.Windows 在后臺實現更新,所以當你調用restartAndInstall,速度會更快,因為本地已經提前下載好了需要的更新文件。Squirrel.Windows 也支持 delta 更新,比如 app 檢測到新版本需要更新,需要更新的部分會以補丁包的方式被下載和安裝,而不是重新下載整個新的 app。假如當前的 app 要比最新版本低三個版本,Squirrel.Windows 甚至可以按照遞增的方式來下載和安裝需要的更新。當然如果當前 app 已經落后最新版本 15 個版本,Squirrel.Windows 就直接下載和安裝整個最新的 app。這些功能底層已經幫你實現好了,API 使用起來依然很簡單。你只需要檢查更新,系統會幫你找到最優方案實現更新,并且告知用戶更新完畢。

注意:雖然這些補丁包也必須部署在服務器上,但是 electron-builder 會幫你生成這些文件。

感謝 Electron 社區,讓我們不一定非要構建自己的服務器。有很多開源項目幫助你實現把更新文件部署在 S3 上,或者用 GitHub release,甚至還有提供后臺控制面板來管理不同的更新版本。

桌面應用和網頁應用的對決

那么桌面 app 到底和 web app 有些哪些不同?讓我們來看看你可能遇到的一些意想不到的問題或收獲,比如在 web 平臺上使用 API 的副作用以及工作流中的痛點還有維護困難等。

第一件事情就是瀏覽器限定(browser lock-in),你也許會因此暗自高興。假如你只做桌面 app,你很清楚用戶用的是哪個版本的 Chromium。讓我們來假設一下:你可以在 app 當中用到 flexbox,ES6,原生的 WebSocket,WebRTC 以及任何你想到的東西。你甚至可以在 app 當中開啟尚在測試的 Chromium 特性,或者允許使用 localStorage。你根本不用處理任何跨瀏覽器的兼容問題?;?Node.js API 和 NPM,你可以做任何事情。

注意:但你依然需要考慮用戶在使用什么樣的操作系統。不過相比較不同瀏覽器之間的問題,跨操作系統的兼容性處理要更簡單些。

處理 file://

另外一個有趣的事情是你的 app 要做到離線優先(offline-first)。在構建 app 的時候需要牢記的是,用戶即使在沒有網路的情況下也能正常使用 app,載入本地文件。你需要認真考慮 app 在網絡條件差的情況下,如何正常工作。你可能需要改變思考問題的方式。

注意:你可以載入遠程 URL,但是我不建議這么做。

我給出的建議是不要完全相信 navigator.onLine。這個屬性會返回布爾值來反饋是否存在網絡連接,不過請注意誤報。如果有本地連接它就返回 true 而不去驗證連接的有效性。網絡連接雖然顯示成功,但是可能實際上無法正常訪問網頁。比如本地機器到 Vagrant 虛擬機的連接會被誤認為是成功的網絡連接。所以,請使用 Sindre Sorhus 的 is-online 來復核網絡連接狀態。它會 ping 互聯網的根服務器或者一些著名網站的 favicon 文件。比如:

const isOnline = require('is-online');

if(navigator.onLine){
  // hmm there's a connection, but is the Internet accessible?
  isOnline().then(online => {
    console.log(online); // true or false
  });
}
else {
  // we can trust navigator.onLine when it says there is no connection
  console.log(false);
}

說到本地文件,有幾件事情需要注意,比如你無法使用少協議(protocol less)的 URL,我的意思是比如用 // 代替 http:// 或者 https://。理論上,如果一個 web app 在請求 //example.com/hello.json 時,瀏覽器會把地址擴展為 http://example.com/hello.json 或者 https://example.com/hello.json (如果當前頁面是通過 HTTPS 加載)。在我們的 app 當中,如果這么做,當前頁面會使用 file:// 協議。所以,當我們請求同樣的 URL 時候,app 會把地址擴展為 file://example.com/hello.json 然后請求失敗。我們真正要擔心的是那些第三方模塊;那些作者可能并沒有按照桌面 app 的思路來制作模塊。

你不會使用到 CDN,因為載入本地文件基本上是瞬間完成的。而且不像瀏覽器,你沒有同時請求數量的限制,至少不會像 HTTP/1.1 那樣。你可以并發載入盡可能多的文件。

大量文件生成

構建一個可靠穩固的桌面 app 需要生產大量的文件。你需要為一個自動更新的系統生成可執行文件和安裝包。然后對應的每一個更新,都需要再次構建可執行文件和更多的安裝包(因為如果有人去你的網站下載,他們應當下載到最新版本)以及針對增量更新(delta update)的更新補丁。

文件大小仍然是一個需要考慮的問題。一個“Hello, World!”的 Electron app 壓縮包是 40 MB。在構建 web app 的時候,除了遵循一些常見規則外(比如寫更少的代碼、壓縮文件、使用更少的依賴等等),我可以提供的意見不多?!癏ello World” app 本質上就是一個包含了 HTML 文件的 app;占 app 體積的絕大多數文件是來自 Chromium 和 Node.js。至少在 Windows 平臺上增量更新可以有效減少下載文件的大小。但是我希望用戶不要在 2G 網絡上去下載文件。

預判意外狀況

在日后你一定會遇到一些意想不到的事情。有些事情要比其他更明顯而且讓人惱火。比如你制作了一個音樂播放器的 app,它支持迷你化,在其他應用之上用小窗口展示。假如用戶點擊了下拉菜單,app 會展示可選項,從 app 的底部邊界溢出。如果你使用了非原生的包(比如 select2 或者 chosen),你會因此陷入麻煩。在打開下拉菜單的時候,它會被 app 的底部邊界切割。用戶會看到很少的選項甚至什么也看不到,這確實讓人無語。當然這件事也會發生在瀏覽器上。但是用戶不太可能會調整窗口到那么小。

Screenshots comparing what happens to a native dropdown versus a non-native one
Screenshots comparing what happens to a native dropdown versus a non-native one

你也許會知道,在 Mac 上每一個窗口都有一個 header 和 body。當窗口沒有聚焦的時候,如果你把鼠標停留在 header 里面的圖標或者按鈕上,窗口的外觀會對應的顯示為鼠標停留狀態。舉個例子,macOS 上窗口的關閉按鈕在未被停留時是灰色模糊的,當鼠標停留時,按鈕變成紅色。但是如果鼠標只是停留在 body 上,窗口外觀不會發生改變。這是有意而為之的設計。讓我們再回到我們的桌面 app,基于 Chromium 的 app 是沒有 header,整個 web app 就是窗口 body。你可以不用原生的框架而創建自己的 HTML 按鈕來取代原生的最小化,最大化還有關閉按鈕。如果窗口沒有被聚焦,當鼠標停留的時候,窗口不會有任何變化。Hover 的樣式沒有被應用,這總讓人感覺不太對。更糟糕的是,只有在點擊關閉按鈕的時候,窗口才會被聚焦。然后你還得再次點擊關閉按鈕來真正關閉當前窗口。

雪上加霜的是,Chromium 有一個 bug 可以掩蓋這個問題,讓你以為窗口會按照你期待的樣子工作。把鼠標從窗口外移動到窗口內的元素,如果你移動得足夠快,hover 樣式會被應用。這是已經確認的 bug。把 hover 樣式應用在一個模糊化的窗口 body 上“并不滿足當前系統平臺的要求”,日后該 bug 會被修復。但愿我上面說的話不會讓你太心碎。事實上,你可以創建一個足夠漂亮的自定義窗口控制區,但現實是許多用戶會因此苦惱(他們會懷疑這到底是不是原生的)。

所以你必須用到 Mac 原生的按鈕。沒有其他更好的辦法了。對于 NW.js app,你必須開啟使用原生框架(你也可以通過在 package.json 里面把 window 的屬性 frame 設置為 false 來關閉使用原生框架)。

Electron app 也可以實現同樣效果。比如設置 new BrowserWindow({width: 800, height: 600, frame: true}) 來創建窗口。Electron 官方團隊就是這么做的,他們還加入另外一種不錯的選項:把 titleBarStyle 設置成 hidden 會隱藏原生標題欄但是通過覆蓋 app 左上角來保留原生的窗口控制。 這樣就解決了之前的問題,但同時可以使用在左上角使用自定義按鈕。

// main.js
const {app, BrowserWindow} = require('electron');
let mainWindow;

app.on('ready', () => {
  mainWindow = new BrowserWindow({
    width: 500,
    height: 400,
    titleBarStyle: 'hidden'
  });
  mainWindow.loadURL('file://' + __dirname + '/index.html');
});

下面這張圖,我禁用了標題欄然后設置了html 的背景圖片:

A screenshot of our example app without the title bar
A screenshot of our example app without the title bar

詳見 Electron 官方文檔 “Frameless Window57

工具

你可以盡情地使用在構建 web app 時候用到的工具。你的 app 其實就是 HTML,CSS 還有 JavaScript 不是嗎?針對桌面 app 開源社區也有豐富的插件和模塊供你使用,比如你可以用 Gulp 插件來為你的 app 簽名(如果你不打算用 electron-builder)。Electron-connect 可以用來監控文件改動,如果主要的腳本文件有改動,它會在打開的窗口中應用這些改動或者重啟 app。畢竟這就是 Node.js,你可以做任何事情。你也可以在 app 中用到 webpack 如果你想的話,雖然我不知道為什么要這么做,但這也是一個選擇嘛。詳情見 awesome-electron 獲取更多資源。

版本發布流程

維護和開發一個桌面應用是怎么樣的體驗?首先,發行版本流是完全不一樣的。觀念上就需要重新調整。在開發 web app 的時候,如果部署了之后然后遇到問題,這些都不是事。你直接修復 bug 就行了。新用戶直接訪問頁面或者老用戶重新加載頁面就能得到最新的代碼。開發者一旦有新任務,就直接去完成任務或者修復 bug 就好了。但是開發桌面 app 可不是這樣。一旦冒失犯錯,就無法撤回。這特別像開發移動 app 一樣。你構建了 app,然后發布,就不可能撤回了。有些用戶可能都不會從立即更新到最新的修復版本。這些存在于舊版本的 bug 可能會讓你非常苦惱。

量子力學

考慮到要服務于不同版本的 app,你的代碼會以不同的形式和狀態而存在。多個版本的客戶端(桌面 app)會以多種方式訪問你的 API。所以你得認真考慮 API 的版本控制問題,做好測試。當 API 有變化時,你無法獲知此次變動會不會造成問題。一個月前發布的版本可能會因為一些代碼的變動而發生崩潰。

亟待解決的問題

你也許會遇到一些很奇怪的問題,一些涉及到奇怪的賬戶管理,反病毒軟件或者更糟。我之前遇到過一個案例,用戶自己安裝某些文件導致系統環境變量被修改。這直接導致了我們的 app 當中某個重要的依賴安裝失敗,因為系統命令無法找到。這些案例提醒我們有些情況下必須劃清界限,這對我們的 app 很重要,所以不能忽略報錯,但我們也不能幫用戶修好電腦。對于遇到這種問題的用戶,他們的多數桌面應用頂多也是無法正常啟動。最后我們決定如果再次報錯,用戶會看到一條鏈接到文檔的報錯信息,這個文檔用來解釋錯誤為什么會發生,同時告訴用戶如何一步步去修復錯誤。

當然,一些基于 web 的顧慮將不再適配于桌面 app,比如一些歷史遺留的瀏覽器問題。但有一些新的問題需要考慮,比如在 Windows 上文件路徑有 256 字節大小的限制。

舊版本的 npm 采用遞歸的文件結構存儲依賴。你的依賴都各自存儲在項目中的 node_modules 目錄下的文件夾里(例如, node_modules/a)。如果依賴模塊自己本身也有依賴模塊,這些子級的子級依賴會被存儲在父級的 node_modules 中,比如 node_modules/a/node_modules/b。因為 Node.js 和 npm 鼓勵使用小巧的單用途模塊,你可能會很容易遇到長路徑,比如 path/to/your/project/node_modules/a/node_modules/b/node_modules/c/.../n/index.js。

注意:版本 3 之后 npm 盡可能地扁平化依賴關系樹。但是也存在一些其他原因導致長路徑。

我們之前遇到一個問題,就是在特定版本的 Windows 上因為路徑太長 app 無法正常啟動或者啟動之后就崩潰。這是個很頭痛的問題。使用 Electron 時,你可以把所有代碼放在 asar archive 當中。雖然使用這種方法也存在例外而不能保證永遠都能正常使用。

我們做了一個小小的 Gulp 插件 gulp-path-length 用來告知開發者當前 app 當中是否存在任何危險的長文件路徑。終端用戶將 app 放在哪里才能最終決定是否存在長文件路徑。舉個例子,假如安裝包安裝在 C:\Users\<username>\AppData\Roaming,當 app 構建完成(在本地通過持續集成服務完成),gulp-path-length 會用來監控是否當前目錄下存在長文件路徑(比如用戶機器上的用戶名過長而導致問題)。

var gulp = require('gulp');
var pathLength = require('gulp-path-length');

gulp.task('default', function(){
    gulp.src('./example/**/*', {read: false})
        .pipe(pathLength({
            rewrite: {
                match: './example',
                replacement: 'C:\\Users\\this-is-a-long-username\\AppData\\Roaming\\Teamwork Chat\\'
            }
        }));
});

關鍵性錯誤真的很致命

因為所有的自動更新都發生在 app 內部,在每次檢查更新前,未捕獲的異常會導致 app 崩潰。假設你發現了一個 bug 然后發布了新版本進行修復。如果用戶啟動 app,自動更新開始下載,然后 app 崩潰。如果用戶重新啟動 app,自動更新再次下載,再次崩潰...所以,你必須想盡辦法讓用戶知道他們需要重新安裝 app。相信我,這確實很糟糕。

分析和 bug 報告

你很可能想追蹤 app 的使用情況和各種錯誤。首先 Google Analytics 不起作用。你得找到一個分析工具可以支持 file:// URL。如果你正使用工具來追查錯誤,假如工具支持發布版本追蹤,一定要確保錯誤和版本掛鉤。例如,如果你使用 Sentry 追蹤錯誤,確保在設定客戶端的時候設定了正確的 release 屬性 ,這樣錯誤會按照版本分類。否則當你收到錯誤報告準備修復錯誤的時候,你會持續收到錯誤報告和日志,這當中會包含一些誤報。而這些誤報來自用戶正在使用舊版本 app。

Electron 包含了 crashReporter 模塊,該模塊在 app 完全崩潰后(例如整個 app 崩潰,而不是錯誤拋出)自動向開發者發送報告。你也可以監聽一些事件用來指示 app 的渲染進程無法響應。

安全

當接收用戶輸入或者信任第三方腳本的時候需要格外注意,因為惡意攻擊者會用各種意想不到的方式來使用 Node.js。而且記住永遠不要在未經檢查直接接受用戶輸入并傳值到原生 API 或者命令。

也不要相信來自 vendors 的代碼。我們最近遇到的問題來自公司 X 的分析應用的第三方代碼片段。官方團隊在發布的新版本當中包含了問題代碼,導致了 app 致命錯誤。當用戶啟動 app 的時候,代碼片段從 CDN 獲取最新的 JavaScript 代碼然后運行,隨后拋出異常導致 app 無法繼續運行。任何正在運行的 app 都不會受到影響,但是一旦重新打開 app 就會產生問題。我們聯系公司 X 客服,隨后他們發布了修復版本。如果再次重啟 app 就會正常運行了,雖然已經解決了問題,但是回頭想想還是很讓人擔心。如果我們不去強制受影響的用戶手動下載修復版本的 app,我們自己就很難直接解決問題。

該怎么樣才能規避風險呢?也許你可以試著捕獲報錯,但是你完全不知道公司 X 在 JavaScript 里面究竟做了什么。你最好使用更可靠穩固的代碼。你可以加入一層抽象,不直接在 <script> 指向公司 X 的URL而使用 Google Tag Manager 或者你自己的 API 來返回包含有 <script> 標簽的 HTML 文件或者包含所有第三方依賴的單獨的 JavaScript 文件。這樣在避免重新安裝新版本的情況下,指定任意第三方代碼片段被加載。

但是,假如 API 不再返回用來分析的代碼片段,之前被代碼片段創建的全局變量依然會存在你的代碼當中,這些全局變量會嘗試調用未定義的函數。所以我們并沒有完全解決問題。而且,如果用戶沒有聯網就打開 app,API 調用會失敗。你并不想在離線時限制你的 app。當然你可以用上次成功請求的緩存文件來用作離線版本的加載。但是如果當前版本出現問題怎么辦,你又回到了之前提到的問題(如果不強制用戶下載新版本,app 就會崩潰)。

另外一種解決方案是創建一個隱藏窗口加載包含了所有第三方代碼片段的本地HTML 文件。這樣,任何由全局變量導致的問題會在這個隱藏窗口里報錯,而主要窗口不受影響。如果你需要在主要窗口當中調用 這些 API 或者 全局變量,你可以通過 IPC 的方式來實現。通過 IPC 向主進程發送一個事件,然后該事件會被發送到隱藏窗口當中。如果隱藏窗口沒有任何問題,它會監聽事件同時調用第三方函數。這樣就可以解決之前提到的問題。

這會帶來安全問題。萬一來自公司 X 的惡意攻擊者在他們的 JavaScript 中包含有危險的 Node.js 代碼?我們肯定死慘了。幸運的是,Electron 里有一個很不錯的設置用來禁止在給定窗口中執行 Node.js 代碼,使惡意代碼不會運行:

// main.js
const {app, BrowserWindow} = require('electron');
let thirdPartyWindow;

app.on('ready', () => {
  thirdPartyWindow = new BrowserWindow({
    width: 500,
    height: 400,
    webPreferences: {
      nodeIntegration: false
    }
  });
  thirdPartyWindow.loadURL('file://' + __dirname + '/third-party-snippets.html');
});

自動化測試

NW.js 本身不包含對測試的支持。但是由于你可以使用 Node.js, 技術上,測試是可行的。 例如 Chrome Remote Interface 可以用來測試 app 當中的按鈕點擊。但這個還是有點牽強,因為你無法觸發原生窗口按鈕的點擊,也就無法測試。

Electron 官方團隊開發了 Spectron 用來自動測試。它支持測試原生控制按鈕,管理窗口還有模擬 Electron 事件。它甚至可以在持續集成構建中運行。

var Application = require('spectron').Application
var assert = require('assert')

describe('application launch', function () {
  this.timeout(10000)

  beforeEach(function () {
    this.app = new Application({
      path: '/Applications/MyApp.app/Contents/MacOS/MyApp'
    })
    return this.app.start()
  })

  afterEach(function () {
    if (this.app && this.app.isRunning()) {
      return this.app.stop()
    }
  })

  it('shows an initial window', function () {
    return this.app.client.getWindowCount().then(function (count) {
      assert.equal(count, 1)
    })
  })
})

考慮到你的 app 就是 HTML 文件,僅僅在靜態文件中添加指向測試工具的腳本,你可以用任何工具來測試 web app。但是你得確保 app 可以在沒有 Node.js 的 web 瀏覽器中依然可以運行。

桌面和 Web

這不僅僅是關乎桌面 app 或者 web app。作為一個 web 開發者,你可以用任何工具制作 app 確保在任何平臺和環境中運行。但是為什么沒有一勞永逸的辦法呢?我們還需要努力,但這是值得的。接下來我會提到一些相關的話題和工具,考慮到它們太過復雜,我就點到為止。

首先,忘記什么“瀏覽器限定”和原生 WebSockets 等等其他的事情。ES6 也是如此.你要么寫純粹的 ES5,要么用類似 Babel 的工具來把 ES6 代碼編譯成 ES5,供 web 使用。

你的代碼里也會寫滿了許多瀏覽器不會理解的 require(用來引入其他腳本文件或者模塊)。使用支持 CommonJS 的模塊打包器,比如 Rollup,webpack 或者 Browserify。當構建 web app 的時候,模塊打包器會遍歷代碼,找到所有的 require 然后把他們放在一個腳本文件里。

任何用到 Node.js 或者 Electron API(比如寫盤操作或者集成桌面環境)的代碼都不應該在 app 運行在 web 端的時候被調用。你可以通過檢測 process.version.nwjsprocess.versions.electron 是否存在來判斷。如果存在,則表明 app 當前運行在桌面環境。

即便如此,你仍會在 web app 上加載大量冗余代碼。假設你的代碼中 if(app.isInDesktop) 后面緊接著和桌面環境有關的 require 代碼。與其在 app 運行的時候來檢測當前運行環境,同時設置對應的 app.isInDesktop,不如把 truefalse 當做 flag 在構建的時候傳值到 app。在它進行靜態和樹狀分析(也就是消除無用代碼)時,這將有助于模塊捆綁的選擇。它會知道 app.isInDesktop 是否為 true。因此,當你運行 web app 的時候,它不會到代碼里去找對應的 if 條件,或者找到相關的 require。

持續交付

我們對于版本發行的觀念也需要換一換了,這非常有挑戰性。當你在開發 web app 的時候,你希望能夠頻繁發布新的改動。我相信在持續交付中,小的增量改動可以快速回滾。理想情況是,經過足夠的測試,一個實習生也可以把改動的代碼 push 到 master 分支,然后讓 web app 自動測試和部署。

我們之前談到,你不能像 web app 那樣在桌面 app 中實現同樣的效果。沒錯,理論上如果你使用 Electron 的話,electron-builder 可以自動測試,而且 spectron 也可以測試。我不知道還有誰這么做,我自己不會有信心這么做。記住,錯誤的代碼不可以撤銷,你可能打破正常的更新流。而且,你也不想讓桌面 app 更新太過頻繁。更新不會悄無聲息的發生,不像 web app 那樣,這對于用戶來說其實很不友好。而且在 macOS 上不支持增量更新,用戶必須針對每一個發行版本都要下載完整的新版本的 app,不管更新是多么的小。

你得找到一個平衡點。一個妥協的做法是針對 web app 要盡可能快的更新和修復問題,對于桌面 app 每周或者每月更新一次就可以,除非你要發布新功能。你也不能指責用戶選擇安裝桌面 app。沒有什么比等待很久來發布新功能更糟糕的事情了。你可以采用功能發布控制器(feature-flag)API 來在同一平臺同一時間發布新功能,但這又是另外一個話題了。我第一次學習和了解到功能發布控制器是來自 Etsy 的工程師 VP,Mike Brittain 的講話,持續交付:骯臟的細節(需翻墻)

總結

那么你已經掌握了。只要一點點努力,你就可以在簡歷中加上”桌面 app 開發者“的標簽了。我們從創建第一個現代桌面 app,打包,分發,講到售后服務還有更多。但愿我提到的一些陷阱和坑對你來說并沒有那么可怕。你已經知道它們的前因后果了。你需要做的就是看一遍 API 文檔。感謝那些可供我們任意使用的強大的 API,你可以從 web 開發者的技能樹上獲取更多有價值的東西。我希望可以在 NW.js 和 Electron 社區中看到你的身影。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React前端、后端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容