网站主题

随着 CSS Variables 的普及,切换网站主题变成了一件轻松又愉快的工作,只需简单在 @media (prefers-color-scheme: dark) 下指定新的 --* 变量即可。但如果有多个主题,或者是主题嵌套该怎么办呢?
本文将告诉你如何解决这一切。

主题
纯色主题
带图主题
自定义主题
亮色主题
深色主题
主题管理器
document.body

主题复用

假设我们有三个主题,

  • 纯色默认主题 A (即 :root)
  • 暗色主题 B 继承自 A
  • 带图主题 C 继承自 A

很明显,我们可以写成下面这样的样式表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
:root,
[data-theme='B'],
[data-theme='C'] {
--red: #f00;
--bg: #fff;
}

[data-theme='B'] {
--bg: #000;
}

[data-theme='C'] {
--background: url(c.png)
}

:root {
--bg: #fff;
}

但我们很快发现一个问题,主题间可能存在覆盖问题,A 与 B 的 CSS 优先级是一样的,如果有一个在下方就会被覆盖,那如何能加深这个深色模式呢?答案是加一层选择器。

我们发现 data-theme 已经不适合表达包含明暗和其他配色的多个主题了,于是用 data-mode 来表示明暗。

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
:root,
[data-theme='A'],
[data-theme='A'][data-mode='light'],
[data-theme='B'][data-mode='dark'],
[data-mode='dark'],
[data-theme='C'],
[data-theme='C'][data-mode='light'] {
--red: #f00;
--bg: #fff;
}

[data-mode='dark'],
[data-theme='B'][data-mode='dark'], {
--bg: #000;
}

[data-theme='C'],
[data-theme='C'][data-mode='light'] {
--background: url(c.png)
}

// 其他地方引入的样式

:root {
--bg: #fff;
}

看起来多了很多无用的代码,但我们列个表格来分析下各种组合。

明暗 A B C
Light 白底无图 N/A 白底带图
Dark N/A 黑底无图 N/A

可以看到最后一个 :root 由于优先级不如前方主题将不会覆盖前面的样式。

主题嵌套

前面我们解决了平级主题的复用,但如果是父子元素的嵌套呢?这时就变成了一个 CSS Variables 的嵌套。

HTML 模板

前端三剑客 HTML/CSS/JavaScript。JavaScript 已经飞黄腾达,甚至都不认浏览器这个祖宗了;CSS 痴迷与自己的布局样式、动画和色彩、Houdini 扩展等等,进入了修仙的通道。看来看去只剩 HTML 这个老顽固还在自己的一亩三分地里爬坑,几十年来一点长进都没有。

虽然 web components 让人们看到了重拾 HTML 组件的希望,但随着 Safari 等厂商的不配合,以及深入探究后的继承兼容性问题,最终也没有达成一致。HTML 还是归于一滩死水。但这样的的好处也很明显,像其他领域还在挣破头皮要开发新功能时,HTML 只要能渲染 if/else, for loop,import/include, pipe/filter 就谢天谢地足够日常使用了。

正因如此,其实各家 MVVM 与其说是为了渲染 HTML 而写了个框架,不如说是挂羊头卖狗肉专职搞数据流,只是把随手写的 HTML 模板滥竽充数地塞到了用户面前。而这也造成了各家的模板语言五花八门,根本无法有效互通。

React
Svelte
Vue
Angular

更可恶的是,小程序还有一套模板语言……

这些模板可谓是争奇斗艳,群魔乱舞。但最大的一个问题是,很多模板都在破坏 HTML 自身的图灵完备。几十年后,不要说读懂这段代码,就是运行版本都找不到。

有悲观的博主甚至直接切换到了 web components 来对抗这种不确定性,但这只适合于自娱自乐的自嗨项目,公司级项目肯定不合适。咋办呢?先调研调研用过的模板吧。

模板大赏

非 JavaScript 模板

Django 模板

这大概是我用过最早的模板了,甚至还记得当时第一次联动 MySQL 输出动态内容时的喜悦,语法接近于 ejs,大括号加 % 包裹一切。用起来平平无奇,只记得 Python 传数据用 dictionary 很麻烦,每次都要加 ['*'] 来获取对应的键值。但这种模板的好处是跟后端彻底分离,只作为模板单独存在,也容易替换。

Plim

平心而论这可以说是我见过地表最强的模板了,像瑞士军刀一样百搭。基于 Mako 的设计,使其支持了很多你想要的功能,很多你想都想不到的功能它也支持。甚至直接在模板层编译 Sass 和 Stylus,还有 markdown。但它最最令人满意的是,和 Jade(现在叫 Pug 了)还有 Python 一样,是基于缩进来区分嵌套层级的。

Plim

less is more

当你写了一整天代码,突然说要改个模板中的链接,你就会发现没有闭合尖括号的代码是多么的赏心悦目。

JavaScript 模板

静态模板

Handlebars / Mustache

这两个框架都以他们的 logo 而闻名,大胡子形象深入人心。要说还有啥特别之处,倒不如说是他们都有别的语言的实现,可以方便地迁移。哦,对了,还都定位于可以渲染不只是 HTML 的内容,比如 RTF 或者配置文件 config.ini 之类,但还有哪个别的框架不能这么做吗?微笑脸😊

ejs

一个标榜自己是纯 JS 语法的模版,广义上来说它也算是 JSX 的一种了。相比其他模板遮遮掩掩去写 loop 和 if/else 标签,它做到了原生 JS 的控制流。

1
2
3
<% if (user) { %>
<h2><%= user.name %></h2>
<% } %>
1
2
3
4
5
<ul>
<% users.forEach(function(user){ %>
<%- include('user/show', {user: user}); %>
<% }); %>
</ul>

怎么说呢,看起来虽然有些怪,但还是比较符合直觉的哈。

nunjunks
又一个舶来品,从 Python 的 Jinja2 改装而来,总体平平无奇,有个 asyncAll 的功能可以在模板里用 Promise,但我何必呢 …… 直接在外面拼好不就行了。

动态模板

JSX

JSX 可谓是大道至简。语法上除了直接渲染 Array 展开表示 for loop 和 && 表示 if 之外,再无别的特殊语法。哦,还有个 dangerouslySetInnerHTML 可以表示 raw 输出 js 内容到 HTML。

可以说是跟 React 的理念一样,消除了绝大部分需要记忆的内容。

all-in-one (Vue, Svelte)

这种模板,又可以说是框架的一部分,早已为了框架整体的性能或者架构设计而做了很多特殊适配。比如 Angular 的 Ivy Rendering Engine 就思考了这样一个问题,到底是现有组件还是现有模板?很明显从数据的流向来说,先有组件里的 data 再有 template,这样就出现了问题,在加载组件时要先加载组件的 class 部分再去 compile template,那不就存在 template 不对应但却运行下去了?所以 Ivy 就把 template 里的类型定义反转暴露给组件侧进行 AOT 检查。

详见 https://www.angularminds.com/blog/what-is-angular-ivy

The Ivy Compilation Model

In the Ivy model, Angular decorators (@Injectable, etc) are compiled to static properties on the classes (ngInjectableDef). This process takes place without a complete analysis of code, and in most cases with a decorator only. Here, the only exception is @Component, which requires knowledge of the meta-data from the @NgModule which declares the component in order to properly generate the ngComponentDef. The selectors which are applicable during the compilation of a component template are determined by the module that declares that component.

The information needed by Reference Inversion and type-checking is included in the type declaration of the ngComponentDef in the .d.ts. Here, Reference Inversion is the process of determining the list of the components, directives, and pipes on which the decorator(which is getting compiled ) depends, allowing the module to be ignored altogether.

解决方案

目标

  • 同时支持 SSR 和 SPA 动态渲染
  • 对数据和现有框架侵入低

Next.js 介绍

如何从前端到客户端

我是如何从一个工作全部内容只是 HTML + CSS + JavaScript 的前端,转为一个依然基本靠着前端三板斧技能工作,但支撑起了”横跨三大操作系统 + 各种处理器架构 + 保持 web 同步迭代周期 + 复用了 web 95% 以上功能的桌面端产品“的伪客户端工程师?

首先当仁不让地要祭出 Electron 这件大杀器,现在开发一个桌面端最最行之有效的方式仍然是 web 套壳,让 web 代码几乎不用改就可以直接运行在一个窗口里。如果采用别的技术方案,我曾不止一次地在前同事和当下 leader 等口中听到拿 C++/OC/Swift 之类的写界面要吐血三升之类的话语,理解各种 Window、Dialog、Layout、Signal & Slot 就足够一个前端喝一壶了,而这些技术细节在 Electron 下都可以化繁为简成一个个普通的 Chrome 窗口,以及与之相关联的 Node.js 胶水逻辑。

Electron 更接近于 Hybrid 应用

Electron 更接近于 Hybrid 应用

虽然把页面展示出来只是客户端面临的众多问题之一,但这恰好是客户端做 UI 最复杂的那一部分。

使用 Electron 就是说我可以在不改变主要工作语言的前提下,尽可能快且大地延展自己的作业范围,以较低成本快速搭建起原型,这是前端选择其他语言或者框架进行客户端开发无法企及的优势。

那是不是只要上了 Electron,把 web 页面一封装就万事大吉了呢?倒也不是,客户端开发上接网络通讯、界面渲染,下至系统特性 API,它这个逻辑不是后端来一个 request 返回一个 response(简化模型),也不是前端从一个页面加载到脚本执行后进入等待 UI 交互的过程就结束了。客户端需要同时处理多个页面的展示、管理前后台进程、与服务器做通讯、主动存取数据至文件等。

角色 替身
后端 收银员/会计
前端 导购/客服
客户端 客户经理/维修人员

