#217 Electron 深度实践总结

思维导图

前言:

Electron 从最初发布到现在已经维护很长一段时间了,但是去年才开始慢慢升温。笔者个人恰好也有一些 Electron 的实践经验,原本这篇总结是大约半年前开始起草的,但是由于一些个人原因拖到现在才算是正式完稿,现在发出来供有意实践 Electron 的开发者借鉴,避免一些本人在实践中踩过的坑。此外,笔者的水平有限,难免出现错误,如果读者在阅读本文过程中发现原则性的错误,请务必指出,以免相关内容祸害他人。

本文的实践基于 Electron 1.x

创建基本应用

问题: renderer 端无 jQuery

根据官方的新手教程,我们很容易创建一个基本的 Electron 应用,我们不再累赘叙述,如果你只需要加载一个网页,那么只需要下面几行简单的代码:

1
2
3
4
5
6
7
8
9
10
const { BrowserWindow } = require('electron')
let window
function createWindow() {
window = BrowserWindow({
width: 1024,
height 720
})
window.loadURL('https://changkun.us')
}
app.on('ready', createWindow)

如果你真的尝试加载笔者的博客页面,会发现笔者博客的文字内容无法加载,这是由于 jQuery 找不到而产生的问题,为此我们并没有必要调整 Web 端的内容,只需要简单在 Electron 的 Renderer 端引入 jQuery 即可。

解决方法就可以使用 BrowserWindow 的 preload 参数,然后使用 preload 脚本为 renderer 端引入 jQuery:

1
2
3
4
5
6
// preload script
document.addEventListener('DOMNodeInserted', (event) => {
if (!!window && !(window.$)) {
window.$ = window.jQuery = require('../utils/jquery.min')
}
})

相关 issue: https://github.com/electron/electron/issues/345

一些常见需求

在 Renderer 端创建菜单

在 Electron 中,无论是应用程序的主菜单(macOS 顶部的菜单)、窗口菜单(Windows/Linux)的窗口菜单、Tray 菜单、还是 Renderer 端的 Context 菜单等,凡是和菜单挂钩的功能都是通过 Menu.buildFromTemplate 进行创建的,他们只是被挂载到了不同的实体上,比如被挂载到了页面上的叫做 Context 菜单,被 setApplicationMenu 挂载的成为了主菜单。

Electron 的菜单其实限制也非常之大,其菜单在创建完成后便不能在运行时被直接修改,需要修改时,必须对整个菜单重新创建,从而达到动态菜单的目的。这也是非常不友好的。

另一方面,一个菜单项被点击后,只接受三个参数,也就是说 click 属性的回调只接收: menuItem(被点击的item)、focusedWindow(点击按钮时focus的窗口) 以及毫无用途的 event (提供键盘是否被按下的信息),这也就造成的诸多的不便。

例如,当我们希望一个按钮被点击后,其他的菜单项的 enbalbed 属性设置为 false,将无从下手。

为了解决这一问题,这时候我们可以巧妙的使用 Electron 的 ipc 机制在 renderer 端创建应用菜单,在 renderer 端创建的菜单便可以在内部使用 ipcRenderer.send() 这个方法,让 ipcMain 来处理其他其他菜单项的操作。

不仅如此,甚至于可以让整个菜单的 click 逻辑都交付于 main 端进行管理,例如下面这样的 menu template:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
type: 'separator',
visible: (()=>{
if (process.platfrom == 'darwin') return true;
else return false;
})()
},
{
label: `Current Version: ${app.getVersion()}`,
enabled: false
},
{
type: 'separator'
},
{
label: 'Logout',
click: () => { ipcRenderer.send('application', 'logout'); }
},
{
type: 'separator'
},
{
label: 'Exit ${app.getAppName()}',
accelerator: 'CmdOrCtrl+Q',
click: () => { ipcRenderer.send('application', 'quit'); }
}

而在 main 端则可以:

1
2
3
4
5
6
7
8
9
ipcMain.on('application', (event, args) => {
switch(args){
case: 'logout':
....; break
case: 'quit':
...; break
default: ...
}
})

相关 issue: https://github.com/electron/electron/issues/7455

后台网络状态监测

