我渲染故我在,Electron 窗口开发指北

前情摘要

在前一篇文章中笔者主要介绍了使用 Electron 进行开发过程中打包、构建、自动升级与文件关联相关的内容,细心的读者可能已经发现其中并没有提到与界面相关的话题。时隔一个月,中间又经历了一些需求迭代开发和代码维护,这篇文章将会尽可能详细地介绍 Electron 跨端开发中用户界面部分与平台及系统差异相关的注意点,并探索建立支持自定义多范式的窗口管理方式。

窗口开发基础

我住长江头,君住长江尾,日日思君不见君,共饮长江水。 —— 李之仪

通信方式

我们知道所有 Electron 自定义的窗口界面都是跑在渲染进程里的,而讲到渲染进程则不得不提到主进程。《卜算子》这首小令非常形象地道出了同一个应用里不同渲染进程和主进程之间的关系:一个应用实例只有一个主进程,而会有多个渲染进程。渲染进程之间是无法直接通信的,他们必须要通过主进程这条”长江”来通讯。

一个渲染进程就是管理一个 Window 的进程,通常意义上总是这样。它可以用 IPC 消息和主进程通讯,而主进程通常情况下是作为调度器来衔接各个渲染进程的,只能在收到消息后进行回复。但其实渲染进程和实际展示的网页还是有所不同的,下图简单梳理了一下「主进程 - 渲染进程 - 窗口」直接通信的方式。

上图中,ipcMain 和 webContents 都存在在主进程里,它们之间可以无缝直接调用,每个 webContents 管理一个窗口,一一对应浏览器里的 window 对象,而每个 window 对象里通过打开 nodeIntegration 开关赋予调用 Electron 的 ipcRenderer 模块发送异步或同步消息则是进程间通信的关键点。

这里有几个注意点,理清它们才能更好地规划应用的架构:

  1. ipcRenderer 通常是通信的发起者,ipcMain 只能作为通信的接受者,而 webContents 则作为中间枢纽,代理了部分 ipc 消息(如 send 或者 sendSync 消息可以直接被 webContents 截获并处理)。

  2. ipcMain 只有在接受消息时才知晓渲染进程的存在,本身应该作为一个全局的无状态的服务器。

  3. webContents 作为既存在于主进程又可以直接对应到单个 window 的对象,有效地隔离了 ipc 消息的作用域。

通常情形下,我们应该尽可能直接使用 webContents 和 ipcRenderer 之间的通讯,只有涉及到全局事件时才通过 ipcMain 进行调度。

窗口结构

Electron 提供了基于 Chromium 的丰富多彩的窗口能力,注意它是基于 Chromium,所以在 Electron 里看到的几乎所有窗口(有些文件选择弹窗之类的是系统原生实现)都是一个个浏览器。这些窗口都有很丰富的选项和能力,比如最强大的 nodeIntegration 可以在浏览器环境下使用 Node.js 的能力。


一个简单的弹窗


一个 DUI 的 Snackbar


一个复杂的多 Tab 应用

它们都是 BrowserWindow。这时问题就产生了,如何有序地管理这些窗口?Electron 是一个客户端应用,但它跑在 Node.js 上,而 Node.js 最出色的特性比如 stream 和 Event Loop 和它似乎都没啥关系。那就看看我们所熟知的 Web 应用是如何管理”多任务”的吧:在服务端,我们有基于 MVC 的路由,根据 url 转发到不同的 view 进行处理并返回,在客户端,我们也有前端路由,同样根据 url,不过是渲染成不同的 DOM 节点。

可见,任务组织结构与实际节点的物理/逻辑拓扑关系是息息相关的,任何组织结构都是服务于节点间更好地进行沟通交流,以及根节点对子节点有效的管理。所以,我们可以得出一个简单的结论:

  1. 如果是抽象关联节点的话,可以用哈希表(如 Map)来对应单个窗口

  2. 如果是具象关联节点的话,可以用树状结构(如 XML)来对应单个窗口

但这时有一个问题,窗口内加载的仍是 web 页面,本质是一个个 HTML 文件,而我们知道 HTML 并不能互相嵌套 …… 虽然曾有过 HTML Imports,但其在 MDN docs 上已被标记为过时且不建议使用,我们仍然需要一个组合 HTML 的机制否则我们的页面文件随着需求的增加就会变成一个冗长的 entry 列表。

代码风格

“Imperative programming is like how you do something, and declarative programming is more like what you do.”

我们知道代码风格有命令式和声明式两种,命令式编程意味着你告诉计算机每一步应该做什么,而声明式则更多关心计算机执行的最后结果,即告诉计算机要什么,怎么做到我不管。

上面两段代码估计早期前端开发都曾写过,第一个函数的作用是将数组的每一项乘以 2 并返回所得的新数组,第二个函数是求数组内所有项的和。

这一段代码在上一段代码基础上利用 Array.prototype 上的 map 和 reduce 高阶函数对迭代操作进行了封装,可以注意到函数的行数明显下降了,并且读者并不用关注具体迭代过程是怎么样的,也不用关注这些计算过程。