我更偏向从角色和现实生活中的替身角度认识各个端的分工,这个分工不是绝对的,彼此之间有交集。前端主要负责 UI 的展现,像是一个商店的导购小姐,根据用户喜欢安排不同的商品;客户端则是客户经理,向已经对此感兴趣的用户进一步演示商品的功能,并分析深层次用户需求;后端则是收银员兼会计,这时候用户已经拍板下单了,如何收钱,如何签订协议,如何发货和三保。之后可能还会有进一步交互,比如用户发现商品出了问题,继续通过网页上反馈过来,然后客户端需要更新版本,相当于以旧换新,完成持续交付。

下面我将从四个角度来阐述我对客户端开发的理解:

技术应用

抛开具体使用的语言或者框架,不同的职位或者开发方向在解决用户需求和技术问题上有着不同的纵深,意味着“术业有专攻”。这一点很多时候属于给用户一把锤子,他就看啥都像钉子一样,由不得你,甚至还会倒逼你去一步步提升体验。

剪贴板

既然都上了客户端,想必用户需要频繁操作的了,这就免不了要和键盘及鼠标事件打交道。常见的输入方式还好,就跟浏览器基本一致,只是遇到过 Mac 上无法通过快捷键复制粘贴的问题。说来都离谱,作为一个文本编辑软件,发布出去时竟然无法使用快捷键 Ctrl + C + Ctrl +V!还好我们很快就发布了版本修复了这个问题。

Shortcuts in Electron on Mac

而后我们又发现了右键菜单也得自己实现,不由感叹浏览器真是个复杂且贴心的玩意,给前端实现了如此多的功能。右键菜单部分同样是由 Electron 封装好了大量具体系统的 API,只需 JS 调用即可。

这些可能还是看得见的部分,看不见的部分,比如系统的剪贴板?如果我们想要模仿手机上长按复制文本至 App 内识别链接并打开对应页面的功能该如何操作呢?

因为各系统的剪贴板实现都是不同的,Web 上早期使用 execCommand 来与剪贴板交互,现在则有 Clipboard API。看起来很美好,只是致命的是 clipboardchange 事件尚未被 Chrome 实现 …… 这里陷入了前端的盲区。

Async Clipboard API - clipboardchange event not fired

从前端角度走不通,可以看一看 Electron。Electron 本身自带了一个 Clipboard API,上面有各种读取文本、富文本数据、图像的方法。同时,发起检查剪贴板的请求倒是容易,再不济也可以用轮询。做得细一点可以用 RxJS 来订阅一个根据鼠标聚焦窗口事件 + 剪贴板文字 + 去重 + 抢占式的调度模型:

使用 RxJS 处理前端数据流

这里有个问题:当用户重复复制同一段文本进入到我们的客户端时如何判断出来呢?文本比较肯定是失效了,系统层面前面也了解过没有一致的剪贴板改变事件,所以只剩一条路,就是标记这段文本。

Untitled

因为绝大部分程序只读 bookmark 里的 url 而不会去读 title,可以用 title 来标记这段文本已经被我们的客户端识别过了,当用户再次从任意地方复制了文本时会清除掉这个 bookmark,也就达到了我们标识已经过 App 识别链接的文本的目的了。

到这里我们发现,客户端的开发与前端正在趋同,两者都会深入系统提供的功能,又期望第三方库或者浏览器能提供标准的接口。

安装与更新

作为前端开发,页面关闭或刷新时,整个页面所有的元素都被销毁了,重新载入就是一张全新的白纸。而客户端不一样,下载下来就在硬盘占据了几百 MB 的空间,那可是战战兢兢,指不定哪一天用户嫌体积大就给卸载了。怎么办?当然是与时俱进,网页的优势在于刷新就是新版本,而客户端就只能老老实实做一键安装与自动更新了。

本着用户价值最大化的原则,现在软件大多抛弃了争奇斗艳的安装界面,反正做得越花哨越像流氓软件,Electron 社区标配 electron-builder 打包时提供的默认安装功能就挺好。只不过有时候要考虑旧版本的卸载问题,因为客户端技术更新换代,总有技术断层的阶段,旧版本无法正常自动更新到新版。像 Mac 上软件都进到了全局唯一的软件目录,安装时可以由系统来提示,玩 Linux 的也都是大神,安装不上也会自己手动 remove,但最广大的 Windows 用户迫切需要一键卸载旧版本的功能。我找到了 electron-builder 所依赖的 NSIS 安装器,定制了其安装脚本,在检测到旧版本的卸载程序存在于注册表时,会直接调用这个卸载程序,并等待它成功返回后进行新版本的安装过程。

作为互联网产品,诱骗,啊不是,引导用户安装上客户端不是终点,需要推陈出新、高效迭代,这就需要自动更新了。自动更新的目的在于及时让用户用上新版本,仿佛是一句废话。但鉴于大部分用户都是既嫌你更新太勤,又嫌你下载耗费流量,还盼着你能给他一天天地带来体验优化的主,这个开发思路还就是和网页前端不一样。

系统 CPU 架构 安装包 自动更新包
Windows x32 / x64 / arm64 exe exe
macOS x64 / arm64 dmg / pkg zip
Linux x64 / arm64 deb / rpm -

首先不同的系统当然是不同的包,而根据 CPU 架构及包的用途,就能打出 10 多个不同的安装包来,总大小甚至超过了 2G …… 这在前端的角度看来真有些不可思议,究其原因还是因为 Electron 打包了 Chromium,这个东西就占据了每个安装包中至少 70% 的空间,而这一切都是为了能保证在不同机型上都有一致的浏览体验。

由于一开始开发时没有意料到 CPU 架构竟会如此迅速地扩展,所以现在只得以新版本逐渐刷量的方式来逐步替换成独立架构的客户端。这样做的好处是,后续更新时只需要下载对应架构的文件即可。

Untitled

关于自动更新还有如何进行灰度配置下发更新信息,通过 CDN 进行版本管理,App 内如何完成更新等,可参见之前的文章:

Electron 客户端自动更新

这还未到终点,更新一整个客户端接近 100M 的体积仍是过重了,还可以将 Electron 内核和不怎么修改的数据库部分都抽离出来,这样只需要与网页一样更新静态资源即可。

Untitled

离线使用

本身 Electron 就是可以离线使用的,只是加上了在线网页之后就需要网络才能工作。一种方案是和移动端一样拦截 HTTP 请求并转发到本地离线资源,但这样带来的问题是需要定义非常繁复的拦截规则,以及如何更新离线资源等。

我们可以将离线的数据和资源分离,数据可以同移动端走一套数据库接口,只不过需要将数据库编译成不同的平台的动态链接库。

初探 Node.js 原生扩展模块

然后是资源离线使用,或许很多人已经猜到了,那就是被称为 Electron 杀手的 PWA。

Untitled

在看过了 PWA 所列出的种种好处后,我们发现其不可避免地仍是一个 web 应用。所以,一个大胆的假设,用 PWA 来加载客户端所需要的页面!这样既拥有了网页的便利,又拥有了可触达系统本身 API 的能力,可谓一石二鸟之计。

Untitled

目前我们的腾讯文档桌面端离线功能正在紧锣密鼓地攻坚中,很快将于大家正式见面。

浏览器体验

作为一个名为客户端,实际上是定制版 Chrome 的软件,向 Chrome 看齐永远是对的。

用户说页面字体太小,看不清,那就给他加缩放快捷键。

用户又说缩放了看不到当前比例很慌,那就给他加缩放比例的 tips。

Untitled

这个用户满意了,那个用户说,哎呀我导出来了文档放在电脑上记不住了呀。

给他抄个 Chrome 的下载记录页面!

Untitled

如何存取页面缩放比例?如何在不同窗口间切换时共享一个 tips?鼠标悬浮移入移出 tips 显隐规则是怎样的?

导出记录该如何跟文档一一对应?有很多天记录时该怎么设计数据结构?本地文档被删除了怎么办?

不做不知道,一做吓一跳。原来一个浏览器不止看到的网页部分,周围的配套功能也有很多门道。

作为追赶者,有个好处是毕竟前方一直有领军者,永远有追赶的目标,但也总得想着如何积攒自己的优势,在属于自己的赛道上滑出自己的风采。

角色扮演

前面提到的都是用户需要什么功能,但作为开发不仅仅是写出代码交给用户就完事了,同样重要的是进行多方合作,齐心协力将产品做好。这时就需要发挥主观能动性,在不同的情景下扮演不同的角色了。

测试者

前面提到过,前端页面往往是单个页面完成单个任务,随用随走,所以其测试也主要针对在线数据。但客户端与之不同的在于有本地数据和系统 API 的差异,这也就决定了如果按照传统的人工测试,其成本是很高的。从效能上讲,如果只是编写的代码虽然可以在不同系统运行,但有多少系统就需要测多少遍,是不够经济的,同时测试人员也不一定完全理解设计意图。答案是得用自动化的测试进行覆盖,这就要求开发人员同时扮演测试者的角色。

如何改进呢?首先从技术上讲,应该把测试行为左移。

Untitled

从设计阶段就应该埋入测试所需的常量或者数据,比如可供 UI 测试获取元素用的 CSS 选择器,一些数据的 mock 可以直接放在类型定义旁边,方便测试时直接引入。

而在编写代码时,则应注意拆分可测试的单元。如果一个功能很复杂,那可以分成若干个子模块来进行编写,同样的,如果一个模块无法便捷地被测试,那它也可以被拆成若干个可测试单元,这样可以在一旦出现问题时通过一系列测试用例,准确定位到具体的单元,而不是一遍遍运行完整的模块测试以查找蛛丝马迹。

在编写测试用例时,区分平台特性是很重要的,例如快捷键或是菜单,这是 mac 和 Windows 存在显著差异的部分,又或者说只支持某个平台的用例,这时就得将不同系统的用例集用不同的机器运行。

