Ubuntu Firefox 编程 php wordpress mysql 程序员 开源 shell google centos Python java Windows 云计算 nginx apache Android 微软 linux

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

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

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

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

本文將會介紹使用 NW.js 和 Electron 開發桌面應用,包括它們的優劣,以及如何使用同一套代碼庫來開發桌面、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 有一个主要的后台进程,每个标签页也会有自己的进程。你可能注意到 Google Chrome 在 Windows 的任务管理器或者 macOS 的活动监视器上总是至少存在两个进程。我并没有尝试在这里安排穿插主后台进程相关的内容,但是它包括了 Blink 渲染引擎、V8 JavaScript 引擎(也构建了 Node.js )以及一些从原生 API 抽象出来的平台 API。每个独立的标签页或渲染的过程都可以使用 JavaScript 引擎、CSS 解析器等,但为了提高容错性,它们又和主进程是完全隔离的。渲染进程与主进程之间是用进程间通信(IPC)来进行通讯。

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

就是这么简单。NW.js 初始化了第一个窗口,加载了你的 HTML 文件,虽然这看起来并没有什么,但接下来就是你来添加标签及样式了,就和在 web 应用中一样。

你可以凭自己喜好去掉窗口栏,构建自己的框架模板。你可以有半透明或全透明的窗口,可以有隐藏窗口或者更多。我最近尝试使用 NW.js 做了Clippy(Office 助手)。能在 macOS 和 Windows 10 上看到它有种奇妙的满足感。

Screenshot of clippy.desktop on macOS

现在你可以写 HTML,CSS 和 JavaScript 了,你可以使用 Node.js 读写硬盘、执行系统命令、生成其他可执行文件等等。设想一下,你甚至可以通过 WebRTC 造一个多玩家的轮盘赌游戏,随机删除其他人的文件。

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

注意:对于 Electron 只是 node-webkit 的 fork,还是一切从头重新做的,是很有争议的。但无论哪种方式,最终都成为终端用户的一个分支,因为 API 几乎完全一致。

在开发 Atom 的过程中,GitHub 改进了一些方案,也解决了很多 bug。2015年,atom-shell 正式更名为 Electron。它的版本已经更新到 1.0 以上(译注:最新正式版本为v1.3.14),并且因为 GitHub 的推行,它已经真正发展壮大了。

Logos of projects that use Electron

和 Atom 一样,其他用 Electron 开发的有名项目包括 SlackVisual Studio Code、 BraveHyperTermNylas,真的是在做着一些尖端的东西。Mozilla Tofino 也是其中很有趣的一个,它是 Mozilla( FireFox 的公司)的一个内部项目,目标是彻底优化浏览器。你没看错,Mozilla 的团队选择了 Electron (基于 Chromium )来做这个实验。

Electron 有什么不同呢?

那么 Electron 和 NW.js 有什么不同?首先,Electron 没有 NW.js 那么面向浏览器,Electron app 的入口是一个在主进程中运行的脚本。

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

在 Electron 提供的内置模块中,像在前面例子中使用的 app 和 BrowserWindow,大多只能要么在主进程要么在某个渲染进程中使用。比方说,你只能在主进程中管理你的所有窗口,自动更新或者其他。你可能想在主进程中点击一个按钮触发一些事件,因此 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

如果你通过 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,更加多地与桌面端的集成,并且内置了自动升级,我稍后会谈到。

但是感觉如何呢?

感觉很好,当然,它并不是原生的。现在大多数桌面应用并不会长得像资源管理器或者 Finder,所以用户并不介意或者意识到用户界面背后是 HTML。你愿意的话,你可以使之更像原生应用,但是我并不认为那样会让用户体验更好。比如,你可以在用户将鼠标悬停在按钮上时,不让光标变成手,一般原生的桌面应用都是这样做的,但是这样做有什么好的吗?当然也有像Photon Kit 这样的类似 Bootstrap 的 CSS 框架,可以做出 macOS 风格的组件。(下图是 Photon Kit 做出的组件 demo)

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 被创造出来完成这些自动化工作。我说的是持续集成工具例如 JenkinsCodeShipTravis-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.Mac 和 Squirrel.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 的时候,除了遵循一些常见规则外(比如写更少的代码、压缩文件、使用更少的依赖等等),我可以提供的意见不多。“Hello 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

你也许会知道,在 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

详见 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.nwjs 和 process.versions.electron 是否存在來判斷。如果存在,則表明 app 當前運行在桌面環境。

即便如此,你仍會在 web app 上加載大量冗余代碼。假設你的代碼中 if(app.isInDesktop) 後面緊接著和桌面環境有關的require 代碼。與其在 app 運行的時候來檢測當前運行環境,同時設置對應的 app.isInDesktop,不如把 true 和 false當做 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 社區中看到你的身影。

延伸阅读

    评论