阅读代码的人只需要知道:

  1. 将 arr 的每一项都映射到这一项取值的 2 倍

  2. 设置初始值为 0,将 arr 的每一项加上初始值之后赋值给初始值

具体每一项是如何取值的,又是如何赋值的,写代码和阅读代码的人都不必关注。可能 map 和 reduce 的例子还不够充分,sort 这个方法是诠释声明式编程最好的例子。

1
2
3
arr.sort((a, b) => {
return a - b;
})

如上图,如果 a - b < 0,则将 a 放在 b 的左边,如果 a - b === 0 则保留两者位置不变,如果 a - b > 0,则将 a 放到 b 的右边,简单朴素地说明了排序的基本原则,而具体排序是用什么方式,时间复杂度和空间复杂度都不要用户去关注(如果想要进一步了解 Array.sort 的实现可参见这篇 StackOverflow 的回答)。

理清了上面这一点我们可以通过一个更进一步的例子来说明为什么声明式编程更适合 UI 开发?

这个例子是一个初学 jQuery 的学徒都可以轻松写出的代码,逻辑也比较清晰,但做的事情已经开始混杂了:

  • highlight 这个 class 与文本的对应关系不明,需要更多上下文或者结合 HTML 考虑。

  • “Add Highlight” 被重复写了两遍,并且以 DOM 文本属性作为状态有点违背 Model View 分离的原则。

上图则是 React 中表达一个 Btn 组件的方式,可以看到 highlight 存在于 this.state 之上,成为脱离 UI 的状态,并且 onToggleHighlight 将调用关系绑定给 handleToggleHighlight,而 highlight 属性则是绑定给了 state 里的同名值。用通俗的话讲就是将数据状态和 UI 给串联起来了,盘活了。一旦数据状态发生变化就会自动映射到 UI 上,而 UI 上接受到事件也可以对数据做出相应改变,一个数据状态就对应了一个 UI 状态,童叟无欺。

这正是我们需要的组合 Web UI 的最佳形式。

窗口管理器

综上所述,我们需要一个数据即 UI,惰性更新窗口样式以及生命周期能够被主进程统一管理的窗口管理器,它应该具有以下几个功能:

  1. 提供包含组件及函数方法等多种创建销毁更新组件的方式

  2. 将数据与 UI 绑定,无需一一手动设置窗口样式

  3. 组件生则窗口生,组件卸载则窗口关闭

  4. 主进程与窗口可以进行直接或间接的通讯,交换数据

Electron 及 React 相关 API 解析

说好了不讲具体 API 的,摔!但不理解这些 API 就很难开发一个高效的窗口管理系统,遇到了具体问题也会找不着北,所以还是忍住睡意看一下会用到哪些神奇的
API 吧~

BrowserWindow

首先是 Electron 的窗口类,这是我们与窗口外观打交道的主要途径了,在它上面有从窗口尺寸位置到标题栏样式乃至窗口内包括 JavaScript 和各种 Web API 是否开启等设置项,可以说就是一个定制版的小 Chrome。下面列出比较重要的几项:

  • frame: 窗口边框

  • titleBarStyle: 标题栏样式,包括 macOS 的红绿灯样式

  • webPreferences: web 相关设置

    • nodeIntegration: 是否在内部浏览器开启 Node.js 环境

    • preload: 预加载脚本

    • enableRemoteModule: 启用远程模块,例如在浏览器中访问只属于主进程的 app 对象等

    • nativeWindowOpen: 是否支持原生打开窗口,这个属性非常重要,是实现跨窗口通讯的基础,当前工作窗口必须设置为 true

webContents

webContent 是 BrowserWindow 下面具体管理 web 页面的一个对象,它同时也是一个 EventEmitter。我们只关心它上面关于创建窗口最重要的一个事件:**’new-window’**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const window = new BrowserWindow({
webPreferences: {
nativeWindowOpen: true,
},
});

window.webContents.on('new-window', (event, url, frameName, disposition, windowOptions) => {
if (frameName.startsWith('windowPortal')) {
event.preventDefault()
const options: BrowserWindowConstructorOptions = {
...windowOptions,
frame: false,
titleBarStyle: 'default',
skipTaskbar: true,
show: false,
width: 0,
height: 0,
}
const newWindow = new BrowserWindow(options)
event.newGuest = newWindow
}
})

上面这段代码的最终结果是将创建新窗口的过程的控制权交给 Electron 这边,比如设置无边框、默认标题栏样式、宽高都为 0 并且不显示,最后将这个完全为空的窗口交给 event.newGuest 交还给 window.opener。

React

关于 React 的教程网上也是汗牛充栋了,我们这里同样弱水三千,只取一瓢。

1
ReactDOM.createPortal(child, container)

createPortal API 相信大家都用过,但大部分用途应该都只是将 React 组件渲染到 body 上变成模态框之类的,但如果我们将整个桌面看做一个 body,而其中的某个窗口看做一个 div 容器呢?答案是这样做可行!!!