问题: Electron 的官方文档其实就提供了网络检测的方法,思路是通过一个隐藏的后台窗口,检测网络网络状态,通过网络状态产生变化后,通过 ipcRenderer 发送消息给 ipcMain,然后响应所需的操作,见这里

但事实上这个方法是基于 onlineoffline 事件的,换句话说这个方法只能检测到当系统网络连接被切断物理连接后的状态变化,无法检测网络本身。

这个问题可以参考下面的 issue,但其实现思路就是向苹果的 hotspot detect 页面发起请求,当未超时状态下有 Success 返回时,便说明网络状态正常。在实际应用编写过程中,我们也并不需要为了这样一个简单的功能而引入框架,只需定时向服务器发起任意一个能够判断网络状态的请求即可,与心跳连接殊途同归。

相关 issue: https://github.com/electron/electron/issues/6633

preload 脚本的执行阶段

在前面创建基本应用中我们已经谈到了关于 preload 脚本用来引入 jQeury 的使用,事实上我们可以用 preload 做更多的事情。preload 脚本会在整个页面开始加载之前被执行,所以如果我们直接执行一些当整个 DOM 加载完成才能被执行的操作,是必定会失效的,因此这样的两个事件是非常有用的:DOMNodeInsertedDOMContentLoaded

为此,我们可以把 preload 脚本大致分为三块区域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ---------------------------------------------------
// 在页面加载之前需要执行的相关代码
// ...

// ---------------------------------------------------


// -------------------------------------------------------
document.addEventListener('DOMNodeInserted', (event) => {
// 页面内容加载之前需要引入的一些代码
// ...
})
// -------------------------------------------------------


// -------------------------------------------------------
document.addEventListener('DOMContentLoaded', (event) => {
// 页面内容加载之后需要引入的一些操作
// ...

})
// -------------------------------------------------------

preload 脚本的作用非常大,有时候会有这样的需求:当我们加载一个网络上的页面时,我们不能控制从网络中读取到的页面内容,但 preload 提供了这样的可能性,使得我们能够向页面 注入 一些代码,满足一些神奇的需求,比如对网络加载页面增加 Context Menu。但也有使用时值得注意的地方:

Electron 的 main 进程、preload 脚本、renderer 进程、以及 document 对象分别有彼此的创建和执行顺序。首先 main 进程会优先被创建毫无疑问,preload 会在 document 对象被创建之前优先加载(但能够使用 document),而 renderer 进程会在 document 创建之后被创建,而他们三者又是并发创建的,如下图所示。

那么,如果我们不小心在 preload 脚本中直接引入 ipcRenderer 发送一条消息给 ipcMain,那么 ipcMain 可能不能收到这条早期消息。为了保证我们能够收到这条消息,最好的方式就是:

1
2
3
4
5
6
7
8
9
10
// preload.js
// 不要再外面这么干
// ipcRenderer.send(...)

document.addEventListener('DOMContentLoaded', (event) => {
// 页面内容加载之后需要引入的一些操作
// ...
// 正确的做法
ipcRenderer.send(...)
})

相关 issue: https://github.com/electron/electron/issues/7455

第三方URL检查问题

所谓的第三方 URL 检查问题,是应用指针对提供第三方登录接口时,页面需要临时性的跳转到其他第三方域名下进行后,会导致 Electron 窗口出现各种不可预计的后果的这个问题。当一个 Electron 窗口跳转到第三方页面后,由于 Electron 本质上是一个浏览器,而第三方页面通常包含各种其他的指向性链接,例如一个 QQ 的第三方登录页面,点击 QQ 的 Logo 会跳转到 QQ 的主页,这就导致了『穿帮』,让用户察觉这个应用是指一个简单的浏览器,造成不够优秀的用户体验。

这时,为了解决这个问题,笔者实践的一个解决做法是对每次要跳转的 URL 和打开的窗口做 URL 检查,主要依赖下面两行代码:

1
2
win.webContents.on(‘will-navigate’, checker)
win.webContents.on(‘new-window’, checker)

在这两行代码中,无论是页面即将跳转,还是打开一个新窗口, checker 都会对 URL 进行检查,只有符合要求的URL才会在窗口内被加载,否则将通过 shell.openExternal() 来调用。

读者看到这里可能会对 URL 检查本身产生质疑,因为一个严格的 URL 检查事实上是难以实现的。不过下面的例子应该能够给与读者相关启发,例如:

