事实上,这也是我们一开始进行技术选型和开发规划时所考虑的问题,因为选择了使用 JavaScript 及 Web 技术开发客户端,就注定了与 web 开发息息相关,包括迭代周期和开发顺序等方面,web 端腾讯文档的发布周期是一周两次发布,在一个月时间内差不多可以交付一系列完整的 API,这样可以做到桌面端与 web 端并行开发,最终整合成一个整体。如果等到半年时间才交付了桌面端,这时 web 端应用的 API 和 JSBridge 等接口规范都可能随之发生改变,容易造成返工甚至二次开发。
首先是总体的开发框架选择,结果是没有疑问的,我们选择了 Electron,实际上 NW.js 前身 node-webkit 和 Electron 的开发团队具有继承关系,而 NW.js 的特点是以 html 作为启动文件,在窗口里直接调用 Node.js,但我们知道能力越大责任越大,同时风险系数也越高。Electron 的主进程是跑在 Node.js 环境下的,可以无缝使用 Node.js 能力,而单独的窗口,即渲染进程,需要显式地打开开关才能使用,这样就一定程度上降低窗口中的页面滥用 Node.js 能力对系统造成危害或者频繁调用 Node.js 能力对性能产生影响的可能性。在插件、第三方包、社区生态和搜索热度上, Electron 都完胜于 NW.js,所以我们就放心地使用 Electron 进行开发吧。
社区优质实践
既然选定了 Electron 作为开发框架,先来看一看业界基于 Electron 的优质实践,首当其冲的是宇宙第一 IDE 的 Visual Studio 的 …… 挂名弟弟 …… Visual Studio Code,同样是微软出品,现已成为 web 开发事实上的标准 IDE。
然后是 Github 出品的 Atom 编辑器,这里插一句题外话,Electron 原名”Atom Shell”,后来随着框架的进一步抽离和沉淀,改名为”Electron”,这点非常符合国外技术圈觉得”工具不好用就发明一个趁手的工具”的思路。以及同样是 GitHub 出品的 GitHub Desktop 客户端,其他知名的基于 Electron 开发的桌面软件还有协作办公软件 Slack、 IM 即时通讯软件 WhatsApp 和 知识协作软件 Notion 等。
构建工具
我们做桌面端应用与 web 端应用差异最大的在于分发方式不同,web 端应用打开页面浏览即为分发成功,而桌面端应用则必须要下载到本地安装后使用,所以提升下载与安装体验对用户增长率提升至关重要。而提到安装包,就不得不提一下 electron-builder,它不仅做到了轻配置快速构建,也带给了桌面端应用非常多的额外能力,例如系统级别的文件关联,自动签名认证功能,制品管理和安装流程定制等,这些都与后面讲到的工程化建设和跨端体验一致性密切相关。通过一套配置,即可构建出包括自动更新、App Store 发布包在内的多个制品。
单测框架
如果说安装包是团队给用户的交付物的话,代码就是开发给团队的交付物。好的代码应该是可测试、可维护和承上启下的,要做到这些的最佳实践形式就是编写测试。而多种多样的测试里最方便快速的就是单元测试了,针对 Electron 的测试方式与常见 web 端测试不同,也可以认为是分别在 JSDOM 和 Node.js 两种环境下进行测试。经过调研,我们引入了 @jest-runner/electron 作为我们的单测框架,它的优势是一套配置,根据文件目录分发到两种执行环境下运行,也就是前面提到的主进程和渲染进程。并且具有代码无侵入,配置简单,速度飞快等特点。
在前一篇文章中笔者主要介绍了使用 Electron 进行开发过程中打包、构建、自动升级与文件关联相关的内容,细心的读者可能已经发现其中并没有提到与界面相关的话题。时隔一个月,中间又经历了一些需求迭代开发和代码维护,这篇文章将会尽可能详细地介绍 Electron 跨端开发中用户界面部分与平台及系统差异相关的注意点,并探索建立支持自定义多范式的窗口管理方式。
窗口开发基础
我住长江头,君住长江尾,日日思君不见君,共饮长江水。 —— 李之仪
通信方式
我们知道所有 Electron 自定义的窗口界面都是跑在渲染进程里的,而讲到渲染进程则不得不提到主进程。《卜算子》这首小令非常形象地道出了同一个应用里不同渲染进程和主进程之间的关系:一个应用实例只有一个主进程,而会有多个渲染进程。渲染进程之间是无法直接通信的,他们必须要通过主进程这条”长江”来通讯。
但这时有一个问题,窗口内加载的仍是 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.”
如上图,如果 a - b < 0,则将 a 放在 b 的左边,如果 a - b === 0 则保留两者位置不变,如果 a - b > 0,则将 a 放到 b 的右边,简单朴素地说明了排序的基本原则,而具体排序是用什么方式,时间复杂度和空间复杂度都不要用户去关注(如果想要进一步了解 Array.sort 的实现可参见这篇 StackOverflow 的回答)。
exportfunctioncopyStyles(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); } elseif (styleSheet.href) { // for <link> elements loading CSS from a URL const newLinkEl = sourceDoc.createElement('link');
<scriptcontext="module"lang="ts"> functioncopyStyles(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); } elseif (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>
这个问题的答案很简单,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 应用。
electron-builder 是具有同时打包出多个平台 App 的能力的,具体在 Mac 上是通过 Wine 这个兼容层来实现的,Wine 是 Wine Is Not an Emulator 的缩写,从名字里强调它不是一个模拟器,它是对 Windows API 的抽象。打包后的应用与 Windows 上构建的应用没有区别,但构建时的 process.platform 会被锁在 ‘darwin’ 即 macOS,这是个看起来微不足道,但实则遇到会让人抓耳挠腮的情形,后面会详细展开。
但 Windows 就没有这么好运气了,笔者并没有找到可以在 Windows 上打包出 macOS 可用执行文件的方式,所以上面的同时出两个平台可执行文件的方式亲测还是只能给 macOS 用的。
本文简述了通过 Electron 构建应用过程中采用不同方式和配置打包文件、使用 electron-builder 构建可执行文件,同时用其提供的功能实现自动升级与文件关联,完成了在单个平台(macOS)开发并构建出跨端应用的任务。笔者接触 Electron 开发时间较短,行文中多是开发中所见所闻所感,如有错误纰漏之处,还望读者不吝包涵指正。后续文章将介绍在跨端开发中处理兼容性时遇到的问题,以及如何优雅地在产品设计和功能间进行取舍。