我们可以将 React 的组件直接 createPortal 到上面新生成的 window 里去,这样就走通了 React 组件化开发并管理 Electron 窗口的关键链路。

窗口管理器实践

我们已经拥有了在渲染进程里动态创建一个 Electron 窗口的全部知识,下面话不多说直接贴代码,手把手教你玩转 **Electron 'Portal'**!

React + Electron

我们需要拷贝样式至新窗口,在网上找到了如下一段代码,可以将外部样式表 link 和内联样式表 style 统统拷贝到新打开的 document 里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export function copyStyles(sourceDoc: Document, targetDoc: Document) {
const documentFragment = sourceDoc.createDocumentFragment();
Array.from(sourceDoc.styleSheets).forEach((styleSheet) => {
// for <style> elements
if (styleSheet.cssRules) {
const newStyleEl = sourceDoc.createElement('style');

Array.from(styleSheet.cssRules).forEach((cssRule) => {
// write the text of each rule into the body of the style element
newStyleEl.appendChild(sourceDoc.createTextNode(cssRule.cssText));
});

documentFragment.appendChild(newStyleEl);
} else if (styleSheet.href) {
// for <link> elements loading CSS from a URL
const newLinkEl = sourceDoc.createElement('link');

newLinkEl.rel = 'stylesheet';
newLinkEl.href = styleSheet.href;
documentFragment.appendChild(newLinkEl);
}
});
targetDoc.head.appendChild(documentFragment);
}

下面是实现 Portal 的关键代码,关于其实现有以下几个技术要点:

  • 通过 forwardRef 暴露 getSize 等获取实际渲染元素的状态信息方法,因为这些也是命令式的方法,正好契合了 useImperativeHandle 的名字,可以被父组件用 ref 来缓存后用于计算多个组件的位置关系。

  • 在 useEffect 中返回关闭 window 的方法以便销毁窗口。

  • 封装 getWorkArea 方法以便适配多显示器。

  • 初次显示窗口时切记要先 show 一个 0×0 的窗口,将它移动到指定位置再扩张尺寸使其显示,如果 show 放在最后的话,在 macOS 全屏模式下会另起一个全屏窗口,这不是我们所需要的。

  • 巧用 useEffect 的 dependency 监测 props 里位置信息的改变,自动映射到窗口上去。

  • 灵活提供 mountNode 参数给子组件,以便一些原本挂载到 document.body 上的全局组件(如 Modal 和 Toast)渲染到指定位置,仿佛它们是挂载到了 desktop.body 上一样。

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
const Portal = ({
children,
x,
y,
horizontalCenter,
verticalCenter,
inactive,
alwaysOnTop,
}, ref) => {
const [mountNode, setMountNode] = useState(null);
const windowRef = useRef();
useImperativeHandle(ref, () => ({
getSize: () => {
if (mountNode) {
const { clientWidth, clientHeight } = mountNode;
return [clientWidth, clientHeight];
}
return null;
},
}));
// 创建窗口和 mountNode
useEffect(() => {
const div = document.createElement('div');
div.style.display = 'inline-block';
const win = window.open('about:blank', `${WINDOW_PORTAL}-${String(new Date().getTime())}`);
if (!win.document) return;
copyStyles(document, win.document);
win.document.body.appendChild(div);
windowRef.current = win;
setMountNode(div);
return () => {
if (windowRef.current) {
windowRef.current.close();
}
};
}, []);

// 获取工作窗口的位置尺寸
const getWorkArea = useCallback(() => {
if (windowRef.current) {
const { remote } = windowRef.current.require('electron');
return remote.screen.getDisplayNearestPoint(remote.screen.getCursorScreenPoint()).workArea;
}
}, [windowRef.current]);

const getPosition = useCallback(() => {
const workArea = getWorkArea();
const xPos = (horizontalCenter ? (workArea.width / 2) - (mountNode.clientWidth / 2) : x) | 0;
const yPos = (verticalCenter ? (workArea.height / 2) - (mountNode.clientHeight / 2) : y) | 0;
return [workArea.x + xPos, workArea.y + yPos];
}, [getWorkArea, x, y, horizontalCenter, verticalCenter]);

// 初始化窗口
useEffect(() => {
if (mountNode && windowRef.current) {
const win = windowRef.current;
const { clientWidth, clientHeight } = mountNode;
const { remote } = win.require('electron');
const browserWindow = remote.getCurrentWindow();
if (inactive) {
browserWindow.showInactive();
} else {
browserWindow.show();
}
if (alwaysOnTop) {
browserWindow.setAlwaysOnTop(true);
}
browserWindow.setPosition(...getPosition());
browserWindow.setSize(clientWidth, clientHeight);
}
}, [mountNode, windowRef.current]);

// 位移
useEffect(() => {
if (windowRef.current && mountNode) {
const win = windowRef.current;
const { remote } = win.require('electron');
remote.getCurrentWindow().setPosition(...getPosition(), true);
}
}, [mountNode, windowRef.current, getPosition]);

return mountNode && createPortal(
children instanceof Function ? children({ mountNode }) : children,
mountNode,
);
}