到了运行测试时,因为客户端测试往往需要漫长的启动初始化,运行测试,处理异常情况、退出销毁测试环境等过程,在将测试自动化的过程中仍需要缓存一些数据,以供多次运行测试,降低边际成本。

合作方

作为客户端,前端和后端都是你的爸爸。为啥这么说呢?因为页面出了问题,得找前端修,接口出了问题,得找后端修,仿佛变成了 bug 路由器。

想要克服这些问题,需要做到两点:

  1. 做好日志
    当用户发现问题,找到你这边,如何优雅地甩锅 …… 哦不是 …… 定位问题呢?那就是在各种用户行为及接口返回时都做好日志以及进行参数校验,遵循宽入严出的准则。所谓害人之心不可有,防人之心不可无,把任何第三方业务都当成是不可信来源进行防范。同时日志也可以还原出用户操作轨迹,有时候出错的点并不是问题的根源,前几页日志里一行不起眼的 warning 才是真正的问题所在。
  2. 明确职责
    虽然仍然是直面用户的那一端,但团队协作讲究的是分工明确,不逾矩不代劳。客户端本来就是中枢站,如果把前后端的功能都挪到端上实现,必将牵一发而动全身,这就是重构原则里的“发散式变化”。笔者之前接受登录模块,模块里甚至直接存了用户的 token,这是非常敏感的数据,如果不经良好的加密手段,可能造成用户权限被盗用。如何解决这一点呢?答案是不要重复发明轮子,而要善于利用标准轮子。登录相关的标准存储轮子就是 cookie 了,而 cookie 是由 Chrome 直接管辖的,我们只需交给 Chrome 来鉴权,并且自己维护一个非敏感的用户登录状态即可。
    如果一段程序不知道其作用范围,那就不要写。

客服

记得我刚毕业实习时问当时 leader,我们会有直面用户的机会吗?leader 微微一笑,肯定会有的。后来我发现,原来不用和用户打交道、安心写代码的日子,才是非常弥足珍贵的……

有人说客服是性子最好的,因为需要每天应对用户各种刁难职责而不变色。当用户找上门来,通常都是丢失了数据、或者打不开应用闪退之类的问题,仿佛落水之人看到了救命稻草。这种情况下,如果不能给用户解决问题,是有很大心理压力的,而人有压力时就容易犯错。

如何降低犯错的概率和风险呢?一种方式是尽量减少操作的步骤。俗话说,less is more,能让用户一键完成的操作就不要让他点两次。

Untitled

上图是一个打开崩溃日志目录的按钮,用户需要手动把该目录里的 log 文件提供给开发(因为没找到司内相关 native 日志服务)。在没有这个按钮之前,用户需要手动打开终端,输入一长串地址,然后才能到这个目录下,而这中间任何一步都可能阻塞住用户。只有最简单的操作流程,才能高效地解决问题。

但很多时候,用户的问题并不那么简单。看似是 A 除了问题,对比日志后发现 B 也有问题,在 debug B 的过程中发现其依赖于 C。甚至到最后发现这些统统都不是问题所在,用户使用的根本不是你这个软件!这里就陷入了用户给你制造的黑盒。怎么办呢?在现有工具无法保证筛选出正常的反馈时,就得通过作业流程来保证各种类型的 bug 在每个阶段就被精准定位并消灭了。

中医有望闻问切四种基本诊察疾病方法,在针对用户反馈时也可以这么做:

  1. 望:根据用户描述,大致判断问题是否属于所属产品,是网络问题还是应用问题。
  2. 闻:故障截图、上报日志,分析可能出错的模块,如若遇到误报情形,在这一步即可排除。
  3. 问:让用户参与调试,通过改变设置项、清理缓存等步骤以期快速恢复。
  4. 切:直接给用户换一个版本(客户端就这点好,不同用户可以用着不同版本),相当于做移植手术了。可以植入更多自定义日志上报功能,更全面地分析用户使用状态,以便根治问题。

知识储备

前面讲到了技术应用方向和自我角色扮演,它们都属于外功,也就是技能部分,下面要讲的可说是内功心法。读过武侠小说的人都知道,内外兼修,德才兼备,方可成为一代大师。技巧永远是层出不穷的,只有透过日复一日,年复一年的基础积累,感受技术背后的脉搏,才能融会贯通,成为优秀的开发者。

IPC

Node.js addons

CI 流水线:蓝盾 Stream CI

WebAssembly

模式:图、pub/sub

Windows 注册表

心理建设

采用最通用的技术,延长更迭周期

控制技术的复杂度

学习新技术的恐慌

初探 Node.js 原生扩展模块

最近因项目需要开始研究 Node.js 原生模块的实现,并尝试接入自研 C++ 模块。Node.js 因其具有良好的跨平台适配性和非阻塞事件循环的特点,受到了服务端开发者的关注,但 JavaScript 毕竟基于 GC 实现的数据结构,在高性能计算上有所不足;而且很多老代码或者扩展库都以 C++ 书写,也给移植编译带来了一定的困难。如何在性能、兼容性和开发效率上取得平衡,为了解决这些个问题,让我们开始书写第一个 Node.js C++ addon 吧!

C++ addons 的原理

不管你看哪个教程(其实中文书也就一本,就是死月这本《Node.js:来一打 C++ 扩展》),提到 Node.js 一上来就是 V8 Isolate, Context, Handle, Scope 讲一堆,看完这两百页头已经晕了。这些都是非常基础的 V8 知识,但离实际运用还隔了很远。为了写出 C++ addons,我们只要抓住一点 ———— “JavaScript 对象就是一个个 V8 C++ 对象的映射”。

V8

String 类型的继承链

上图是 JS 里一个简单 String 的继承关系,如何创建一个简单的字符串呢?String::NewFromUtf8(isolate, "hello").ToLocalChecked(),是不是看了有些头大?如果告诉你这种继承关系随着 V8 的升级经常发生变化,是不是感觉血压都高了?

没错,这就是上古时期 Node.js C++ addons 的开发方式,需要指定 Node.js 的版本进行编译,只有在指定 ABI 的版本下才能运行。

https://nodejs.org/api/addons.html#hello-world ,有兴趣的读者可以阅读下 Node.js 官网对于 C++ addons 的简易劝退教程,里面展示了不少早期 Node.js 开发者与 C++ 对象搏斗的真实记录。

兼容性

在经历了刀耕火种的日子后,盼星星盼月亮终于迎来了 Node.js 的原生抽象 —— N-API。它利用宏封装了不同 V8 版本之间的 API 差异,统一暴露了多种识别、创建、修改 JS 对象的方法。让我们来看看如何创建一个字符串呢?

1
2
3
4
#include <node_api.h>

napi_value js_str;
napi_create_string_utf8(env, "hello", NAPI_AUTO_LENGTH, &js_str);

哎~ 怎么看起来也不是很简单嘛?。。。

别骂了,别骂了,要知道 Node.js 为了兼容不同版本付出了多大的努力吗?相对来说上述的 API 调用算是很简单的了,最重要的是它很稳定,基本不随着 Node.js 和 V8 的版本更迭而变化。env 是执行的上下文,js_str 是创建出来的 JS 字符串,NAPI_AUTO_LENGTH 是自动计算的长度,这里还隐含了一个变量,就是 napi_create_string_utf8 的返回值 napi_status,这个值一般平平无奇,但万一要是出了 bug 就得靠它来甄别各处调用是否成功了。

C++ addons 实战

前情铺垫结束,让我们拥抱改变吧。下面将带大家以一个简单的 Defer 模块的实现为例,走马观花式感受 C++ addons 的开发过程。

初始化项目

首先你得有个 Node.js,版本呢最好能到 14、16 以上,因为 N-API 有些部分在 14 的时候才加入或者稳定下来。

然后你得准备个 C++ 编译环境,下面简单介绍下各个系统下是如何操作的。

  • macOS: xcode-select --install 基本可以解决,后面会用 llvm 进行编译。
  • Windows: 啥都别说了,VS 大法好。推荐装 2019 Community 即可,然后记得把 v142 工具集装了就成。因为 node-gyp 会写死 VS 的版本号,所以如果出了问题就使用 VS installer 继续安装缺失的组件即可。
  • Linux: apt-get install gcc.

这样就准备好正式编译我们的 C++ addons 了。

node-gyp

通常情况下编译并链接 C++ 库是一件非常吃力不讨好的事,cmake 等工具的出现就是为了解决这个问题,而到了 Node.js 这一边,官方提供了同样的工具 node-gyp。只需 npm i node-gyp -g 即可,后续我们都将在 node-gyp 下操作。

VS Code 相关设置

我们可以在 VS Code 中设置 C++ 环境,这会给开发带来不少的体验提升。
https://code.visualstudio.com/docs/languages/cpp

这里是一份可参考的 .vscode/c_cpp_properties.json 示例:

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
{
"configurations": [
{
"name": "Mac",
"includePath": [
"${workspaceFolder}/**",
"/usr/local/include/node"
],
"defines": [],
"macFrameworkPath": [
"/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks",
"/System/Library/Frameworks",
"/Library/Frameworks"
],
"compilerPath": "/usr/bin/clang",
"cStandard": "c17",
"cppStandard": "c++14",
"intelliSenseMode": "macos-clang-x63"
},
{
"name": "Win32",
"includePath": [
"${workspaceFolder}/**",
"C:\\Users\\kimi\\AppData\\Local\\node-gyp\\Cache\\16.6.0\\include\\node"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE"
],
"windowsSdkVersion": "10.0.17763.0",
"compilerPath": "C:/Program Files (x86)/Microsoft Visual Studio/2017/Community/VC/Tools/MSVC/14.16.27023/bin/Hostx64/x64/cl.exe",
"cStandard": "c17",
"cppStandard": "c++14",
"intelliSenseMode": "windows-msvc-x64"
}
],
"version": 4
}