笔者在实现个人博客的客户端时,需要考虑每篇博文下方的评论区登录问题,如图:

当用户点击登录后,客户端会跳转到第三方登录页,下图中的微博 logo、注册、多说评论框都是可能导致用户进行进一步跳转的隐患:

对此,有这样一个取巧的办法避免这些有隐患的跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function urlChecker(event, url) {
event.preventDefault()
// 第三方登录在登录回博客页面时,通常会带有 login-calback 的请求参数
if (url.match('login-callback'))
// 但是在完成登录后,会返回多说自己的主页,而不是博客页面本身,
// 这是不能忍的,因此这里要求 mainWindow 跳转到 home 页
mainWindow.loadURL(common.url.home)
// 当匹配到要打开的 url 不属于 changkun.us 域名下时,直接在外部打开
else if (url.match('changkun.us') == null) shell.openExternal(url)
// 否则,加载此URL
else mainWindow.loadURL(url)
}
mainWindow.webContents.on('will-navigate', urlChecker)
mainWindow.webContents.on('new-window', urlChecker)

下载

下载也是一个常见的需求,比如,你正在基于 Electron 实现一个 Web 文本应用,用户可能需要下载保存在服务器上的一个编辑好的文件,这时候当点击 Web 界面中的下载时,Electron 并不需要专门针对这个下载行为进行单独的处理,Electron 会想浏览器那样直接跳出一个保存的文件选择器,让用户获得下一步的操作。当我们真正需要处理一些特殊的下载操作时,同样可以用 electron 的 DownloadItem 来实现,但其接口设计着实有点让笔者难以接受,这里推荐可以尝试 electron-userland/electron-download 这个库,虽然其本质也是 DownloadItem,但其接口相比之下友善许多,因为库本身也并不复杂,也可以在项目中自行实现这部分逻辑。

从URL启动

从 URL 启动其实是一个比较 tricky 的功能,这个功能在大约半年多以前的 Electron 1.4.x 再往前其实是一个体验相当差的功能,在那个时候 从 URL 启动还只能在 macOS 上实现,还需要为应用配置额外的 plist 文件 (使用 electron-builder 打包时,后面会具体讨论这个工具和其他工具),且 windows 和 linux 并不支持。后来 Windows 提供了支持,但 Linux 由于需要 root 权限的问题,并不能很好的支持。

而现在实现这个功能在现在已经变得非常的容易了,只需要在 package.json 中配置 build.protocols 字段:

1
2
3
4
5
6
7
8
9
10
"build": {
...,
"protocols": [
{
"name": "changkun://",
"schemes": [ "irc", "ircs" ]
}
],
...
}

并在应用中添加一行代码:

1
app.setAsDefaultProtocolClient('changkun')

便可以通过 changkun://anything 来启动应用了,如果你还要让应用来针对不同的 URL 处理不同的事件,同样也是能做进一步定制的。

多窗口应用: 窗口数大于三无法退出

这个需求是笔者在下意识的创建多窗口应用时候发现的,虽然 Web 应用已经出现了 SPA 的发展趋势。但是有时候多窗口还是会有一些用处,例如一个用户产生应用的主窗口,一个后台隐藏的网络监测窗口,以及一个负责打开其他新页面的窗口。笔者约半年多之前发现这个问题,至今由于忙于他事也未认真寻找问题产生的根源,至今发现这个问题仍然违背解决,于是开出了 issue,有兴趣的读者可以跟踪研究一番。

注意: 此 bug 已被修复,请查看下面的 issue 获得更多细节。

相关 issue: https://github.com/electron/electron/issues/8915

UX

交互的反馈

在 UX 上其实大部分第三方框架已经做得足够优秀,Electron反倒是个例外。拿加载网页内容来说,当用户点击了某个选项之后,没有反馈的交互会让用户没有成功的执行刚才的点击操作,这时候页面上的 loading 进度条是非常有必要的。由于大部分的框架如 React, Polymer都具备这个组件,由于其内容已经脱离 Electron 本身,限于篇幅这里就只做简单提及不再做深入讨论。

类原生处理