export default forwardRef(Portal);

Svelte + Electron

如果一种范式只有一种实现方式,那说明它还不够通用。为了证明这种管理 web 窗口的方式真的有意义,笔者尝试用 Svelte 实现了同样的功能。这里先向不了解Svelte 框架的同学安利一下这个神奇的框架,它一开始的口号是”消失的框架”,什么意思呢?它不像 React、Vue 之类的打包后还需要一个运行时的 lib 提供各种工具函数,比如 React.createElement 方法,它是完全的编译时框架,编译后只有组件代码即可运行。所以在只需要渲染一两个动态组件时,其体积和性能优势非常明显,尤其是配合其作者开发的 Rollup 时。由于 Electron 对其内 web 浏览器具有完全的操控权,我们可以放心地交付 ES6+ 的代码,不必过多 care 兼容性。下面给出试用 Svelte 写的 Portal 组件代码,感兴趣的同学可以尝试下哈,感受下别样的框架风情~

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
54
55
56
57
58
59
60
61
62
63
64
65
66
<div bind:this={portal} class="portal">
<slot></slot>
</div>

<style>
.portal { display: inline-block; }
</style>

<script context="module" lang="ts">
function copyStyles(sourceDoc: Document, targetDoc: Document) {
Array.from(sourceDoc.styleSheets).forEach(styleSheet => {
if (styleSheet.cssRules) { // for <style> elements
const newStyleEl = sourceDoc.createElement('style');

Array.from(styleSheet.cssRules).forEach(cssRule => {
// write the text of each rule into the body of the style element
newStyleEl.appendChild(sourceDoc.createTextNode(cssRule.cssText));
});

targetDoc.head.appendChild(newStyleEl);
} else if (styleSheet.href) { // for <link> elements loading CSS from a URL
const newLinkEl = sourceDoc.createElement('link');

newLinkEl.rel = 'stylesheet';
newLinkEl.href = styleSheet.href;
targetDoc.head.appendChild(newLinkEl);
}
});
}
</script>

<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import type Electron from 'electron'

type PortalWindow = Window & {
require: (...args: any) => {
remote: Electron.Remote
}
}
let windowRef: PortalWindow
let portal: HTMLDivElement

onMount(() => {
windowRef = window.open('about:blank', 'windowPortal') as unknown as PortalWindow
copyStyles(document, windowRef.document)
windowRef.document.body.appendChild(portal)

const { clientWidth, clientHeight } = portal
windowRef.requestAnimationFrame(() => {
const { remote } = windowRef.require('electron')
const win = remote.getCurrentWindow()
const workArea = remote.screen.getDisplayNearestPoint(remote.screen.getCursorScreenPoint()).bounds
win.showInactive();
win.setPosition(workArea.x + 50, workArea.y + 50);
win.setSize(clientWidth, clientHeight);
})
})

onDestroy(() => {
if (windowRef) {
windowRef.close()
}
})

</script>

对 Svelte 和 Electron 结合感兴趣的同学还可以移步我的个人项目地址:https://github.com/msyfls123/diablo2-wiki

“中央处理器”

The last but not the least.

前面我们从一个带有 nativeWindowOpen 的 BrowserWindow 开始,经过 React.createPortal 创建新的 BrowserWindow 并将其交还给 React 渲染组件,并通过一系列的 effect 和 ref 方法使其得到了良好的管理,但实际上这个”窗口管理器”只存在渲染进程中,它与外界的通讯必须通过最开始那个 BrowserWindow 进行。我们需要将它封装成一个”中央处理器”,以便处理更加多样的调用。

这里不再赘述如何去用代码实现一个 WebUI 的类,因为它是全局唯一的,所以需要单例模式。在主进程入口,创建一个上面提到的用于承载各种窗口的 BrowserWindow,将这个 Window 通过 WebUI 的 init 方法注入到实例上,后续就可以通过公共的方法来调用这个中央处理器了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class WebUI {
init(window) {
this.window = window;
this.window.webContents.on('ipc-message', this.handleIpcMessage);
}
showDialog(type, payload) {
if (this.window) {
this.window.webContents.send('show-dialog', type, payload);
}
}
handleIpcMessage(event, channel, payload) {
...
}
}

主进程中使用只需要直接调用单例的方法,如果需要暴露给渲染进程则可以如法炮制一个 ipcServer 转发来自其他渲染进程的消息。

意义

首先是声明式渲染 vs 命令式渲染,声明式责任在自己,命令式责任在对方。为啥呢?从 Electron 加载页面的方式就可以看出来了,加载一个 GitHub 的首页,只要网页一变,必然需要调用方处理相关的改动,且是被动处理。