这段 JSON 最重要的就是要指向 node 头文件的 includePath,上面分别提供了当前 Node.js 安装版本和 node-gyp 缓存的路径,以供参考。

Hello world

凡事怎么少得了 hello world 呢?这里假设你已经装好了 node-gyp 了。

先创建一个 main.cpp,写入以下内容。

1
2
3
4
5
6
7
8
9
10
#include <node_api.h>

napi_value Init(napi_env env, napi_value exports){
napi_value hello_str;
napi_create_string_utf8(env, "hello", NAPI_AUTO_LENGTH, &hello_str);
napi_set_property(env, exports, hello_str, hello_str);
return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

创建一个 binding.gyp,写入以下内容。

1
2
3
4
5
6
7
8
{
"targets": [
{
"target_name": "native",
"sources": ["./main.cpp"],
}
]
}

然后执行 npx node-gyp configure build,不出意外的话会生成一个 build 目录,build/Release/native.node 就是我们所要的货了。

生成的原生模块

如何使用呢?很简单,打开 Node.js REPL,直接 require 就行。

1
2
3
4
Welcome to Node.js v16.6.0.
Type ".help" for more information.
> require('./build/Release/native').hello
< 'hello'

大功告成!

小试牛刀:开发一个简单的 Defer 模块

在学习新知识点时,以熟悉的概念切入会更有学下去的动力。我们就小试牛刀,先实现一个非常非常简陋只支持一个 Promise 调用的 Defer 模块吧!

先让我们看一下最终需要的调用方式,从 JS 侧看就是加载一个 *.node 的原生模块,然后 new 了一个对象出来,最后调用一下它的 run 方法。可能 JS 写起来 10 行都不到,但这次的目标是将 C++ 与 JS 联动,这中间的过程就有点让人摸不着头脑了。

简单的 Defer 模块

别慌,遇事不决先确定接口类型,类型就是编程中的量纲,分析量纲就能得出解题思路。

JS 接口类型定义

抛开语言的差异,来分析一下这个 Deferred 类,它的构造函数接受一个字符串进行初始化,然后有个 public 的 run 方法接受一个数字并返回一个 Promise,以这个数字所代表的毫秒数来延迟 resolve 所返回的 Promise。

1
2
3
4
class Deferred {
constructor(private name: string)
public run(delay: number): Promise<string>
}

咦,这么简单吗?是的,JS 本就为了开发效率而生,但事情整到 C++ 层面可就不那么简单了 …… 但天下大事,必作于细,良好的职责划分有利于用不同的工具切准要害,逐个突破,我们接着往下看。

划分 C++ 与 JavaScript 职责

JavaScript 与 C++ 各自的职责

为了 OOP,我们将数据和行为都存放在一起,这会带来一些问题,就是数据该由谁持有?如果 JS 持有数据,将 C++ 作为一个无状态的服务,每次都将数据从 JS 传过来,计算完了传回去,但这样会造成序列化的开销。如果 C++ 持有数据,JS 侧就相当于一个代理,只是把用户请求代理到 C++ 这一边,计算完再转发给用户侧。

实际情况是,一旦涉及到原生调用,C++ 持有的数据很有可能是 JS 处理不了的不可序列化数据,比如二进制的文件,线程 / IO 信息等等,所以还是 C++ 做主导,JS 只做接口比较好。但这样就不可避免地要从 C++ CRUD 一些 JS 对象了,接着往下走。

创建 C++ 类

激动的心,颤抖的手,终于开始写 C++ 代码了 …… 老规矩,还是先定义一个 class 吧。

1
2
3
4
5
6
7
8
9
10
11
#include <string>
#include <functional>
#include <node_api.h>

class NativeDeferred {
public:
NativeDeferred(char *str);
void run(int milliseconds, std::function<void(char *str)> complete);
private:
char *_str;
};

看起来和 JS 侧的代码也很像嘛,只不过换成了 callback 的方式。如何使它能在 JS 侧使用呢?

创建 JS class

napi_define_class

N-API 提供了各种直接创建 JS 对象的方法,包括字符串、数字、undefined 等基本量,也有函数和对象等等。擒贼先擒王,一上来就找到了用于创建 class 的 napi_define_class。读了一遍定义后,发现需要提供 napi_callback constructorconst napi_property_descriptor* properties 作为参数。又马不停蹄地找到了 napi_callback,这个函数是我们后面会经常遇到的。

napi_callback

napi_callback 接受一个 napi_envnapi_callback_info,前者是创建 JS 对象所必须的环境信息,而后者是 JS 传入的信息。

如何解读这些信息呢?有 napi_get_cb_info 这个方法。通过它可以读出包括 this 和各种 ArrayLike 的参数。

napi_get_cb_info

我们在讨论如何创建一个 JS 的 class 啊,这是不是绕太远了?等等,你提到了 this?有的面试题里会考如何手写一个 Object.create,难道这就是那里面默认的 this?你猜对了,这个 this 在通过 Function 创建时,在构造器里是用 v8 的 ObjectTemplate 来实例化一个 instance 的。(PS: 如果 napi_callback 是从 JS 侧调用,那它就是 JS 的那个 this。)

从 JS class 创建对象的话,这个 napi_callback 就是 JS 定义的 constructor,执行完返回 this 就行了,但既然是深度融合 C++ 的功能,我们当然还有别的事要做。

将 C++ 对象封装到 JS instance 上

前面声明了一个非常简易的 C++ 对象 NativeDeferred,我们要将它封装到刚创建的 this 上,返回给 JS 侧。为啥要这样做?因为前面提到了,我们要用 C++ 对象持有一些数据和状态,这些不便于在 JS 和 C++ 来回传递的数据需要一个可追溯的容器来承载(即 NativeDeferred),我们可以假设这个容器有两种存储方式:

  1. 全局对象,也就是 V8 里的 global,然后生成一个 key 给 JS instance。
  2. 挂到 JS instance 上(N-API 支持这种操作)。

很明显第一种方法不仅污染了全局对象,也避免不了 JS instance 需要持有一个值,那还不如直接把 C++ 对象绑到它上面。

从 napi_callback 中读出 C++ 对象

取出 C++ 对象的过程形成了 napi_callback -> JS Deferred(this) -> unwrap C++ NativeDeferred 这样一个线路,需要用到 napi_wrapnapi_unwrap 方法。

napi_wrap

这里又有个坑,finalize_cb 是必须要赋值的,而且它应该去调用 NativeDeferred 的析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void Destructor(napi_env env, void *instance_ptr,
void * /*finalize_hint*/)
{
reinterpret_cast<NativeDeferred *>(instance_ptr)->~NativeDeferred();
}

napi_value js_constructor(napi_env env, napi_callback_info info)
{
// 中间省略了获取 js_this 和 name 的步骤
NativeDeferred *deferred = new NativeDeferred(name);

napi_wrap(env, js_this, reinterpret_cast<void *>(deferred),
Destructor, nullptr, nullptr);
return js_this;
}

这样,我们就设置好了一个在 constructor 里会生成并自动绑定 C++ 对象的 JS class。

设置 JS class 上的调用方法

数据只有实际被使用才能发挥其价值,对应到 JS Deferred 上面,就是要让 JS 侧 run 方法顺利地调用到 C++ 侧的 run,这里面又要经历前面所说的从 napi_callback 一直到拿到原生 NativeDeferred 的过程,但如何让这个 napi_callback 可以被 Deferred 实例后的对象应用呢?

聪明的读者已经猜到了,就是将它设到 Deferred 这个类的原型链上,具体来说就是前面 napi_define_class 时的 const napi_property_descriptor* properties,我们来看一下它的定义。

napi_property_descriptor

napi_property_descriptor 上其他属性都比较常见,似乎跟 Object.defineProperty 有些相似,但 enumerableconfigurable 这些值呢?。我们注意到了 napi_property_attributes 这个参数,

napi_property_attributes

找到了找到了,这就是我们需要的属性了。

1
2
3
4
5
6
7
8
9
#include<node_api.h>

napi_property_descriptor runDesc = {"run", 0, js_run, 0,
0, 0, napi_default_method, 0};
napi_value js_class;
napi_property_descriptor descs[1] = {runDesc};
napi_define_class(env, "Deferrered", NAPI_AUTO_LENGTH, js_constructor,
nullptr, 1, descs,
&js_class);

*js_run 会在下一节实现。

上面的 js_class 就是我们一开始定义的 JS Deferred 了,将他 napi_set_property 到 hello world 中的 exports 上就能被 Node.js 访问啦。

这里还有个坑,napi_default_method 有些版本下是被定义在 if 里的,需要我们预先 define NAPI_VERSION 或者 NAPI_EXPERIMENTAL
NAPI_VERSION 需要 8 以上

让我们打开 binding.gyp,在 target 里加入以下内容,就可以啦。

1
2
3
4
5
6
{
"defines": [
"NAPI_EXPERIMENTAL",
"NAPI_VERSION=8",
],
}

C++ 回调 JS callback

到现在我们已经实现了一个 class 所需要的一切能力,但有个小问题:这些方法都是单向的从 JS 侧传递给 C++ 侧,或者反之,没有双向交互的部分。可以想一想怎样算是“双向交互”呢?就是 Node.js 常见的 callback 啊,我们还没有涉及到如何从 C++ 调用 JS 函数。napi_call_function这个函数就是 napi_get_cb_info 的逆操作了,把参数按个数和数组传递给函数指针。

napi_call_function

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 将这个函数 export 出去,使用时会以传入的第一个参数 args[0],判定其为函数传入 42 作为唯一参数进行调用
napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
napi_value args[1];
size_t argc = 1;
napi_get_cb_info(env, info, &argc, args, &js_this, nullptr);

napi_value num;
napi_create_int32(env, 42, &num);
napi_value res[1] = { num };
napi_call_function(env, js_this, args[0], 1, res, nullptr);
return num;
}