Web 应用通常很难直接给用户带来像原生应用那样的体验的一个主要原因就是,文本是可选可拖拽的。为了维护上的可用性,如果一个 Web 应用会在浏览器端和 Electron 端同时分发,那么可以将这类体验的 CSS 通过 preload 的方式注入到 Web 页面,而不是直接在 Web 页面进行实现,从而实现很好的逻辑分离。

软件更新

软件的日后更新一直都是产品日后迭代的杀手,一个需要被分发的桌面应用,在没有确定的更新机制之前,切忌发布。

Electron 虽然本身自带 audoUpdater 模块,但作为框架的使用者来说,笔者很难说它做得优秀,因为需要配置的内容相较于其他功能来说略加繁琐。因此这里推荐使用 electron-updater。下面的代码相当于一个纯粹的更新功能的封装,使用成本非常简单,只需根据 electron-builder wiki 的说明配置好 publisher 即可实现更新功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// updater.js
const { dialog } = require('electron')
const { autoUpdater } = require('electron-updater')


let updater
// 禁用自动下载,给用户选择余地
autoUpdater.autoDownload = false
autoUpdater.on('error', (event, error) => {
dialog.showErrorBox('Error: ', error)
})
autoUpdater.on('update-available', () => {
dialog.showMessageBox({
type: 'info',
title: 'Found Updates',
message: 'Found updates, do you want update now?',
buttons: ['Sure', 'No']
}, (buttonIndex) => {
if (buttonIndex === 0) {
autoUpdater.downloadUpdate()
} else {
updater.enabled = true
updater = null
}
})
})
autoUpdater.on('update-not-available', () => {
dialog.showMessageBox({
title: 'No Updates',
message: 'Current version is up-to-date.'
})
updater.enabled = true
updater = null
})

// 下载完成时,提醒用户
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox({
title: 'Install Updates',
message: 'Updates downloaded, application will be quit for update...'
}, () => {
autoUpdater.quitAndInstall()
})
})


// 将这个回调输出给更新功能所在的菜单项的 click 回调
function checkForUpdates (menuItem, focusedWindow, event) {
updater = menuItem
updater.enabled = false
autoUpdater.checkForUpdates()
}
module.exports.checkForUpdates = checkForUpdates

发布

打包工具

期初的 Electron 打包是一个比较恼人的问题,因为可用的工具其实不多,electron-packager 是一个很原始的打包工具,虽然具备打包的功能,但是其提供以开发 API 为蓝本的入口使得构建还需要额外编写脚本进行,而它其实只具备将应用进行打包编译的功能,这与最终发布的 Installer 还有一步之遥,所以使用 electron-packager 是非常消耗开发成本的一件事情。好在有一个取而代之的工具 electron-builder。它的好处在本文前面的部分也已经多次提及,它不仅拥有方便的配置 protocol 的功能、内置的 Auto Update、简单的配置 package.json 便能完成整个打包工作,用户体验是相当优秀的。

代码签名

代码签名对于发布作为正式商业产品应用来说是非常重要的。如今的 electron-builder 对于代码签名已经做得相当友好,对于 macOS 来说,它能够自动获取系统中 Keychain 中的开发者证书,自动对代码进行签名,而 Windows 也可以通过配置一个 .p12 证书来达到签名的目的。

因此,到目前为止,代码的签名成本已经非常低,只需购买好证书,基本上没有什么烦心的事情,唯一一个值得注意的事情是:如果要分发 macOS 上的应用,那么构建平台将只有 macOS 是被推荐的,因为它是唯一一个能够同时构建 macOS/Linux/Windows 三平台应用的平台。但是,如果使用 CSC_LINK 将会出现冲突,因为 CSC_LINK 已被用于 macOS 平台的签名,因此额外在 package.json 中配置 Windows 平台的证书。

由于证书最终不会被分发,可以在签名时使用一个移除密码的证书;亦或者对两个平台的证书使用相同的密码,方便最终的签名和打包。

开发工具

开发工具其实与 Electron 本身并不无关系,因为如果我们编写的应用是 SPA,那么 Electron 并不能左右我们的工具选择。但作为一篇完整的实践总结,这里简单提及一些开发工具以作为笔者的个人爱好进行推荐。

Dev