改为声明式渲染则可以把主动权把握在自己手里。因为不存在 URL,所以无需考虑跳转之类的问题,仅需考虑最大程度复用组件,可做到比 SPA 更进一步。但这里存在一个小小的问题,没有 URL 也意味着相对路径引用的文件资源统统不可用了,比如 ‘./images/a.png’ 这样简单的引用图片的方式是无法做到的,只能变成绝对路径或者是 dataURL 内联,后续将进一步探索更合适的资源加载方案。

然后是这种后台常驻一个渲染服务进程看似和市面上常见的预加载 + 显示隐藏窗口提升性能的方案大同小异,其实不然,那种方式本质上还是多对多地维护”窗口缓存”,数量上去了一样很卡。但这套方案后台渲染进程恒为 1,做到了 O(1) 空间复杂度,并且在页面加载上完全无需考虑 DOM 解析和 JavaScript 加载(因为根本就不需要,Portal 的渲染都在已经加载完成的那个窗口进行),做到了最小化资源占用。像 Serverless 追求的也是快速热启动,既然我们已知用户所有的操作路径,又有最高效的窗口管理方式,何乐而不为呢。

还有一点是,通过 Portal 可以实现任意渲染窗口不借助主进程独立创建并管理一个新的渲染窗口,这一点给了 Electron 更多新的玩法,比如自定义右键菜单、常驻任务栏等都可以借此实现,直接脱离原 DOM 树,给予更多的 UI 操作自由和想象空间。

注意事项

编译时与运行时区分

因为是跨端开发,所以一套代码会运行在多个平台上,需要区分编译时和运行时。

  • 编译时可以确定的值应该使用 webpack.DefinePlugin 替换成常量,如 process.env.NODE_ENV,这些是一旦打包以后再也不会发生改变了。

  • 运行时确定的值应该尽可能使用 os.platform 动态判断,原因是如果某一平台不支持某些属性,而开发时为了 debug 将功能开启,却忘了删 debug 开关,上线即造成这一平台的用户体验 crash 套餐。

参考

  1. https://ui.dev/imperative-vs-declarative-programming/

  2. https://www.electronjs.org/docs/api/browser-window

  3. https://medium.com/hackernoon/using-a-react-16-portal-to-do-something-cool-2a2d627b0202

丝般顺滑的 Electron 跨端开发体验

简介

减化软件开发复杂度的核心奥义是分层与抽象,汇编语言抹平了不同 CPU 实现的差异,做到了”中央处理器”的抽象,而操作系统则是抽象了各种计算机硬件,对应用程序暴露了系统层的接口,让应用程序不需要一对一地对接硬件。