总结一下,我们目前总共实现了以下的 C++ addon 能力。

功能 实现
创建 JS class
给 JS class 添加 method
将 C++ 对象封装到 JS 对象上
调用 JS 函数

高级技巧

读到这里的朋友可能发现了,前面提到的 Deferred 还有一环没有实现,就是延时调用。来想一下 C++ 里如何能延时呢?可以另外启动一个线程,将它 sleep,可以简单写下代码。

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
#include <node_api.h>
#include <thread>
#include <functional>

static void thread_run(std::function<void()> complete) {
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
complete();
}

napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
napi_value args[1];
size_t argc = 1;
napi_get_cb_info(env, info, &argc, args, &js_this, nullptr);

napi_value num;
napi_create_int32(env, 42, &num);
napi_value res[1] = { num };

std::function<void()> complete = [=]() {
napi_call_function(env, js_this, args[0], 1, res, nullptr);
};
std::thread runner(thread_run, complete);
runner.detach();
return num;
}

但实际调用时,等了很久也没有触发,这是为什么呢?

JavaScript functions can normally only be called from a native addon’s main thread. If an addon creates additional threads, then Node-API functions that require a napi_env, napi_value, or napi_ref must not be called from those threads.

When an addon has additional threads and JavaScript functions need to be invoked based on the processing completed by those threads, those threads must communicate with the addon’s main thread so that the main thread can invoke the JavaScript function on their behalf. The thread-safe function APIs provide an easy way to do this.

Asynchronous thread-safe function calls

原来跨线程之后 napi_env 就不是原来的那个它了,我们需要按照 N-API 的方式来包装一下异步调用的函数。

线程安全调用

写到这里,笔者发现自己的功力已经不足以解释我所看到的文档了,直接上代码吧。

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
#include <node_api.h>
#include <thread>
#include <functional>

static void thread_run(std::function<void()> complete) {
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
complete();
}

static void thread_callback(napi_env env, napi_value js_callback, void* context, void* data) {
napi_value js_this = reinterpret_cast<napi_value>(context);

napi_value num;
napi_create_int32(env, 42, &num);
napi_value res[1] = { num };

napi_call_function(env, js_this, js_callback, 1, res, nullptr);
}

napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
napi_value args[1];
size_t argc = 1;
napi_get_cb_info(env, info, &argc, args, &js_this, nullptr);

napi_value async_resource_name;
napi_create_string_utf8(env, "foobar", NAPI_AUTO_LENGTH,
&async_resource_name);
napi_threadsafe_function thread_complete;
// 将 js 传来的 callback 调谐函数 thread_callback 一起传入生成线程安全的回调
napi_create_threadsafe_function(
env, args[0], nullptr, async_resource_name, 0, 1, nullptr, nullptr,
js_this, thread_callback, &thread_complete);

// 将线程安全的回调再包装成闭包
std::function<void()> complete = [=]() {
napi_call_threadsafe_function(thread_complete, nullptr, napi_tsfn_blocking);
};
// 真正放到另一个线程去执行
std::thread runner(thread_run, complete);
runner.detach();

return js_this;
}

napi_value Init(napi_env env, napi_value exports){

napi_value fire_str;
napi_create_string_utf8(env, "fire", NAPI_AUTO_LENGTH, &fire_str);
napi_value fire;
napi_create_function(env, "fire", NAPI_AUTO_LENGTH, fire_js_callback, nullptr, &fire);
napi_set_property(env, exports, fire_str, fire);
return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

经过一番眼花缭乱的操作后,终于成功触发了 args[0] 处的 JS callback 函数,这就是简化版本的 js_run 了。

Promise 的实现

既然实现了异步回调,我们再努力一把,实现 Promise 的返回值,这就比较简单了,N-API 将 napi_create_promise 设计为生成 napi_deferred* deferrednapi_value* promise,一式两份,一份直接返回给 JS,一份则留着在异步调用中将其 resolve。
我们只需稍微改写一下前面的代码即可。

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
static void thread_callback(napi_env env, napi_value js_callback, void* context, void* data) {
napi_deferred deferred = reinterpret_cast<napi_deferred>(data);

napi_value num;
napi_create_int32(env, 42, &num);

napi_resolve_deferred(env, deferred, num);
}

napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
size_t argc = 0;
napi_get_cb_info(env, info, &argc, nullptr, &js_this, nullptr);

napi_value async_resource_name;
napi_create_string_utf8(env, "foobar", NAPI_AUTO_LENGTH,
&async_resource_name);
napi_threadsafe_function thread_complete;
// 将 js 传来的 callback 调谐函数 thread_callback 一起传入生成线程安全的回调
napi_create_threadsafe_function(
env, nullptr, nullptr, async_resource_name, 0, 1, nullptr, nullptr,
nullptr, thread_callback, &thread_complete);

napi_value promise;
napi_deferred deferred;
napi_create_promise(env, &deferred, &promise);

// 将线程安全的回调再包装成闭包
std::function<void()> complete = [=]() {
napi_call_threadsafe_function(thread_complete, deferred, napi_tsfn_blocking);
};
// 真正放到另一个线程去执行
std::thread runner(thread_run, complete);
runner.detach();

return promise;
}

篇幅起见,只贴出关键的两个函数了。

完工

事已至此,与 Deferred 这个类相关的代码已经基本介绍完了,完整的代码可以参见这个仓库:
https://github.com/msyfls123/basin

启动工程应该只需要:

1
2
3
4
npm i
npm run configure
npx node-gyp rebuild --debug
npm run basin

C++ addons 调试与构建

别看前面洋洋洒洒一堆操作,只写出了百来行代码,基本每行代码都踩过坑。这时候强有效的调试工具就显得非常重要了。

VSCode CodeLLDB 调试

推荐大杀器 CodeLLDB,配合 launch.json 食用,可在 VSCode 中左侧 Run and Debug 里对 C++ 代码断点并显示变量信息。

CodeLLDB

简易 launch.json

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"version": "0.2.0",
"configurations": [
{
"name": "debug with build",
"type": "lldb",
"request": "launch",
"preLaunchTask": "npm: build",
"program": "node",
"args": ["${workspaceFolder}/index.js"]
},
]
}

断点信息

prebuildify 预构建包

前面都是开发模式,如果是服务端使用的话,加上入口 js 文件后已经可以作为 npm 包发布了,安装时会自动执行 node-gyp rebuild 重新构建的。
但如果是嵌入到某个 App,比如腾讯文档桌面端,或是 QQ 之类的客户端应用里,那就需要根据不同的系统和架构进行跨平台编译了。

常见架构有:

  • Linux: x64, armv6, armv7, arm64
  • Windows: x32, x64, arm64
  • macOS: x64, arm64

竟然有这么多 …… 还好社区提供了跨平台编译的解决方案 ———— prebuild,但它需要在安装时下载对应的包,所以还需要将这些构建产物发布到服务器上,不与 npm 包放在一起。虽然在包体积很大的情况下的确有必要,这显然不是我们所追求的一键下载。

然后我就找到了 prebuildify。它是这么说的:

With prebuildify, all prebuilt binaries are shipped inside the package that is published to npm, which means there’s no need for a separate download step like you find in prebuild. The irony of this approach is that it is faster to download all prebuilt binaries for every platform when they are bundled than it is to download a single prebuilt binary as an install script.

Always use prebuildify --@mafintosh

有没有成功案例呢?有,那就是 Google 出品的 LevelDB 的 js 封装就是它做的,

prebuildify 直接应用在 npm scripts

我们项目里也应用了这个方案,参见 https://github.com/msyfls123/basin/blob/main/package.json#L16-L18。

与 CI 集成

可是这虽然可以只在三个系统各执行一遍进行编译,但每次发布都得登录三台机器来执行吗?no, no, no, 我们当然可以将这一切集成到 CI 中自动运行。

这里展示一下业界标杆 ———— GitHub Actions 的配置:

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
name: Build

on: push

jobs:
build:
runs-on: ${{ matrix.platform.runner }}
env:
CXX: g++
strategy:
matrix:
platform:
[
{ runner: "windows-latest", command: "build:windows" },
{ runner: "macos-latest", command: "build:mac" },
{ runner: "ubuntu-latest", command: "build:linux" },
]
fail-fast: false
steps:
- name: Check out Git repository
uses: actions/checkout@v2

- name: Set up GCC
uses: egor-tensin/setup-gcc@v1
with:
version: latest
platform: x64
if: ${{ matrix.platform.runner == 'ubuntu-latest' }}

- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v2
with:
node-version: "16.6.1"

- name: Install Dependencies
run: |
npm i --ignore-scripts
- name: Compile
run: |
npm run configure
npm run ${{ matrix.platform.command }}

- name: Archive debug artifacts
uses: actions/upload-artifact@v2
with:
name: build
path: |
index.js
index.d.ts
package.json
prebuilds/

GitHub Actions

C++ addons 的展望

至此,本文也要进入尾声了,期望能对想要提升 Node.js 程序性能或是拓展应用场景的你带来一些帮助!最后提两点展望吧:

无痛集成第三方库

笔者看到项目里大部分第三方 C++ 库都是以源码形式引入的 …… 对于习惯 npm i 的人来说这肯定是像狗皮膏药一样贴在心上。听说 bazel 挺香,但其语法令人望而却步,似乎也不是一个依赖管理工具,这时有个叫 Conan 的货映入眼帘。

这里有篇文章讲述如何将 Conan 和你的 Node.js addons 结合,笔者试了一下确实可行,甚至都不需要 python 的 virtualenv,只是 libraries 需要小小的调整下:

1
2
3
'libraries': [
"-Wl,-rpath,@loader_path/"
]