开发本身其实是一件苦力活,我们有时候确实是需要一个工具来帮助我们提升代码质量的,所以正如各位所熟悉的工具 ESLint,可能在任何时候都是必不可少的。而对于最终整个应用的构建过程,笔者则更倾向于 webpack,虽然 gulp 也是不差。

Test

像 Electron 这种涉及 UI 的应用,实施 UI Testing 其实是一件不太顺畅的事情。对于 main process 的核心逻辑相关的测试我们大可使用自己熟悉的测试框架编写,但是对于 Electron 窗口和窗口之间的管理逻辑,直接下手是不太好做。好在有一个库帮我们解决了 UI 测试的问题,那就是 Spectron,它可以与任何我们喜欢的测试框架相配合,例如 mocha。Spectron 同时也是官方推荐的测试库,起使用非常简单,具体内容可以参考下面的链接,这里不再赘述。

相关链接: https://electron.atom.io/spectron/

Debug

编辑器的选择虽然是萝卜白菜的问题,但是笔者选择的是 VSCode ,不得不说他从速度和功能上打败了 Atom,同时还有微软在背后撑腰,Electron 的官方文档也有一篇关于使用 VSCode 来分别配置调试 main process 和 renderer process 的过程,鉴于其内容比较简单,这里只提及此内容,读者可以进一步阅读官方文档,了解如何配置调试文件,这里便也不再赘述。

相关链接: https://electron.atom.io/docs/tutorial/debugging-main-process-vscode/

架构设计

架构是进化而来的。在项目之初过度的设计架构其实是一件非常不合理的事情,笔者在实践 Electron 之初就将 Electron 与业务逻辑、Web 页面混合得稀烂,webContent.send()ipcRenderer.send 翻来覆去的发,最终导致逻辑混成一团麻,难以维护。

Electron 的 IPC 机制是一把非常锋利的双刃剑,利用的当非常便于实现一些功能,也能维护得很好。前面的 renderer 端创建 Menu 就是一个典型的例子:在 Renderer端创建的 Menu,具备调用 ipcRenderer.send(message, args) 的能力,这样所有的 MenuItem 的 click 功能都能够交给 ipcMain.on(message, args) 来实现。

最终,笔者实践得出的一个便于维护的 Electron 应用架构应该如下图所示:

当然也可能进化为更好的架构, 如果你有更好的架构设计愿意分享, 欢迎在下方留言

1. Electron 与应用逻辑层分离

正如之前提到的,Electron 如果和业务逻辑与页面代码混成一坨,将导致日后的维护难度增大,一方面原因在于 Electron 不断在发展,也必将面临接口升级的问题,另一方面,我们也很难保证 Electron 就能久经不衰,说不定明天就会有新的框架发布,替代 Electron。如果代码处处混杂着 Electron 的内容,整套应用全部重写的成本其实是很大的。一个比较简单的做法就是对 Electron 再做一层自定义的封装,将用到的 Electron 统一封装到一个脚本之内。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// libmain.js
'use strict'

const electron = require('electron')
const app = electron.app
const shell = electron.shell
const BrowserWindow = electron.BrowserWindow

class lib {
static exit (cleanCookie) {
if (cleanCookie) {
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.session.clearStorageData({storages: ['cookies']}, () => {
console.log('Successfully eliminate cookies')
})
})
}
app.quit()
app.exit(0)
}
static externalOpenURL (url) {
shell.openExternal(url)
}
}

// main.js
...
lib.exit(true)
...
lib.externalOpenURL(...)

在上面的代码中,我们其实实现了应用的退出逻辑,在每次退出应用之前,都将浏览器的 cookie 清除,shell.openExternal 也是如此。这样做的一个好处在于我们在需要 Electron 时,只需统一的调用 libmain.js 即可,无需再任何需要 electron 的地方 require electron,减少 Electron 和应用之间的耦合度。值得一提的是,ipcMain 和 ipcRenderer 不能在同一个脚本中被引入,所以我们好针对 renderer 进程额外再封装另一个脚本 librenderer.js

2. IPC混合式 MVC/MVVC