回到这篇文章的标题,目前主流的桌面端主要为 Windows、macOS 和 Linux 三种,考虑覆盖人群,实际做到覆盖前两端即可覆盖绝大多数用户。或许有同学就要问了,都 2020 年了还有必要开发桌面端应用吗??Web 它不香吗?答案是:香,但它还不够香。网页因为各种安全方面的限制,现在还无法很好地和系统进行交互,比如文件读写(实际已经有了 Native File System [https://web.dev/file-system-access/)和更改各种系统配置的能力,这些往往是处于安全和兼容性的考虑。如果想强行在 Web 里做到这些部分也不是不可以,但相对普通 Web 开发,成本就显得过高了点,这点先按下不表。
俗话讲”酒香不怕巷子深”,Web 在富 UI 应用的场景下已经一枝独秀遥遥领先于其他 GUI 方案,像早期 Qt、GTK 等跨端 GUI 开发方案已经几乎绝迹(PyQt 还过得比较滋润,主要是 Python 胶水语言的特性编写简单界面比较灵活),随着移动端浪潮的袭来以及 Node.js 的崛起,更多开发者选择 JavaScript (包括附属于其上的语言和框架)进行跨桌面、移动端和 Web 的混合开发,像 Ionic、Cordova(PhoneGap) 等框架在 2011 年以后如雨后春笋般冒了出来,当然随着 Facebook 和 Google 发布 React Native 和 Flutter,广大 Web 开发者终于可以喘口气,看到了只学一种框架就用上五年的曙光。

移动端的竞争如此激烈,但桌面端则目前只有一个王者,那就是 Electron。我们来看看它究竟是什么?

这个问题的答案很简单,Electron 就是 Chromium(Chrome 内核)、Node.js 和系统原生 API 的结合。它做的事情很简单,整个应用跑在一个 main process(主进程) 上,需要提供 GUI 界面时则创建一个 renderer process(渲染进程)去开启一个 Chromium 里的 BrowserWindow/BrowserView,实际就像是 Chrome 的一个窗口或者 Tab 页一样,而其中展示的既可以是本地网页也可以是线上网页,主进程和渲染进程间通过 IPC 进行通讯,主进程可以自由地调用 Electron 提供的系统 API 以及 Node.js 模块,可以控制其所辖渲染进程的生命周期。


Electron 做到了系统进程和展示界面的分离,类似于 Chrome或小程序的实现,这样的分层有利于多窗口应用的开发,天然地形成了 MVC架构。这里仅对其工作原理做大致介绍,并不会详尽阐述如何启动一个 Electron App 乃至创建 BrowserWindow 并与之通讯等,相反,本系列文章将着重于介绍适合 Web 开发者在编码之余需要关注的代码层次、测试、构建发布相关内容,以「腾讯文档桌面端」开发过程作为示例,阅读完本系列将使读者初步了解一个 Electron 从开发到上线所需经历的常见流程。

在这里,笔者将着重介绍与读者探讨以下几个 Electron 开发相关方面的激动人心的主题:

  • 我只有一台 MacBook,可以用 Electron 开发出适用于其他平台的 App 吗?

  • 我需要为不同平台分发不同的版本吗?它们的依赖关系如何?

  • 如何让用户觉得我开发的应用是可信任的和被稳定维护的?

  • 我想让用户在”更多场景”下使用我的应用,我该怎么做?

  • 我是一个 Web 开发者,Electron 看起来是 C/S 架构,应该如何设计消息传递机制?

  • 用 Electron 开发的 App 可测试性如何,可以在同一套测试配置下运行吗?

不用担心,以上问题的回答都是”Yes,Electron 都能做到”。下面我们就进入第一个主题吧,如何构建你的 Electron 应用。

打包应用

首先我们假定你已经创建了一个 main.js 的文件,同时创建了一个名叫 renderer.html 的文件用于展示渲染内容,这时候你就可以直接将这个文件夹压缩后发给你的用户了,请用 Terminal 切换到该文件夹下,键入 electron . 并回车即可运行应用,全文完!撒花 ✿✿ヽ(°▽°)ノ✿

当然不是这样简单,我们需要交付的是一个完整的独立运行的 App,至少我们得把代码和 Electron 的可执行文件都打包进去。但首先第一步是,既然用户是下载了一个大几十 M 的 App,我们是不是可以直接在 App 里 serve 源码了?简而言之,是的,你可以将你的源文件连同 node_modules 一起发给用户,但是 ——

巨巨…… 怕了怕了,并且直接 server 源代码也可能会将一些敏感信息或者你写得不咋滴的代码直接暴露给用户,带来不必要的安全风险。这里介绍一下使用 Webpack 和 Rollup 打包 Electron App 的关键代码:

使用 Webpack 打包

用 Webpack 打包还是相对简单的,只需要将 config.target 设置成 ‘electron-main’ 或者 ‘electron-renderer’ 即可

1
2
3
4
// webpack.config.js
const config = {
target: 'electron-main' | 'electron-renderer'
}

其原理是对不仅包括 Node.js 原生模块,同时也包括 Electron 相关模块都不打包了,交给 Electron 自己在运行时解决依赖,见链接:webpack/ElectronTargetPlugin.js#L24-L64

使用 Rollup 打包

既然实际项目中都是拿 Webpack 打包的,何不尝试下新的方式呢,Rollup 作为打包 npm 模块的最佳工具,想必也是能打包 Electron 应用的吧…… 但 Rollup 就没有这么简便的配置方式了,需要做一番小小的手脚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// rollup.config.js
export default [
{
input: 'src/main-process/main.ts',
output: {
format: 'cjs',
},
external: ['electron'],
},
{
input: 'src/renderer/index.tsx',
output: {
format: 'iife',
globals: {
electron: "require('electron')",
},
},
external: ['electron'],
},
]

解释一下,format 为 ‘cjs’ 或 ‘iife’ 是表明适用于 Node.js 环境的 commonjs 或者是浏览器环境的立即执行格式,而他们同样都需要将 electron 设置为外部依赖,同时在渲染进程里还需要指定 electron = require('electron');。等等,这里竟然在 window 下直接 require???是的,通过创建 BrowserWindow 时设置 nodeIntergration: true 即可在打开的网页里使用 Node.js 的各种功能,但能力越大所承担的风险也越大,所以是得禁止给在线网页开启这个属性的。

这里还有个类似的属性 enableRemoteModule。 它的含义是是否开启远程模块,这样就能直接从渲染进程调用主进程的一些东西,但这样做同样有包括[性能损耗在内的一系列问题]{.ul},所以 Electron 10.x 以后已经默认关闭了这个开关,手动开启同样需要慎重。

不进行打包的依赖

虽然 node_modules 确实很大,但因为是桌面应用,总有些库或者包里的内容是不需要或者说没法去打包的,这时候就要将他们拷贝到生成文件夹里去,比如项目里用到的 levelDB 针对 Windows 32 位和 64 位以及 macOS 都有不同的预编译文件,这时将它们直接拷贝过去就好啦。

构建完成后,我们的应用已经有了直接 electron . 跑起来的能力,离可发布的 MVP 只差打包成可执行文件这一步了!

构建可执行文件

将代码打包成可执行文件同样需要市面上的第三方解决方案,有 electron-packager 和 electron-builder 可选,实际比较下来 electron-builder 提供了包括安装和更新在内的一系列流程,体验极好,所以只以其作为构建工具作介绍。

electron-builder 也是一个对开发代码无侵入的打包构建工具,它只需要指定好各种路径以及需要构建的目标配置即可一键完成打包构建、签名、认证等一系列流程。

electron-builder 是具有同时打包出多个平台 App 的能力的,具体在 Mac 上是通过 Wine 这个兼容层来实现的,Wine 是 Wine Is Not an Emulator 的缩写,从名字里强调它不是一个模拟器,它是对 Windows API 的抽象。打包后的应用与 Windows 上构建的应用没有区别,但构建时的 process.platform 会被锁在 ‘darwin’ 即 macOS,这是个看起来微不足道,但实则遇到会让人抓耳挠腮的情形,后面会详细展开。

但 Windows 就没有这么好运气了,笔者并没有找到可以在 Windows 上打包出 macOS 可用执行文件的方式,所以上面的同时出两个平台可执行文件的方式亲测还是只能给 macOS 用的。

自动升级

electron-builder 提供了生成自动升级文件的能力,配置好对应平台的 publish 字段后会同步生成升级 yml 文件,将它们和安装包一起上传到 CDN 并配置 electron-updater 即可以实现自动升级。

配置 electron-updater 需要注意以下三点:

  1. electron-builder 会在应用打包时偷偷塞进去一个 app-update.yml,本地开发时没有读到相似的开发配置会无法调试,需要手动复制一份并重命名成 dev-app-update.yml 放到开发目录下才能继续升级,但最后一定会自动升级失败,因为开发时的代码没有签名。

  2. electron-updater 会去读 package.json 文件的 version 字段,如果是主目录和 App 目录不相同的开发模式的话,需要手动指定 autoUpdater.currentVersion。同样需要手动指定的还有 autoUpdater.channel,这里有个 bug,mac 虽然用的是 latest-mac.yml 文件但 channel 却要设置成 latest,electron-updater 似乎会自动补上 -mac 字样。

  3. 与 macOS 静默升级不同,nsis 包的 Windows 升级动静很大,所以如果用户不是想立马升级的话最好将 autoInstallOnQuit 设置成 false,否则用户就会惊奇地发现哪怕取消了自动安装还是在退出后立马更新了。

1
2
3
autoUpdater.currentVersion = APP_VERSION;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.channel = os.platform() === 'darwin' ? 'latest' : 'latest-win32';

文件关联

在移动端 App 大杀四方,Web 大行其道,小程序蠢蠢欲动的当下,一个桌面应用的生存空间是极其狭小的,通常都不需要什么竞争对手,可能自身产品的其他端就把自己给分流耗竭而亡了……我们提到桌面端,不得不提的就是文件系统了,如果 iPhone & Android 都有便捷好用的文件管理系统,那感觉桌面端的黄昏真的就来到了。

但这里首先还是看一看 electron-builder 可以给我们带来什么能力吧。

系统关联文件类型

这就是文件右键菜单里的打开方式了,设置方式也很简单,通过设置 electron-builder config 里的 fileAssocaitions 字段即可。

1
2
3
4
5
const config = {
fileAssociations: {
ext: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt', 'csv'],
}
}

QQ AIO 结构化消息打开应用(Windows)

QQ 通过唤起写入注册表里的腾讯文档地址来打开腾讯文档 App 同时带上了 –url=https://docs.qq.com/xxx 的参数,继而打开对应的文档。

electron-builder 配置 nsis.include 参数带上 nsh 脚本,写入如下设置(注:该配置并不完全)即可帮助 QQ 定位已安装的腾讯文档应用。

1
WriteRegStr HKLM "SOFTWARE\Tencent\TencentDocs" "Install" "$INSTDIR"

NSIS 脚本还可以做很多事情,比如卸载 App 后清理数据或者检查是否安装了老版本等,具体可参见其官方文档。

1
2
Delete "$APPDATA\TDAppDesktop\*.*"
RMDIR /r "$APPDATA\TDAppDesktop"

在 QQ 发起消息后,文档这边需要支持解析所带的参数,从进程参数中解出相关信息再打开对应 Tab 页面。

1
2
3
4
const args = process.argv;
if (argv.length > 1) {
handleCommandArguments(argv.slice(1));
}

小结

本文简述了通过 Electron 构建应用过程中采用不同方式和配置打包文件、使用 electron-builder 构建可执行文件,同时用其提供的功能实现自动升级与文件关联,完成了在单个平台(macOS)开发并构建出跨端应用的任务。笔者接触 Electron 开发时间较短,行文中多是开发中所见所闻所感,如有错误纰漏之处,还望读者不吝包涵指正。后续文章将介绍在跨端开发中处理兼容性时遇到的问题,以及如何优雅地在产品设计和功能间进行取舍。

为什么我们要打包源代码

盘古开天之际,我们是不需要打包代码的,直接执行即可……

咳咳,走错片场了。但道理是一样的,当计算机还是由纸带驱动时,什么程序和数据都是混杂在一个个空洞之上,像挂面一样,没法打包,或者说压缩。直到区分了处理器和存储器,才有了汇编指令,程序才变得像意大利面一样曲曲折折,千转百回。

今天我们组的实习生突然提到了 B/S 架构,突然联想到之前的单体发布应用,加上目前对于 WebAssembly 等胶水层和大大小小各种容器技术的理解,想对编译打包这个过程做一点分析。任何产品,哪怕是数字产品,所追求的永远是多快好省,在更新频率要求很低的 90 年代,发放软件的方式可以是光盘💿甚至软盘💾,每次更新升级都需要插入磁盘驱动执行更新程序,这在当下点一点就能升级的体验是天壤之别了。用户的体验升级也意味着开发模式的进步,从复杂的单体架构(dll -> exe),变成了松散分布的依赖包共同组织成一个完整的应用(npm -> exe)。甚至无代码开发的时候,某些重型库或包的大小已经超出了一般的应用程序,这时如何将它们有机地组合在一起,将不多也不少刚刚好的应用交付给用户,就成了开发人员需要解决的难题。

熟悉前端的朋友应该知道,JavaScript 的加载经历了纯 script 引用加载 - AMD 异步依赖的刀耕火种时期,直到 2012 年 Webpack 横空出世,才解决了打开一个页面需要加载成千上百个 js 文件的困境。这是 HTTP 1.x 的弊病所导致的,当然这个时期 JavaScript 的作用大多限于提升页面丰富度,随着 node.js 的应用,越来越多的与系统相关的包进入 npm,它们活跃在 node.js 层,却无法被浏览器使用,怎么办呢?一个办法是在浏览器里模拟操作系统,就是虚拟机,这个肯定性能有问题,pass,或者就是把系统相关的接口阉割掉,只保留计算部分,这就是 WebAssembly:将程序编译成字节码在浏览器里以汇编运行,实现了浏览器编译能力的升华;另一个办法,是把浏览器和 node.js 环境捆绑打包起来,这就是 Electron!

个人觉得 Electron 最精髓的应用不在于可以把网页打包成桌面应用,当然也是赋予了它很多桌面应用才有的功能,比如数据库以及和系统交互的能力。最重要的是引入了 B/S 架构以后,代码的打包阶段可以被分块分层,从而使开发和构建过程各取所需,一个预想的未来是可以基于 Electron 做下一代编辑器(Visual Studio Code++ …… 大误,逃)集成了从服务端到浏览器端的全链路。当然目前比较有用的是可以选择性地不打包一些库。

开发时工具不用打包

用于开发时自动重启的 electron-connect 是不用打包到生产环境的。可通过配置

1
2
3
4
// main-process/window
if (process.env.NODE_ENV === 'development') {
require('electron-connect').client.create(window)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// rollup.config
import replace from '@rollup/plugin-replace'
const config = {
plugins: [
replace({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
}),
...
],
external: [
'electron',
'electron-connect',
],
...
}

来避免打包,在开发环境里用 node_modules 里的就好啦。

重型依赖只在生产环境下打包

像 RxJS 这种重型依赖,编译打包一遍耗时巨大,我们可以把它也排除在外,具体配置如下

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
// rollup.config
import html from 'rollup-plugin-html2'
import copy from 'rollup-plugin-copy'

const useBundledRx = process.env.NODE_ENV === 'development'
const rendererConfig = {
...common,
plugins: [
...common.plugins,
html({
externals: [
...(useBundledRx ? [{
type: 'js',
file: "../assets/rxjs.umd.min.js",
pos: 'before'
}] : []),
]
}),
],
output: {
globals: {
electron: "require('electron')",
...(useBundledRx ? {
rxjs: 'rxjs',
'rxjs/operators': 'rxjs.operators',
} : {}),
},
},
external: [
'electron',
...(useBundledRx ? ['rxjs', 'rxjs/operators'] : []),
],
}

const mainConfig = {
plugins: [
copy({
targets: [
...(useBundledRx ? [{
src: path.join(projectDir, 'node_modules/rxjs/bundles/rxjs.umd.min.js'),
dest: path.join(outDir, 'assets'),
}]: [])
],
})
],
}

开发环境打包流程如下:

  1. copy node_modules 下的 rxjs umd 文件至输出目录
  2. 渲染进程打包文件,排除掉 rxjs,并设置其为全局依赖(window.rxjs)
  3. 在 html 中引入拷贝过去的 umd 文件

避免无谓的打包,把优化用在刀刃上

这样以后,开发环境将不在打包 rxjs,而生产环境下做 tree-shaking 之后直接和业务代码合成一块,在本机加载的基础上更进一步缩小体积,利于解析。实际上,Electron 将大部分的包都直接打进 exe 文件都不会太大影响,只是为了项目目录整洁,我们还是选择尽可能多的用 bundle 过的包,无论是 npm 打包的还是我们自己 bundle 的。