编译目标:WebAssembly Interface?

居安思危,笔者也思考了下 Node.js addons 的局限性,需要每个平台都编译一遍还是有点麻烦的,有没有什么办法可以 compile once, run everywhere 呢?

有!那就是 WebAssembly,“那你为啥不用呢?”,这是个好问题。LevelDB 仓库内也有过类似的讨论,最后问题落到了性能和文件系统上,如果涉及到异步线程问题的话,会更复杂一点,因为 emccpthread 是基于 Web Worker 提供的,不清楚 Node.js 侧是否有 polyfill,以及在不同 Worker 运行,各种同步原语、Arc、Mutex 等是否都得到了支持,这些都是未知的。所以遇到一坨祖传下来打满了补丁的 C++ 代码,我们选择的稳妥方式依然是悄悄关上门,然后建座桥,把路直接修到它门口就跑,真刺激啊……

Electron 客户端自动更新

随着科技的进步,啊不,是网络通信技术的提升,客户端用户不再受限于拨号上网那样的小水管,百兆宽带触手可达,随时随地自动更新版本成为了标配。作为跨端框架的翘楚,Electron 自然也是内置了自动更新功能,但查阅其官网发现其提供的 autoUpdater 并无明确的操作路径可言,读完仍是一头雾水,尤其是还需要私有 CDN 部署时更是两眼一抹黑。

漫漫更新路

让我们从零开始,更新逻辑其实很简单,每次发版时将更新文件分发到 CDN 上,客户端通过检查 CDN 上有无更新文件继而下载文件并安装即可完成更新。抛开上传下载这种技术问题不谈,要解决两点:

  • 什么版本可以更新
  • 可以更新到什么版本

说人话就是从哪儿来,要到哪儿去。本文将要为你解答的就是如何通过一系列配置服务及本地设置,完成包含灰度更新、强制更新、静默更新以及 GUI 更新过程展示在内的可操纵动态更新策略。

发布与更新

首先确定一点,我们依然用的是 Electron 提供的更新功能,但主要用了 electron-builder 封装后的 electron-updater。这里的文档和 Electron 官方文档比较类似,有点啰嗦,下面就使用自定义 CDN 这条路提纲挈领地给大家梳理关键步骤。

生成制品信息