IPC 固然好,但消息发来发去其实是非常绕逻辑的。例如一条消息首先从 ipcRenderer 发出,然后 ipcMain做出反应,然后通过 sender.send 发回,由另一个 ipcRenderer 再做响应。这样的逻辑方式是非常累人的。前面我们对 Electron 进行解耦和的过程中其实可以看到,当对 Electron 解耦之后,消息本身从何处发出其实是可以被淡忘的,这样便回归到了我们常见的 MVC/MVVC 结构的实践。Electron 本身的逻辑充当 Controller,而 Web 则充当 Model。在这个过程中,由于双向绑定的存在,我们竟可能减少IPC的双边通信逻辑,让 Render而 端单边向 ipcMain 通信,然后 Controller 来修改 Model 从而通过双向绑定达到修改 View 的目的。

相关 pull request: https://github.com/electron-userland/electron-builder/pull/1377

其他问题

杀毒软件的误报

如果你毅然决然的实现了前面提到的 protocol,即使应用代码也成功的进行了签名,仍然会有让你心烦的事情,那就是(Windows平台上)杀毒软件的报毒。笔者估计这也是很多独立开发者不愿意发行Windows版本应用的原因。

这是一个非常恶心的事情,如果你应用的目标用户是小白,那么很可能会因为杀软的误报而不考虑安装你的应用,这里有一个叫做 virustotal 的网站能够帮你检测你打包后的应用会被哪些杀毒软件处理。

当然,报毒并不仅仅是你使用了 protocol 这么简单,早期的 Electron 组件中包含了大量的容易被误报的内容,但现在这一问题少了很多,对此我们作为 Electron 的用户基本上没有别的办法,只能等待 Electron 的影响力继续扩大,使杀毒软件将其彻底列入白名单。

相关 issue: https://github.com/atom/atom/issues/3927

压缩应用体积的方法

Electron 应用体积动辄一百多兆的体积从他发布的第一天起到现在一直被人诟病,而其体积之所以如此之大的原因在于 Electron 为其跨平台的支持,在内部打包了整个 V8 引擎,相当于一个功能完整的浏览器。

曾经 Slack 作为仰仗 Electron 『大厂』, macOS 版本曾神奇的只有 20M 左右。但事实上那个时候的 Slack macOS 版本并不依赖 Electron,而是 MacGap(所以你可以看到现在的 Slack 全面转向 Electron 后也毫无疑问的成为了百兆应用的大户)。如果你打算使用 MapGap 来优化 Mac 版本的体积,请打住。简单阅读一下 MacGap 的文档就会发现,MacGap 的发展速度甚至赶不上 Electron,很多需求在 MacGap 上甚至无法实现,例如 preload 这样的功能就不具备。

所以无论你的 Electron 应用多么简单,都至少拥有超过 120M 的体积。这是相当不友好的。对于这个问题,笔者实践中找到了三种相对妥协,却能很好的解决问题的方案:

1. 使用 yarn clean

我们知道 node 程序其实是将依赖库整个下载到了 node_modules 中,这也就包括一些 example 和 docs 和 test,而在 electron 应用被打包的过程中,这些依赖其实也是被耿直的打包进了应用之中。这也就无形之中增加了 Electron 应用的体积。

而使用 yarn clean 可以清除这些内容,从而一定程度上减少应用的体积。

2. 将应用程序打包后再分发

Electron 应用本身的 bundle 确实高达 120M,但其压缩后的体积能够变得很小,尤其是 Windows 平台上的安装程序甚至能够被压缩到 33M 左右,而 macOS 和 Linux 的打包体积也将被压缩为 40M 左右。这其实是一个相当可观的体积了,如果配合下面提到的第三点方法,那么几乎向用户隐瞒了应用本身体积巨大的事实。正如笔者在前面提到的,推荐使用 electron-builder。

3. 定制应用的更新功能

关于这一点内容,我们要从 Electron 打包应用的结构谈起。以 macOS 为例,Electron 应用最终被打包成了如下的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ElectronApp.app
└── Contents
├── Frameworks
│   ├── Changkun\ Blog\ Helper\ EH.app
│   ├── Changkun\ Blog\ Helper\ NP.app
│   ├── Changkun\ Blog\ Helper.app
│   ├── Electron\ Framework.framework
│   ├── Mantle.framework
│   ├── ReactiveCocoa.framework
│   └── Squirrel.framework
├── Info.plist
├── MacOS
│   └── Changkun\ Blog
├── PkgInfo
├── Resources
│   ├── Changkun\ Blog.icns
│   ├── app-update.yml
│   ├── app.asar
│   ├── electron.asar
│   ├── en.lproj
│   ├── zh_CN.lproj
│   └── zh_TW.lproj
└── _CodeSignature
└── CodeResources