这里假定你一定是通过 electron-builder 进行打包了,需要在 electron-builder-config 中加入如下字段(详细字段配置

1
2
3
4
5
6
7
8
const config = {
...others,
publish: {
provider: 'generic',
channel: 'latest-win32',
url: 'https://your.cdn/update-path',
},
}

这里假设你的更新文件会被放在 https://your.cdn/update-path 目录下,通过这个配置打出来的安装文件就会多出一个 latest-win32.yml 文件,这个文件长下面这样子。

制品信息

这里面主要包含了版本号、更新资源文件的文件名及校验 hash 及发布日期等关键信息,对于后续步骤最重要的就是资源的文件名了。

将安装包与 yml 文件一起上传到 CDN 的 https://your.cdn/update-path 目录下就完成了生成制品信息的这一步。

配置自动更新信息

来到这一步需要保证 https://your.cdn/update-path/latest-win32.yml 已经是可以访问到的了,后面就是如何在端内把 electron-updater 支棱起来。

首先安装 electron-updater: npm i electron-updater
这里作者实操中有个问题,electron-updater 打包后失效了,暂未明确原因,故在 webpack 中将其设为 externals 并在最终由 electron-builder 打包的目录 projectDir 里安装了 electron-updater。

接下来,为了开发调试我们需要做一点骚操作,在下图所示目录中有 app-update.yml 文件。
mac app-update.yml
windows app-update.yml
这个文件里面内容是这样的,将它复制到项目根目录下并改名叫 dev-app-update.yml,后面就能调试更新了。

需要说明的是,macOS 上自动更新只有在签名后的 App 上才能进行,在后续步骤的退出并安装前会校验签名,校验失败时会报错。

自动更新

进入激动人心的代码环节!

1
2
3
4
5
6
7
8
9
10
11
12
13
import { autoUpdater } from 'electron-updater';

// 设置为指定发布版本,以防错读为 Electron 版本
autoUpdater.currentVersion = APP_VERSION;
autoUpdater.setFeedURL({
provider: 'generic',
channel: os.platform() === 'darwin' ? 'latest' : 'latest-win32',
url: `https://your.cdn/update-path`,
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});

对,就是这么简单,一旦下载更新完成立即以迅雷不及掩耳之势退出 App 进行更新并重启。这是不是太快了点?都没留给用户反应的时间了。别着急,可以通过 autoUpdater 上的各种事件,参考这篇文章做一个漂亮的更新界面出来。
https://blog.csdn.net/Wonder233/article/details/80563236

autoUpdater 事件:

  • error
  • checking-for-update
  • update-available
  • update-not-available
  • download-progress
  • update-downloaded

精细化更新

静默更新

如果把上面的退出更新步骤去掉,离静默更新就只差一步了。

1
2
3
4
+ autoUpdater.autoInstallOnAppQuit = true;
- autoUpdater.on('update-downloaded', () => {
- autoUpdater.quitAndInstall();
- });

至这一步为止,你已经做完了一个不断更新到最新版本的 Electron App 所需要的一切了。

强制更新

即使你设置了每次都会自动更新,依然免不了有用户不肯买账,或者说会在各种网络差的情况下没法及时更新到最新版本,我们可以通过下发一个配置文件,来控制一些有废弃 API 或者有严重 bug 的版本被继续使用。

例如在配置系统上生成一个如下的配置,其中 force_update_version_list 就是一串 semver 规范的版本范围。
配置字段

在使用时只需要判断一下 APP_VERSION 是否在这些个区间内即可。

1
2
3
4
5
6
import * as semver from 'semver';

const config = await fetchUpdateConfig(key);
const forceUpdateVersions = config.force_update_version_list;
const shouldForceUpdate = forceUpdateVersions.length &&
semver.satisfies(APP_VERSION, forceUpdateVersions.join('||'));

这里在发出拉取更新配置请求时出现了一个 key,这个 key 提供了本地去决定使用哪个配置组的能力,比如测试就填 Test,线上默认为 Production,方便测试。

灰度更新

强制更新解决了哪些版本必须更新的问题,如果我们只想让某些版本或是用户更新到指定版本呢?这也就是通常所说的金丝雀发布、A/B 测试之类的了。同样可以用从网络拉取一个配置文件来解决,正好内网配置平台也满足我们的这种需要。

配置下发

首先配置系统支持根据 uin、ip 等进行灰度发布,我们选择了将 uid 后两位截取为 uin 上传到灰度名单,配置系统拿到上传的 uin 后根据灰度规则(上图配的是 30% 的比例)下发最新更改的配置项。

逐天放开灰度比例

直至 100% 比例后,可以进一步替换官网链接,完成全量发布。

更新路径划分

聪明的读者已经发现了,在发布与更新中,我们设置了统一的更新目录 https://your.cdn/update-path,如果有不同的更新版本,我们就需要设置不同的文件或是目录来控制。该用哪一种呢?

版本排布方式 优势 劣势
按目录 一个目录一个版本 无统一更新地址
按文件 目录层级扁平 文件混杂难分清

综合考虑后,我们选择了按目录划分版本的方式。
版本
文件

在上面的自动更新代码中替换如下内容即可享受精细控制的灰度更新功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { autoUpdater } from 'electron-updater';

+ const config = await fetchUpdateConfig(key);

// 设置为指定发布版本,以防错读为 Electron 版本
autoUpdater.currentVersion = APP_VERSION;
autoUpdater.setFeedURL({
provider: 'generic',
channel: os.platform() === 'darwin' ? 'latest' : 'latest-win32',
- url: `https://your.cdn/update-path`,
+ url: `https://your.cdn/update-path/${config.version}`,
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});

尾声

按渠道分发

更新和下载一样是为了分发,当我们有了更多渠道时也可能需要考虑渠道间的差异性。渠道包可以通过配置文件进行区分,更新时只更新资源而不更新配置文件,这样就可以做到不同的安装渠道在同一更新下保持自身渠道特殊性。

永远递增你的版本号

The lastest, the best.

使用 RxJS 处理前端数据流

在前端开发中,虽然大部分时间都是在”接受用户操作数据将它们发送给服务器,再从服务器拉取数据展示成 UI 给用户“,但偶尔还是会有一些操作和显示不同步的情形,例如用户不停地在搜索框输入文字,那么在用户输入的时候其实是不希望一直去网络请求“建议搜索项”的,一方面会闪烁很厉害,一方面也会发出不必要的请求,可以用防抖 debounce 和节流 throttle 函数优化输入体验。

单个组件也许只需要加个 lodash 的纯函数即可,但遇到更复杂的输入情形会是如何呢?

实际问题

问题 1:简单识别 URL

识别 URL

如上图所示,左侧是一个 window,从其他地方将一个带有超链接的文本复制到剪贴板之后切换到这个 window,我们希望在 window 的 focus 事件发出时能够识别到这个链接并打开。

第一版代码可能非常简单,只需要用正则表达式判断一下即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// window.js
const URL_REG = /(?:(?:https|http):\/\/)?docs\.qq\.com\/\w+/;

function handleClipboardText(text) {
const matched = text.match(URL_REG);
if (matched) {
openUrl(matched[0])
}
}

thisWindow.on('focus', () => {
const text = clipboard.getText();
handleClipboardText(text);
})

问题 2:记忆已识别的文本

记忆已识别的文本

如果有多个窗口呢?希望能只在一个窗口触发一次,那就需要一个中心化的缓存值,缓存之前处理过的文本。

1
2
3
4
5
6
7
8
9
10
// main.js
let memorizedText = null;

export function checkIsMemorizedText(text) {
if (text !== memorizedText) {
memorizedText = text;
return false;
}
return true;
}

而相应的处理单个 window 的地方需要改成这样

1
2
3
4
5
6
7
8
9
10
11
12
// window.js
+ import { checkIsMemorizedText } from 'main';
const URL_REG = /(?:(?:https|http):\/\/)?docs\.qq\.com\/\w+/;

function handleClipboardText(text) {
+ const checked = checkIsMemorizedText(text);
+ if (checked) return;
const matched = text.match(URL_REG);
if (matched) {
openUrl(matched[0])
}
}

进阶问题:通过 HTTP 请求获取详细数据

这时产品觉得只拿到 URL 信息展示给用户没有太大的价值,要求展示成带有丰富信息的卡片格式,问题一下子变得复杂起来。

链接卡片

当然还是可以直接在每个 window 下去发起并接受 HTTP 请求,但这样代码就会变得越来越臃肿,该怎么办呢?

RxJS 实现数据流

这时就不满足于只是能简单处理时间间隔的 lodashdebouncethrottle 函数了,我们需要可以随时掌控数据流向和速率,并且具有终止重试合并等高级功能的工具。

RxJS 作为反应式编程的翘楚映入我们的眼帘,这里简单引用一下官网的介绍。

RxJS is a library for composing asynchronous and event-based programs by using observable sequences. It provides one core type, the Observable, satellite types (Observer, Schedulers, Subjects) and operators inspired by Array#extras (map, filter, reduce, every, etc) to allow handling asynchronous events as collections.

“Think of RxJS as Lodash for events.”

具体教程和 API 文档可参见官网,https://rxjs.dev/guide/overview。
以及本人严重推荐程墨老师的这本《深入浅出RxJS》,可以说把基础知识和实践应用讲透了。

下面的内容需要读者对 RxJS 有基本的了解。

创建数据流

我们创建了一条最基本的数据流 textInClipboard,它是所有后续操作的源头。从技术角度讲,它是一个 Subject,也就是作为触发器接受数据,也能够作为 Observable 向 Observer 发送数据。

1
2
3
4
5
6
7
8
9
const textInClipboard = new Subject();

export function checkUrlInClipboard(windowId) {
const text = clipboard.getText();
textInClipboard.next({
text,
windowId,
});
}

上面的代码创建了 textInClipboard Subject,并创建 checkUrlInClipboard 函数,在其中将当前剪贴板里的值传递给 textInClipboard,这样在 window 侧只需要调用这个方法就可以触发后面的一系列数据操作了。

1
2
3
4
5
thisWindow.on('focus', () => {
const text = clipboard.getText();
- handleClipboardText(text);
+ checkUrlInClipboard(text);
})

数据去重

创建完了接受用户操作的数据流之后,就需要对输入做去重,连续触发多次(例如用户在多个窗口间切换并不会连续识别 URL,而是只识别不同的第一个

去重

这在 RxJS 中非常容易实现,可以使用 distinctUntilKeyChanged 运算符。

加上常用的 filtermap,我们就组合出了一套简易过滤有效 URL的管道,将上面的 textInClipboard 灌进去试一试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { map, filter, distinctUntilKeyChanged } from 'rxjs/operators';

const URL_REG = /(?:(?:https|http):\/\/)?docs\.qq\.com\/\w+/;

const filteredUrlInClipboard$ = textInClipboard.pipe(
distinctUntilKeyChanged('text'),
map(({ text, ...rest }) => {
const matched = text.match(URL_REG);
if (matched) {
return {
url: matched[0],
...rest,
}
}
}),
filter(Boolean(e?.url)),
);

filteredUrlInClipboard$.subscribe(console.log);

演示一下。

发送 HTTP 请求

处理完了重复的文本,下面就该将筛选出的 URL 通过 HTTP 请求去获取详细信息了。

现在前端通常使用 fetch 直接发起 HTTP 请求,得到的是一个 Promise,如何将 fetchRxJS 有机结合起来?RxJS 自身提供了 from 创建符,将一个 Promise 转变成 Observable 是非常容易的。

1
2
3
import { from } from 'rxjs';

const fetch$ = from(fetch(someUrl));

但这里我们对程序的可维护性和健壮性提出了更高的要求:

  • 同时支持多个 HTTP 请求,并且将它们放在一个 Observable 里处理。
  • 支持 HTTP 请求的错误重试及 log 功能。

mergeMap 拍平请求

针对第一个要求,可以使用 mergeMap 来将 URL 一一映射成 fetch 得到的 Observable,因为是在一个 Observable 里创建出的 Observable,所以是高阶 Observable,再将这些高阶 Observable 收集起来变成 Observable 吐出的一个个值,就成为了 docInfo$ 的新 Observable,其中每一个值都是从 HTTP 请求返回的文档信息。

1
2
3
4
5
6
7
8
9
10
import { from } from 'rxjs';
import { mergeMap } from 'rxjs/operators';

const CGI_URL = 'example.com/get-info';

const docInfo$ = filteredUrlInClipboard$.pipe(
mergeMap(url => from(
fetch(`${CGI_URL}?url=${encodeURIComponent(url)}`)
))
);

其实 mergeMap 原来叫做 flatMap,是不是更有拍平摊开的意味?

带有重试功能的请求

RxJS 里的 Observable 如果出错了,默认是直接在当前 Observable 发出 error,并且终止,意味着一次失败的请求后面的请求将永远不会被发出了,这肯定不是我们希望看到的。首先我们得接住这个爆出来的 error,可以用 catchError 操作符。接下来考虑网络不稳定的情形,添加自动重试逻辑,这里会用到比较多的操作符,先将示例代码展示在这里,感兴趣的同学可以自行研究 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
import { defer, timer, NEVER, throwError } from 'rxjs';
import { retryWhen, concatMap, mergeMap, catchError } from 'rxjs/operators';

const CGI_URL = 'example.com/get-info';

const retryThreeTimesWith500msDelay = retryWhen(errors => ( // <-- projector
errors.pipe(concatMap((e, i) => {
console.error(`第 ${i + 1} 次失败`, e.toString());
return i >= 3 ? throwError(e) : timer(500);
}))
));

const docInfo$ = filteredUrlInClipboard$.pipe(
mergeMap(url => (
defer(() => fetch(`${CGI_URL}?url=${encodeURIComponent(url)}`)).pipe(
retryThreeTimesWith500msDelay,
catchError((error) => {
console.error(`获取 url=${url} 的信息失败`, error.toString());
return NEVER;
})
)
))
);

这段代码做了很多事情,retryThreeTimesWith500msDelay 里的 retryWhen 接受一个 projector 函数,传入的 errors 是一个 Observable,它在上游每次报错时吐出一个值 e,这里可以拿到 e 和索引 i,而这个 projector 返回的 Observable 一旦发出值就会重新 subscribe 上游 Observable(相当于再来一次),而当它报错时,这个错误将会抛给上游 Observable 并完结,是不是听的一头雾水?看看 GIF 图吧。

然后特别需要重点注意的是,这里替换掉了 from 创建符,而使用 defer 代替,为什么?

因为 from 是 hot observable,也就是无论有么有被订阅都会自顾自发出值,并且再次订阅后也不会重复发出已有值,就像直播一样。
而 defer 则像是点播,它是 cold observable,每次被订阅都会重新走一遍流程。

这点非常重要,所以在需要重复操作的地方还是需要 defer 来重复创建可利用的 Observable。

而在最后我们 catchError 里,处理掉错误信息并打 log 后,直接返回 NEVER,也就意味着这个错误将消失在漫漫长河里,不会对下游造成影响。

接受 HTTP 请求的结果

一般而言,订阅一个 Observable 只需要 subscribe 即可,但这里出了一点小小的问题,还记得上面传入 checkUrlInClipboard 的参数 windowId 吗?我们需要给不同的 window 订阅不同的 URL 检查结果。如果同时 subscribe 一个 Observable 多次会发生什么?

答案是 subscribe 几次中间的 pipe 过程会走几次,这与我们所期望的不一致啊,总不能为了每个 window 发一次 HTTP 请求?

甚至可能出现多个请求返回结果不一致的情形,那就乱套了。

事实上前面所用的 RxJS 操作符都是单播,也就是一对一,如果要一对多的话需要用到 multicast,但其实还有先后订阅的问题,这里就不展开了,可以参见这篇文章。我们直接使用 share 来共享这里的 HTTP 请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
+ import { share } from 'rxjs/operators';
const docInfo$ = filteredUrlInClipboard$.pipe(
mergeMap(url => (
defer(() => fetch(`${CGI_URL}?url=${encodeURIComponent(url)}`)).pipe(
retryThreeTimesWith500msDelay,
catchError((error) => {
console.error(`获取 url=${url} 的信息失败`, error.toString());
return NEVER;
})
)
)),
+ share(),
);

加完以后可以清楚地发现 HTTP 请求只发送了一次!至此我们已经完美地按照产品的需求完成了「多 window 并发检查剪贴板中 URL 数据」的开发,整个流程使用 RxJS 划分地整整齐齐。在 window 调用处只需要简单地调用 checkUrlInClipboard 并订阅相应的 docInfo$ 即可轻松接入剪贴板监听功能,so easy。

1
2
3
4
5
6
7
8
9
10
thisWindow.on('focus', () => {
const text = clipboard.getText();
checkUrlInClipboard(text);
});

docInfo$.subscribe((info) => {
if (info.windowId === thisWindow.id) {
openDialog(info);
}
});

拓展边界

如果这时产品经理告诉你又有新功能了,需要支持进程间共享剪贴板状态,WTF!如果平时可能就骂娘了,但 RxJS 基于流和操作符的特性拯救了所有的不开心,因为肯定有个 master 进程,只需要在 master 存一份检查过的 URL 文本就可以啦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const filteredUrlInClipboard$ = textInClipboard.pipe(
- distinctUntilKeyChanged('text'),
+ mergeMap((payload) => (
+ from(checkIfUrlChangedInMaster(payload.url)).then(isChanged => isChanged ? payload : null)
+ )),
+ filter(Boolean),
map(({ text, ...rest }) => {
const matched = text.match(URL_REG);
if (matched) {
return {
url: matched[0],
...rest,
}
}
}),
filter(Boolean(e?.url)),
);
  • checkIfUrlChangedInMaster 是与 master 通讯的异步方法,如果检查与前一次不同则返回 true,否则返回 false。

RxJS 的意义

这里给出上面各种操作的示例代码,可以实际修改并操作感受下 RxJS 的魅力。

https://stackblitz.com/edit/rxjs-check-clipboard-demo?file=index.ts

RxJS 单向数据流的设计符合函数式编程、纯函数单一输入输出无副作用的趋势,其强大的根据时间参数操作变量的能力给予了前端在处理并发事件时从容不迫的信心。

最后放张老图~

Electron 开发实践

前言

首先介绍一下腾讯文档桌面端应用,以下简称桌面端,其通过嵌入 web 端腾讯文档应用并利用 Electron 封装本地系统接口的能力实现了独立分发的桌面端 App,兼顾了 macOS 和 Windows 两大操作系统,借此实现了腾讯文档的全端覆盖。

两大平台,一个月时间,我们是如何做到从技术选型到项目上线的呢?

这也太标题党了,跟市面上流传甚广的 21 天精通 C++ 简直一模一样。

我们都知道罗马不是一天建成的,如果把软件开发比作建一座城市的话,我们的的确确在一个月时间内造出了腾讯文档桌面端应用。肯定有人要问了,为何是一个月时间呢?为什么不是半个月,三个月亦或是半年时间?

事实上,这也是我们一开始进行技术选型和开发规划时所考虑的问题,因为选择了使用 JavaScript 及 Web 技术开发客户端,就注定了与 web 开发息息相关,包括迭代周期和开发顺序等方面,web 端腾讯文档的发布周期是一周两次发布,在一个月时间内差不多可以交付一系列完整的 API,这样可以做到桌面端与 web 端并行开发,最终整合成一个整体。如果等到半年时间才交付了桌面端,这时 web 端应用的 API 和 JSBridge 等接口规范都可能随之发生改变,容易造成返工甚至二次开发。

下面,我们将从四个方面介绍腾讯文档桌面端开发实践内容:技术选型、DevOps 工程化实践、混合式开发基础建设和跨端统一用户体验。

技术选型

竞技场耸立,罗马屹立不倒;竞技场倒塌,罗马倒塌;罗马倒塌,整个世界都会崩溃。 ——圣徒比德

技术选型与古罗马的竞技的核心并无二致,都在于选优拔萃。而我们做技术选型的目的则不在于观赏,在于为了今后的开发找到正确的方向。

首先是总体的开发框架选择,结果是没有疑问的,我们选择了 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 作为我们的单测框架,它的优势是一套配置,根据文件目录分发到两种执行环境下运行,也就是前面提到的主进程和渲染进程。并且具有代码无侵入,配置简单,速度飞快等特点。

从下面的图可以看到,运行全部 200 多个用例仅耗时不到 30s,方便开发时快速验证功能完备性。

DevOps 工程化实践

然后是我们的 DevOps 实践。为什么要协作呢?一个团队单打独斗不舒服吗?因为不同团队不同开发人员间基础能力有差异,倒不一定是体现在技术能力,而是技术侧重点不一样。DevOps 则提供了平台赋能,将各个能力项拉齐到统一水准。就像罗马士兵拥有了统一的装备,将人变成了战士。

同时要注意到的是选择协作工具时不仅仅要考虑当下,也要考虑系统的伸缩性,为未来的发展壮大留有余量。

最终实现了”把控代码质量”,”托管构建过程”和”运行时保障”这三大目标。

把控代码质量:

静态代码分析、ESLint 扫描、圈复杂度扫描、重复代码扫描、单元测试、自动化测试

托管构建过程:

自动构建托管、自动签名认证、自动发布、自动转工单

运行时保障:

配置下发、灰度开关、自动化故障上报、日志监控、性能监控

混合式开发基础建设

前面讲到的都是外在条件,但文档内在是 web 项目。我们需要设想一下,对于一个 web 项目而言,包括 HTML、CSS、JavaScript 和其他媒体文件等等都是外部资源,如何建设好应用在于如何利用好外部资源。

如果说将 web 应用改造成桌面端应用是建一座城的话,那么将外部的能力引入到应用内的混合式开发基础建设就像是建造罗马水道一样。将水源从山脉中引流到城市里供人饮用、灌溉农田菜圃,再将污水输送出城市,完成城市的资源循环。

让我们先来看看这里都有哪些系统和外界网络提供的能力呢?比如本地的原生数据库,文件系统里存储的数据文件,服务器的计算资源和静态文件 CDN 等。

本地资源:数据库、文件 IO 和 JSBridge

首先是本地的资源,我们经过反复比较,最终选择了最高效的 LevelDB 作为底层数据库实现,它是由 Google 开发的 KV 数据库,具有很高的随机写,顺序读/写性能,同时原生数据库也给于客户端程序更多的操控权。我们在其上封装了包括多库多用户管理,请求指令封装、分发日志上报等能力,通过 electron 提供的基于 scheme 的渲染进程 URL 请求拦截,以及主进程 webContents 通过 executeJavaScript 向渲染进程执行脚本,实现了 JSBridge,将包括上面提到的 LevelDB 以及 electron-store 等存储能力引入到了 web 端,同时通过 Node.js 自带的 fs 模块将文件 IO 能力提供给桌面端应用。

外部资源

然后要提到的是外界网络能力,包括服务器和静态文件 CDN 等。

这两种能力都是通过 web 技术实现的,腾讯文档目前 web 上已经实现了有限的离线编辑能力,比如自动缓存增量编辑操作,进行版本冲突处理和提交等,在静态文件上正在开发基于 PWA 的离线缓存方案。同时因为是桌面端,前面提到的 LevelDB 是通过拷贝二进制可执行文件到发布资源中来实现分发的,所产生的问题是对不同系统需要分发不同的二进制文件,或带来工序上的复杂和计算资源的浪费,未来对类似需求可能考虑 WebAssembly Interface 来做跨端分发可执行文件。

跨端统一用户体验

既然是应用开发,用户体验是重中之重。如何在跨端情况下保证用户体验的统一性,需要我们制定一系列的规范,像同时期的罗马和秦帝国一样,立国之初就统一了包括法典、文字度量衡等规范,这大大地有利于内部进行交流协作,在处理差异性问题时有据可循。

弹窗

以简单的一个系统设置弹窗为例,在设计规范中 Windows 和 macOS 上样式实现是不一致的,弹框的边框则是都采用了系统样式,但 Windows 同时需要定义标题和关闭按钮,而 macOS 则沿用了系统的红绿灯样式,同时考虑到代码一致性手动实现了标题部分。在内容部分,macOS 考虑到与系统 UI 一致,手动实现了弹窗内 tab 切换,主体内容则是基于 DUI 实现两端共用。这里带来的问题是,开发往往只会在一台机器上开发,如果开发需要每次都打包分发到另一个平台看效果也太麻烦了,可以通过加开关的形式进行调试。在完成对弹窗的封装以后,我们可以基于 BrowserWindow 和 React 对其进行统一的生命周期管理,保证同一类型弹窗只显示一个。

安装与升级

然后是安装与升级,Windows 是覆盖安装,mac 是拖拽安装,Windows 可自定义安装前后行为,例如安装完写入注册表,卸载后清理用户数据,mac 版则利用了系统的静默升级。而 electron-updater 则让两者都实现了自动下载并一键升级的功能。

更多桌面平台特性

我们在开发过程中还遇到了更多的桌面特性,比较顺利的是 Electron 和 electron-builder 帮助实现了非常多的系统功能如:文件打开方式关联,QQ 消息链接自动打开 App 并打开文档,监测剪贴板链接自动打开在线文档等等。而其中不方便实现的则是全局的 Web UI 容器,因为 Electron 自带的系统 UI 控件非常少,也大多不符合 UI 规范,需要自定义 UI 界面只能通过打开渲染窗口并加载 HTML 文件的方式。如图所示的全局 toast 在项目中应用非常广泛,如何将其与 React DUI 组件进行共用呢?

命令式创建 Web UI 组件

首先要明确的是我们肯定是通过打开 BrowserWindow 窗口加载 html 来展示 UI。一种比较常见的思路是命令式创建 web UI 组件,比如创建 DialogManager 来统一管理多个 dialog,但这里的问题是有多少个 dialog 就需要多少个 dialog.html 文件,因为它们都是编译时就确定的,即使通过 url 分发,也必须至少创建一个 dialog 文件才能打开窗口。

声明式创建 Web UI 组件

React 提供了声明式创建组件的方式,我们可否通过其创建组件呢?通过调研,我们发现了 React 的 createPortal 函数是可以将 React 组件挂载到新创建的 window 里去的,那么我们只需要定制新创建 window 的参数就可以实现无边框窗口加载 React DUI 组件的功能。即实现了利用 React 管理窗口的生命周期。

  • window.open 并通过 Electron 拦截定义新创建 Chromium 窗口

  • React.createPortal 将组件(如

挂载到新创建的窗口内

  • 利用 React 管理窗口的生命周期

这样可以把唤起 Web UI 的职责交给常驻后台的隐藏渲染窗口 webComponent,在其中自定义组合各种各样丰富的 React DUI,通过 React 进行统一管理,后续可以几乎零边际成本增添新组件,同时在组件与进程频繁交互时也方便通过组件树找出对应关系进行维护。

我渲染故我在,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 的。