在这个结构中,我们自己的核心代码,其实是被完整的打包进了 Contents/Frameworks/Resources/app.asar 中,其他内容则都是 electron 自身的依赖。换句话说,我们只要更新了 app.asar 这个文件,也就相当于更新了整个应用。我们再来看看这个文件的大小:

1
-rw-r--r--   1 changkun  admin   2.9M Mar 16 17:07 app.asar

这将是一个非常可观的下载量,配合第二点,用户第一次下载了一个不超过 50M 的应用安装程序,每次更新应用时,下载的内容也非常之少。当然,实现这一功能也并不复杂,我们只需要和自己的服务器进行通信,然后下载这个文件退出应用进行替换即可。

相关 issue: https://github.com/electron/electron/issues/2003

案例:一个 bug 的生与死

electron-builder 出现过一个 bug : 在 productName 字段中,如果其包含了一些 UTF-8 字符,那么最终打包后的 dmg 包的应用 icon 的位置将出现错误。之所以以此为例,是因为恰好这个问题笔者前几日找时间跟踪修复的。

这个问题出现的根源可以追溯到 electron-builder 早期对 node-appdmg 的依赖。在 macOS 10.12 更新之前,用于打包 macOS 平台的 dmg 依赖 node-appdmg 不存在任何问题,而且其本身的实现逻辑也是根据苹果自身的文档描述正确无误完成的。但是莫名其妙的是在 macOS Sierra 上就是无法显示背景图。后来才发现是苹果系统自身的 bug,这个问题也在 10.12.3 之后的系统版本上被修复了。那么从 10.12.0 到 10.12.3 这么长的周期间隙中,electron-builder 的维护者也不能闲着,为了摆脱对 node-appdmg 的依赖,electron-builder 的作者使用 perl 黑魔法解决了这个问题,从那以后 electron-builder 不再依赖 node-appdmg

然而这也就埋下了隐患。在去年十二月份的一次 bug 维护中,electron-builder 为了修复使其能够构建时自定义路径而更新了这个 perl 脚本,并将其中关键的 UTF-8 解码交给了 node 进行处理,在 packages/electron-builder/src/targets/dmg.ts 这个文件中非常『暴力』的让 node 读取 perl 脚本本身,然后将 $ENTRIES 进行替换,在运行时中执行整个 perl 脚本。但却忽略了 perl 脚本本身与 python 2 一样,如果不指定 utf-8 编码,那么执行将出错。

因此修复这个 bug 的方法也非常简单,只需要在 perl 脚本中增加一行代码:

1
use utf8;

相关 issue:

  1. https://github.com/electron-userland/electron-builder/issues/697
  2. https://github.com/LinusU/node-appdmg/issues/121
  3. https://github.com/electron-userland/electron-builder/issues/1369

结论

Electron 是一个仍在发展的的框架(可惜不能说它目前是高速发展的),甚至已经可以在 Electron 的官网上看到已经开始有 2.0 的 breaking changes 计划了。通过笔者个人的实践,Electron 存在各式各样的缺陷和问题,但是这些缺陷的背后其实有着高效的开发效率和的跨全平台的优势撑腰。尽管我们深知 native app 是『正道』,但『一套代码随处运行』的特点,在需求层出、高速迭代产品的今天,选择 Electron 是毋庸置疑的。

进一步的参考

如果你对笔者的部分实践感兴趣,可参考笔者使用 Electron 创建的博客桌面客户端,可以算作是一个非常轻量的 Electron 模板:

这个客户端的代码涵盖了大部分的实践内容(部分讨论的内容出于各种各样不可描述的原因而没有在代码中体现,感兴趣的读者可以根据本人的描述自行尝试)。

而对于 Electron 本身,有几个非常好的 repo 是值得深入研究的:

  1. Electron: https://github.com/electron-userland/electron-builder
  2. Electron Userland: https://github.com/electron-userland
  3. Awesome Electron: https://github.com/sindresorhus/awesome-electron
如果我的文章对你起到了帮助,你可以选择金额不限的捐助,帮助我写出更多的文章。