Electron 应用整洁架构

一个好的架构是具有良好用户体验的 App 的必备素质,反而言之,一个差劲体验的 App 一定是架构混乱甚至没有架构的。一坨一坨互相依赖,单例漫天飞的代码,足以让任何一个开发满嘴跑 F**K。

什么是好的架构?众说纷纭,但至少类似洋葱模型的中心化组织形式比较符合心智模型。设想一下,每天睁开眼睛,第一件映入脑海的事可能是下列这些问题:

  • 我是谁?我是一个程序员,我每天需要写代码赚钱……
  • 这是哪?这是我家,租来的房子,离公司骑车 15 分钟,所以我得赶紧起床了
  • 看看今天有啥新鲜事?拿起手机,把通知和各个 IM 应用点一遍
  • 开始新的一天吧?打开水龙头洗漱,出门扔垃圾,扫共享单车去上班

而这些想法和行为正好对应了 Clean Architecture 里的 EntitiesUse CasesInterface AdaptersFrameworks and Drivers。我是实体,我每天根据情景有要做的事,我接受的信息有原始的自然信息(如鸟鸣虫叫)也有经过加工的人工信息(明星八卦、国际局势),最后和社会的水电交通等公共设施打交道。任何一种好的架构都应该符合普通人的基本认知才能绵延不息。

Clean Architecture

光说不练假把式,同样以 Electron 框架为例子。为啥总要举它作为栗子呢?因为 Electron 天生具有 C/S 架构,同时又是客户端应用,可以说在单个体上参杂了前端、后端和客户端三种不同关注点的开发视角,所以大多数 Electron 应用往往都是一个大窗口套一个 web,主进程简单做了个数据层就完事。这种情况下既不需要架构,也不需要优化,只需要把代码堆积起来就完事了。

但时间一长,各种问题就粗来了:

  • class 没有统一初始化的地方,基本是直接 export new 或者单例模式
  • 功能堆砌纯线性组织,一条长长的 main 函数把所有的功能都包含了
  • preload 脚本需要 ipcRenderer.sendSync 通讯,同时 ipc 可能遇到跨 frame 通讯问题
  • 单元测试困难,Electron 模块随处可见,需要多次 mock

没办法,不想脱裤跑路就重构呗。天不生 Nest.js,Electron 架构万古如长夜!经过近两年的苦苦摸索,在尝试了至少三种框架的基础上,终于提炼出了用 Nest.js 框架良好组织 Electron 应用的方法,且听我缓缓道来。

框架特性

每个框架都会把自己的杀手锏特性都排在官网最前面,比如 React 是组件式,Vue 是渐进式,Angular 是让你有信心交付 web app,Svelte 是消失的框架(无运行时 lib),而 Nest.js 几乎是把渐进式、可靠性、扩展性等等都包圆了。实际使用下来,可以说是跟 Angular 一样的总包框架,可以管理一个 App 从启动到关闭整个生命周期。同时它也借鉴了 Angular 很多的创新点,比如依赖反转注入,装饰器模块,五花八门的中继器,以及强大的可扩展性,这也是我们得以将其应用到 Electron 项目的关键。

对于纯命令行程序,我们可以直接一个函数从头执行到尾再退出 App,而对于任何 GUI 程序,通常的解决方案都是以事件循环阻塞整个进程直至收到退出 SIGNAL,但这带来一个问题,如果进程被阻塞了,后续所有操作都无法得到响应。Node.js 的异步事件循环完美地解决了这个问题,在此基础上设计了基于消息的一系列运行机制,很好地处理了成千上万的请求。既然作为服务器都顶得住,那单机应用区区几个请求的并发更是不在话下。

我们设想这样一个应用:

  • 它具有处理自定义请求事件的能力
  • 它支持中继并更改请求,也就是中间件的能力
  • 它可以分析模块依赖并自动按序加载
  • 它可以加载外部网页并与之通讯,同时也可以与宿主机的其他应用通讯

这是一个跨端的带界面的服务器。

依赖反转

依赖反转

这个特性可太重要了,可以说没有它就没有后续所有的模块设计。每个上古项目都有一堆不明所以的头文件或者配置文件,或者不知从哪引入的构建脚本,又或是一串 token 或密码,这些东西都应该被管理起来。

我们需要一个简单的心智模型,所有第三方内容都是输入资源,不管是代码还是配置,webpack 就是把所有的内容都当作资源对待。但 webpack 在打包时也支持给 import 加参数,比如指定图片内嵌为 base64 的最大体积。支持输入参数也是我们的模块建设目标之一。

同时,webpack 也支持拆分模块,延迟至使用时加载,这一点更是杀手锏。因为客户端的生命周期一般比网页要长得多,但对体积相对不敏感,使用一些 preload 技术可以不在影响用户正常使用的情况下在快速响应页面和保证功能随时可用间达到平衡。使用 Nest.js 的 useFactory 可以轻松将模块变为 import(...) 延迟加载并且在使用时只需从 constructor 进行注入,避免全局的 lazy import 污染。

模块存在的意义就是复用,虽然 web 上各种框架的教条都是不要复用组件,多用组合而不是嵌套继承,但实际上数据和 UI 就是不同的组织形式。我们可以精确定义每个模块的使用究竟是全局唯一,每次实例化使用一个新的,还是每次调用就直接新建一个。

参考:

路由模式

组件是前端的组织方式,而路由则是后端的组织方式。

路由模式和消息监听机制很像,区别是路由是编译时即唯一确定的,而消息监听则往往是运行时动态注册。这里动态注册的消息机制带来了两个严重的问题:

  • 首次加载时无法确定消息处理的顺序,往往需要等待一个消息监听器绑定之后再触发事件,要是没监听上就丢失了。
  • 消息监听可以放在全局,或者跟随模块。前者污染了全局环境,后者层层嵌套之后难以确认依赖层级。

既然消息机制具有如此多的不稳定性,我们还是更倾向于跟服务器一样一次性注册所有路由。

不管是 HTTP 还是 IPC,甚至系统菜单,都可以作为路由端点。我们也可以用自带的 EventEmitter 在应用间不同路由间穿梭。下面这段代码就演示了一个简单的创建或更新对象的路由端点,可以看到路由的组件由装饰器标识,在函数参数里通过装饰器来捕获不同的参数,经过 this 上的数据库及 logger 对象,完成了数据的持久化和日志功能,最后把创建结果以 JSON 格式返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Post('/:id')
public async upsert(
@Param('id') id: string,
@Param('name') name: string
@WebContents() webContents: WebContents,
) {
this.logger.log(`Got message from webContents-${webContents?.id}`);
const db = this.database;
try {
const doc = await db.get(id);
return db.put({
_id: id,
_rev: doc._rev,
name,
});
} catch (err) {
this.logger.error(err);
return db.put({
_id: id,
name,
});
}
}

装饰器-decorator

大家肯定也看到上面代码中四个大大的 @,这就是 JavaScript 中的装饰器语法。只是可惜的是 parameter decorator 最终没有进入 stage 3,也就意味着 Typescript 以后肯定会对这个调用方式进行更改,不过这件事还没有尘埃落定,加上这么大一个框架,根本不用担心。

让我们庖丁解牛一下“装饰器”究竟是个什么东西?装饰器的重点自然是在装饰上,也就是给被装饰的类、函数方法、参数等实现一个不同的外观,从而在程序运行起来时以指定的方式调用/生成被装饰的对象。这句话读起来还是很别扭,实际上装饰器是元编程的一种形式,也就是不改变代码结构的前提下给代码走不同路径的方式,可以理解成打补丁。

举几个例子,比如常见的 debounce 和 throttle 方法,它们接受一个函数,返回防抖和节流后的函数版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

function debounce(fn, timeout) {
let timer = null
return function(...args) {
if (timer) {
clearTimeout(timer)
timer = null
}
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, timeout)
}
}

function log(data) {
console.log(data)
}

class Logger {
debounceLog = debounce(log, 1000)
}

无装饰器版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

const debounce = (timeout: number) => (value: any, context: ClassMethodDecoratorContext<any>) => {
let timer: undefined | NodeJS.Timeout = undefined
context.addInitializer(function () {
const fn = this[context.name]
this[context.name] = (...args: any[]) => {
if (timer) {
clearTimeout(timer)
timer = undefined
}
timer = setTimeout(() => {
fn.apply(this, args)
timer = undefined
}, timeout)
};
});
}

class Logger {
@debounce(1000)
log(data: any) {
console.log(data)
}
}

装饰器版本

可以看到装饰器版本将业务逻辑内聚成了一个函数,而不是必须要用函数套函数的方式。让代码分层有助于隔离关注部分,我们通常会把路由的执行逻辑和路由的路径给绑定在一起,但又不想变成每个路由端点都是一个类,这就是装饰器常见的应用场景了。

HTTP类

Nest.js 自带了很多 HTTP 服务的默认装饰器,对于绝大多数场景而言已经足够了。支持 HTTP 不同的请求类型,从请求中获取数据,并最终将内容用模版拼合并返回。

  • 路由:@Get / @Post / @Sse
  • 参数:@Param / @Header
  • 响应:@Render

自定义类

  • 获取参数:@WebContents
  • 装饰路由:@IpcHandle, @IpcOn

除了 HTTP,Electron 应用还内置了一种消息通讯机制,就是客户端熟知的 IPC(Interprocess Communications)。它的实现可以有很多方式,不同平台也会有不同的实现,但 Electron 的特殊点在于 IPC 两端绑定的不是进程或者 socket 连接,而是 web 页面。

Web 页面承载了实际展示给用户的 UI 内容,它本身存在于一个个渲染进程里,主进程里可以认为只是它的一个替身,但实际运用中往往需要拿到这个替身的各种状态。

既然是统一了 HTTP 和 IPC 通讯方式的框架,我们将会用装饰器抹平两者的差别,开发时可以自由选择通讯方式的同时也能支持拿到相应的请求源对象。

通讯方式 优点 劣势
HTTP 无需向 web 注入代码 无法自然获得 web 页面句柄
IPC 传输 JSON 无需序列化 需要向 web 注入对象

这里面会分别用到 Nest.js 的 Custom Transport,以及 Electron 的 WebRequest 模块。

  • Step 1: 通过 @nestjs/microservices 的 EventPattern, MessagePattern 组合出 IpcInvoke 和 IpcEvent 装饰器(给路由用)
  • Step 2: 通过 session.protocol.registerSchemesAsPrivileged 放通自定义 SCHEME
  • Step 3: 在 WebRequest 拦截自定义 SCHEME(HTTP)请求头注入 webContentsId
  • Step 4: 在 CustomTransportStrategy 绑定所有 Step.1 装饰过的路由响应 IPC listener,并在 handler 中传入 event 对象作为 data
  • Step 5: 创建 WebContents 装饰器。通过 @nestjs/common 的 createParamDecorator,根据 ctx.getType == 'http' || 'rpc' 来返回位于 req.headersRpcArgumentsHost.getData 中的 webContents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 路由装饰器 */
import { HandlerType } from '~/common/constants/meta';

import { applyDecorators } from '@nestjs/common';
import { EventPattern, MessagePattern } from '@nestjs/microservices';

export const IpcInvoke = (channel: string) => {
return applyDecorators(
MessagePattern(channel, { handlerType: HandlerType.InvokeMessage }),
);
};

export const IpcEvent = (channel: string) => {
return applyDecorators(
EventPattern(channel, { handlerType: HandlerType.Event }),
);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 参数装饰器 */
import { IRequest } from '~/common/interfaces/electron/request';

import { ExecutionContext, createParamDecorator } from '@nestjs/common';

export const WebContent = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
if (ctx.getType() === 'http') {
const request = ctx.switchToHttp().getRequest<IRequest>();
return request.webContents;
} else if (ctx.getType() === 'rpc') {
return ctx.switchToRpc().getData().event.sender;
}
},
);

使用装饰器可以让我们更轻松地将不同的路由与 Electron 框架进行交互,不管是注册事件监听模拟路由还是从请求中解出需要的源数据,都很方便。

中间件-middleware

如果说装饰器是完成了路由的网状组织最后一块拼图的话,那中间件就是为数据的流动接上了一长条管道。

Nest.js 中的中间件有三种,Middleware,Interceptor 和 Exception filters,甚至 Pipes 和 Guards 也可以算作中间件。它们本质都是对请求和响应作一定的处理,来满足包括鉴权,错误处理,数据转换等等功能。

这是 Stackoverflow 上对不同中间件的总结。
https://stackoverflow.com/questions/54863655/whats-the-difference-between-interceptor-vs-middleware-vs-filter-in-nest-js

这张图很好地展示出了不同中间件在一个请求的生命周期中的作用。

简单实现一个针对特殊路由对 JSON 路由返回结果,将 data 包了一层再返回的中间件。

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
import { Observable, tap } from 'rxjs';

import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RequestInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
const handler = context.getHandler();
const isJSON = this.reflector.get('json', handler);
if (isJSON) {
return {
res: 0,
data,
}
}
return data
}),
);
}
}

import {
SetMetadata,
Post,
Param,
} from '@nestjs/common/decorators';

class Controller {
@Post('create')
@SetMetaData('json', true)
create(@Param('name') name) {
return this.db.create({ name })
}
}

Web 后端服务直接将端口暴露在网络上往往需要很多的安全校验以及错误处理,实际上客户端代码是自主维护的,风险相对可控,所以不会需要太多的校验工作,只是对一些涉及到 C++ 调用时,因为数据类型需要转换,比如只接受整数,传入浮点数会导致类型转换失败甚至程序崩溃。中间件一些应用场景如下:

  • 给 request、context 统一加上指定参数,例如 request.user
  • 在请求处理完毕返回前继续调用别的路由端点

传输器-transporter

可能聪明的读者已经发现,前面讲路由的篇幅大部分都在介绍如何处理请求,但形如 app://create 的 URL 以及 IPC 监听器是如何被注册到应用之上的呢?下面就介绍一下两种注册方式。

自定义scheme

假设要处理 app:// 这样的一个协议,我们需要将这个请求转发到真正的 HTTP Server 上。通常情况下需要绑定本机的一个 TCP 端口,但这里有个问题,如果尝试绑定的端口被占用怎么办?可以用不断尝试的方式最终拿到一个可用端口,但更好的方式是使用 Unix socket。

拦截
转发
app://create
protocol.handle
unix:///tmp/electron.sock:create
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
import os from 'os';
import { Readable } from 'stream';
import { Session } from 'electron';

import nodeFetch, { RequestInit } from 'node-fetch-unix';
import { ReadableStream } from 'stream/web';

const API_SCHEME = 'electron';

export function getSocketPath(socketName: string) {
if (os.platform() === 'win32') {
return `//./pipe/${socketName}`;
}
return `/tmp/${socketName}.sock`;
}

export function getSocketUrl(socketName: string, pathname = '') {
if (os.platform() === 'win32') {
return `unix:////./pipe/${socketName}:${pathname}`;
}
return `unix:///tmp/${socketName}.sock:${pathname}`;
}

export function redirectRequest(req: Request) {
const url = req.url;
const urlObj = new URL(url);
const newUrl = getSocketUrl(API_SCHEME, urlObj.pathname);

const body = req.body
? Readable.fromWeb(req.body as ReadableStream<Uint8Array>)
: undefined;
const newReq: RequestInit = {
...req,
method: req.method,
headers: req.headers as any,
body,
};

return nodeFetch(newUrl, newReq).then((res) => {
const readable = new Readable().wrap(res.body);
return {
...res,
status: res.status,
headers: res.headers || {},
body: Readable.toWeb(readable),
} as unknown as Response;
});
}

Session.defaultSession.protocol.handle('app', redirectRequest)

之后我们只需要将 Nest.js 的 listen 方法指向 getSocketPath 创建出的 socket 地址即可收发协议消息了。

ipc注册

注册 Electron 自有的 IPC 消息则略微麻烦一点,但好在 Nest.js 提供了 MicroServices 模块,允许我们自定义消息传输器。

因为我们在前面使用 EventPattern, MessagePattern 绑定了 handler,这样就能在 server 的 messageHandlers 里找到对应的请求处理函数,将它与 ipcMain 对接起来。示例实现如下:

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
import { ipcMain } from 'electron';

import {
CustomTransportStrategy,
MessageHandler,
Server,
} from '@nestjs/microservices';

export class IpcStrategy extends Server implements CustomTransportStrategy {
private handlerMap: Partial<Record<HandlerType, (pattern: string) => void>> =
{
'invoke': this.bindIpcInvoke,
'event': this.bindIpcEvent,
};

bindIpcInvoke(pattern: string) {
ipcMain.handle(pattern, (event, ...args) => {
const handler: MessageHandler = this.messageHandlers.get(pattern);

if (!handler) {
return this.logger.warn(`No handlers for message ${pattern}`);
}

return handler({
event,
args,
channel: pattern,
});
});
}

bindIpcEvent(pattern: string) {
ipcMain.on(pattern, (event, ...args) => {
const handler: MessageHandler = this.messageHandlers.get(pattern);

if (!handler) {
return this.logger.warn(`No handlers for message ${pattern}`);
}

handler({
event,
args,
channel: pattern,
});
});
}

listen(callback: () => void) {
this.logger.debug('Start listening...');

for (const [pattern, handler] of this.messageHandlers) {
const handlerType: HandlerType = handler.extras.handlerType;
this.handlerMap[handlerType]?.call(this, pattern);
}

callback();
}

close() {
this.messageHandlers.clear();
this.logger.log('End listening...');
}
}

最后在入口处加上这个 strategy 并随 App 启动即可。

1
2
3
4
5
// microservices
app.connectMicroservice({
strategy: new IpcStrategy(),
});
await app.startAllMicroservices();

数据管理

前面我们讲到了如何处理用户操作(也就是各种各样的请求),这里谈下如何将请求的结果持久化,也就是记录数据。不管是前端还是后端,都有一整套数据 CRUD 的工具可以用,但到了客户端这边需要考虑的东西就变得复杂了起来。

首先数据库最简单的需要支持读写,是否支持多人同时读写?抛开写入而言,读取性能是否满足?如果数据库意外崩溃,是否支持根据日志回放等等。然后考虑到跨平台场景,还需要数据库支持不同操作系统不同架构等。根据上述问题分别分析不同数据库使用场景,各自的优劣势分析如下表。

存储方式 瓶颈 优势
localStorage/IndexedDB 仅限 web 使用 原生
MySQL/PostgreSQL 跨平台支持不足,且需要占用端口 生态丰富
JSON 文件 可能存在读写竞争 单文件
LevelDB 文件数量多 跨平台支持,不限定文档结构
SQLite 需要固定单条记录结构 跨平台支持

最终,我们发现可以将简单的 KV 存储和复杂结构数据区分开来考虑。

简单JSON内容与跨进程

JSON 存储主要突出一个字:快。这里通常的数据量级都不会超过 1kb,无论是 set 或是 get 都可认为是同步完成的,会带来以下好处:

  • 无需 async/await 写法,跟普通 JavaScript 对象一样操作
  • 单文件 debug 方便,任意编辑器直接查看
  • 同上一条,单文件可直接跨进程访问,web 与 Node.js 之间可通过 JSON 共享数据

下面将实现一个从主进程写入 JSON 数据,preload 中读取数据的 demo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main-process
import Store from 'electron-store'
import { BrowserWindow, app } from 'electron'
import { resolve } from 'path'

const store = new Store
store.set('name', 'kimi')

const win = new BrowserWindow({
webPreferences: {
preload: resolve(app.getAppPath(), 'preload.js')
}
})
win.show()
1
2
3
4
5
// preload.js
import Store from 'electron-store'

const store = new Store
const name = store.get('name')

数据库的初始化和关闭

客户端所用数据库跟普通后端数据库没有太大差别,只是得注意需要有不同系统及架构的二进制包,事实上例如 SQLite 和 LevelDB 都有官方的 node binding 版本,只需要打包时一并合入发布制品即可正常使用。但如果是在 Electron 项目中,则又多了一项准备工作,那就是如何获得数据库的存放位置。

其实任何存储都有类似的问题,前面的 electron-store 也不例外,需要在 new 的时候知晓确切的存储位置,但这是一个异步过程,简单表述如下。

1
2
3
4
5
import { app } from 'electron'
const getAppPath = async () => {
await app.whenReady()
return app.getPath('userData')
}

为啥要 await app.whenReady() 呢,因为可能会有让用户指定目录或者运行自动化测试需要传入特定目录的需求,比如 Chrome 就提供了 Overriding the User Data Directory 的选项。

但这里带来一个严重的问题,所有使用数据库方法的地方,都需要加上 await 来等待 Electron 的 ready,这样看起来很不科学。有一种办法是给所有的方法都加上一个装饰器,sync 方法也变成 async 方法,但这肯定不是我们想要的。怎么办呢?我们需要用前面提到的 useFactory 来包装一下这个 ready 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// module
import { Global, Module } from '@nestjs/common';

@Global()
@Module({
imports: [],
providers: [
{
provide: 'USER_DATA',
useFactory: async () => {
await app.ready();
return app.getPath('userData');
},
inject: [],
}
],
exports: ['USER_DATA'],
})
export class ElectronModule {}
1
2
3
4
5
6
7
8
9
10
11
12
13
// database
import PouchDB from 'pouchdb';

@Injectable()
export class DatabaseService {

constructor(
@Inject('USER_DATA')
private readonly userDataDir: string,
) {
this.db = new PouchDB(this.userDataDir)
}
}

我们看到上面的代码中,userDataDir 变成了一个 Injectable Provider,可以被随时注入到例如数据库 constructor 里,这样不仅避免了 async 写法,还能让 Nest.js 自己管理依赖关系。

理想情形下,退出 App 前还需要关闭数据库操作,这也可以绑定到 Nest.js 的退出回调监听器上。

数据目录分类

前面提到的数据存储操作,最终都需要将数据写入磁盘目录,而且我们也看到了这里面使用了 userData 这样的目录。实际上 Electron 给我们封装了各种系统预置的目录,让我们来了解一下它们分别是什么用途,以便更好地设置。

名称 说明
appData 程序目录,不要动这里面的文件
userData 用户数据,大多数数据都应该放在这里
temp 临时文件,一次性数据,关机后不保证可以留存
downloads 下载目录
logs 日志目录

这里面没有提到一个很关键的目录,就是 cache 缓存目录。默认实现时,缓存目录和用户数据在同一个路径下,但我们往往希望它能位于一个隔离的路径下,以便用户觉得占用过多空间是主动清理。可以通过以下方式拿到这个 cache 目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import os from 'os'
import path from 'path'

function getAppCacheDir() {
const homedir = os.homedir();
// https://github.com/electron/electron/issues/1404#issuecomment-194391247
let result;
if (process.platform === "win32") {
result = process.env.LOCALAPPDATA || path.join(homedir, "AppData", "Local");
}
else if (process.platform === "darwin") {
result = path.join(homedir, "Library", "Application Support", "Caches");
}
else {
result = process.env.XDG_CACHE_HOME || path.join(homedir, ".cache");
}
return result;
}

简单总结一下,Electron App 的数据存储与普通 Node.js 应用类似,都是通过一定的映射方式,或以文件或以记录的方式存储到磁盘上,只是需要考虑跨进程读写及数据的初始化配置问题。

会话隔离

绝大多数人都会在 web 上使用各式各样的账号,这里就出现了一个问题,如何在同一个网站登录不同的账号呢?有的人做法是开一个隐身模式窗口,dddd …… 但实际上浏览器本身自带多账号功能,称作 session。前文里提到的不同 --user-data-dir 可以让 Chrome 多开成为现实,不同用户间的数据是完全隔离的,但这需要启动多个进程。合理利用 chromium 的 session 可以在同一个进程内实现登录同一个网站使用不同 session 的功能。

使用不同 Session 不仅可以隔离账号,还可以针对不同 Session 定制特殊行为,比如支持自定义协议,对 web 资源进行缓存等,妥妥就是一个 web 定制沙箱。

Session持久化

使用 Session 第一步,要将 session 数据持久化。有人会说 sessionStorage 不是在浏览器关闭 tab 后会被清除吗?事实上此 session storage 非彼 sessionStorage,这里的 session 指的是包括 cookie、localStorage、IndexedDB 以及 sessionStorage 在内的所有 web 缓存。如果没有设置成持久化,那 Session 就变成了隐身访问模式。

设置 Session 持久化只需要一步,就是创建 Session 时传入前缀为 persists: 的 partition。在 Electron 代码中即有这个常量,这也是为数不多通过字面量判断表达不同目的的 API 之一。

不同的 Session 会出现在 Partitions 目录下,persists: 后面的文字就是目录名,里面密密麻麻都是 Chromium 运行时产生的数据文件。

Session事件

与 IPC 消息可以只在全局 ipcMain 对象上监听不同,不同的自定义协议、网络请求和 cookies 都只能从单个 Session 上获取,这是一把双刃剑。一方面我们需要在不同 Session 初始化时绑定事件监听,甚至在 app 启动时注册协议,另一方面同样也能对 Session 进行隔离,以防某个 Session 崩溃影响到别的,这也符合 Chrome 对于多渲染进程安全和效率之间的平衡

简单来讲,Session 上有 protocolwebRequestsCookies 这些对象可供调用,分别提供了协议拦截、请求拦截和 cookies 操作的功能,注意这不是 Chromium 的全部功能,所以可以预见的是这个树状结构会越来越长。

Session
protocol
webRequests
cookies
registerSchemesAsPrivileged
handle
isProtocolHandled
onBeforeRequest
onBeforeSendHeaders
onHeadersReceived
onCompleted
onErrorOccurred
get
set
remove
flushStore

以上三个对象的操作方式大相径庭,下面为简单分析:

  • protocol 比较纯粹,对某个 scheme 比如 app 相关的请求拦截并响应,可以理解成后端服务器的 server。

  • webRequests 需要设定 url pattern 匹配规则,对规则内的请求做处理,或改变 header,或进行重定向。但这里可以拿到请求的 webContents 对象,所以可以做一些特殊的操作。不利之处是,url pattern 及 callback 在单个 session 同一 hook上只能注册一次,所以遇到多 pattern 匹配需要自己手动做拼接及分发。

  • cookies 同样很纯粹,它就是 web 内 cookie 对象的真身,只是移除了跨域读写 cookie 的限制。

Session 可以说是 Electron 的命脉,虽然大部分情况下都不会跟它打交道,但它往往是一个 Electron 程序运行性能的瓶颈。

业务特性

铺垫了如此长的篇幅,Electron App 都快走到了它生命周期的尾声,这才来到了我们开发它的初衷:完成指定的功能。

一个理想的 Electron App 就应该像 VSCode 一样,本身提供渲染排版编辑的功能,其他功能以插件形式补充。事实上类似 TypeScript 支持这种也是插件形式提供,虽然它们在 VSCode 使用过程中就像呼吸一样自然。Electron 本身是一个容器,它承担了大部分 Clean Architecture 里内三层的工作,将系统和窗口的功能封装成 API 提供,只不过这些 API 对于实际应用来说仍然太底层了,还需要一个 Adapter 层来整合梳理。这就是插件机制所要做的事了。

但作为一个 GUI 应用,完成用户功能必须要跟窗口紧密结合起来,少不了要和各种创建修改窗口对象的 API 打交道,但实际看来这部分工作量反而是比较小的。

插件机制

Feature
Extension
Controller
Application
识别文档
识别设置页面
识别小程序
识别剪贴板文本并打开 URL
window
session
tray
clipboard
Electron

上图是一个简单的剪贴板与窗口联动的功能分层,插件的 API 设计位于倒数第二层。在设计插件时有时会进入这样一种误区,插件的功能直接调用了系统功能,从而跟系统的版本有了耦合,这就是即当业务开发又当技术架构的弊端,因为自己写着方便就耦合在一起,最后导致项目架构越来越混乱。而插件机制正是起到了“防止业务特性侵入架构内部的防腐层”作用。

内置插件

虽说几乎可以通过所有的 API 将功能都暴露给不同的插件,但事实上有些关键插件是无法直接提供给用户进行设置的,一来是可能用到了内部不稳定 API 不准备让第三方进行操作,二来是影响范围很大,比如主题色和 UI 展示位置等,毕竟用户也不希望一旦安装了某个主题就把自己熟悉的工具栏位置给换了。这就很像政治课本上讲的,国有经济对国内市场的控制在于控制支柱行业。

VSCode 常见的内置插件范围为:

  • 主题色设置
  • Git 功能
  • 账号功能
  • 搜索
  • 内置浏览器
  • 语言 Debugger
  • 语言语法检查与高亮

可以看到这些功能都属于深入到了应用程序内部,一旦缺失都无法正常运行了,所以必须是全局启用的状态。

第三方插件

没有什么插件是调用一两个 API 解决不了的,有的话就出一吨 API 接口!
VSCode 提供了汗牛充栋的 API 列表,同时还有对每个 API 详尽的注释说明,可以说文档能力才是一个公司技术实力的真正体现。

https://code.visualstudio.com/api/references/vscode-api

但除了插件调用 API 完成功能之外,还有一些配置信息,这就是 manifest 文件做的事了。比如配置了 onLanguage 字段后,当识别到一个文件为特定语言时就会给插件发送消息。

https://code.visualstudio.com/api/references/extension-manifest

插件依赖

插件一多,就跟模块一样存在互相依赖的问题。内置插件还好说,毕竟在同一个仓库里维护,第三方插件完全是脱离控制的存在。

一种简单的办法,插件具有版本,互相引用需要带版本号进行关联,但很明显这是种不经济的做法。况且为了不强依赖于某个版本,大家都会心照不宣地做成向上兼容模式,这时候一个实习生发了 x.x.n+1 的版本,但实际废除了一个重要 API,画面太美不敢想象。事实上没有一颗避免循环版本依赖的银弹,业界通常做法是深度遍历之后在遍历到一定层数,返回循环依赖的报错。

这时候就不得不提一种脱离 RestFul 的查询语言:GraphQL。用它进行查询时,所有的层级都必须显式注明,无法进行无限嵌套查询。这有点像加上了 SQL 的 RestFul 接口,并且它还支持动态 join。

我们可以设计一种生命周期,插件安装完成后根据一系列钩子获取到插件系统的状态,同时将输入输出连接到插件系统,这样任何的行为表现都交给了系统。比如插件 A 需要插件 B 的数据,它会试图先询问系统在存储空间内有没有插件 B 写入的数据,得知不存在时请求调用插件 B 的功能,得知插件 B 不存在时唤起插件系统的下载功能对插件 B 进行加载。

窗口 UI

Electron 对于 UI 的表现力主要来自于 Chromium,也就是 web。做 UI 没啥复杂的,就是改改改到地老天荒就完事了。UI 一定不能直接暴露给插件或者第三方,因为要描述 “使用确认弹窗提示用户后续操作有风险”,要比描述 “创建一个长为 xxx 宽为 xxx,坐标为 (xxx, yyy) 的窗口,并在其中放置标题为 xxx,确定按钮,取消按钮,点击确定按钮继续,点击取消则放弃” 要方便得多。

UI 本身即是很难被准确描述和展现的,对于外部插件而言,只需要关注改变 UI 的方式,而不应关心 UI 元素的组合和操作。

KISS

不要动原生对象!
不要动原生对象!
不要动原生对象!

重要的事说三遍。因为 Electron 中几乎每个对象背后都绑定了 C++ 的对象,只是 C++ 在前台的一个傀儡,所以 class 的 constructor 并不能正常的在子类里复写。有啥需求请老老实实拿到 Electron 提供的对象再操作,最好是 Electron 端的 API 都进行包装,并通过依赖注入使用。

抽离重复判断逻辑

React 一声炮响,为前端带来了声明式 UI。但基于 Virtual DOM 的渲染机制最近越来越式微,究其原因不过是 UI 组件的膨胀非常剧烈,层级也越来越深,跟 OOP 中类的继承一样是个无底洞,不管是常规 diff 还是分优先级 Fiber 机制都无法满足正常渲染出数据对应的页面的要求。更精细的应该是接管所有数据源头,根据源数据的变动来处理最终更新。但现在大部分前端页面还没有复杂到这个程度,处在刀耕火种还是飞机大炮的中间地带,React 就像是蒸汽机车一样提供了强劲的 UI 驱动。

回到我们的应用来,Electron 给窗口(BrowserWindow)提供了很多操作方法,但这里有个问题:如果要检测窗口是否可以做某个操作需要有一些前置判断逻辑,这些前置步骤很难封装。如果我们使用的是路由模式的话,理想情况修改窗口 UI 应该是下面的模式:

1
2
3
4
5
6
7
class WindowController {
@WindowMethod('resize')
@Prevent(['maximized', 'hidden'])
resize(@Window win, @Param('width') width, @Param('height') height) {
win.setSize(width, height)
}
}

其中 @Prevent(['maximized', 'hidden']) 标识这个操作只有在 window 不是最大化或是隐藏的时候才会被调用。基于 Nest.js 的中间件还可以将更多 API 安全地暴露给外界,我们的应用不再是一个闭门造车的状态。

中心化状态

最好的状态管理就是不作状态管理,像页面 URL,Electron 全局对象等都可以充当状态管理器。但这里要注意一点,如果渲染进程和主进程都要用到一种状态,那合理的模式应该是:

订阅
发消息
main-process
Electron 对象状态
renderer

也就是所有的状态都由 main-process 持有,renderer 通过订阅和传送消息来和应用交互。前面讲的所有消息都是从单次往(返),该如何实现订阅机制呢?在 Nest.js 下也非常简单,使用 Server Send Events 即可。

https://docs.nestjs.com/techniques/server-sent-events

最佳实践

软件开发没有银弹。只是在遇到 DDL 临近,PRD 只有两句话,屎山代码一片又一片时,是否还能保持内心的镇定,找出相对更优的那个解,这应该是每个工程师都应该思考的问题。

防呆设计

虽然只是一个很小的点,但实际操作中还是很容易被忽略,那就是没有一个用户会 100% 按照你的预期来使用你的产品。例如给用户一个删除按钮时,用户会毫不犹豫地点下它,然后等待确认删除的弹窗弹出来。什么?直接把文件删除了?你们这是欺诈消费者。所以业界共识都是删除往往都是只标记数据为已删除而不是真正删除,实际在磁盘清理或是存储服务缩容时才会丢弃这部分数据。但不符合用户预期的行为一定会带来用户的困惑,所以一定需要反复确认这种反人性但必要的功能。

另一个小点,用户的数据是我们第一优先级要保障的内容。撇开上面用户主动要删除资源的情形,如果遇到不可抗力,比如网络错误,或者数据格式解析失败,第一步也是尽可能备份用户的数据并提示用户自行转移,而不是失败了提示刷新页面,用户一点,咣的一声数据就没了。这就是为什么危险操作往往用红色标识,但一种趋势是:用户甚至不关心你在表达啥,他只是想把这个该死的弹窗给 × 掉。所以 Windows 更新才会以那么粗暴且不可取消的方式抢占用户的操作。

保持功能独立

没有什么比功能耦合更折磨程序员了,简单的小功能往往最后变得不可收拾,这通常是产品 feature 没有被良好地拆解消化,也可能是 DDL 倒排导致的赶工。总之,连 Chrome 都只能用多进程来解决不同网页的内存泄漏问题,就更不用说我们这些仓促写就的代码了。保持功能独立主要有以下几点:

  • 数据的唯一性,参数的正交性。这个可以参见我之前的一篇文章掌控前端数据流,响应式编程让你看得更远,只有做到数据独立,才能最大程度减少错误状态给整体带来的危害。
  • 对于外部 API/SDK 足够的不信任。也就是说任何未经严格测试的第三方内容都需要加兜底处理,以防击穿层层防护使应用崩溃。
  • 组合优于继承。虽然这是句老生常谈的话,但实际运用中,加一个 if else 或是添加一个类似命名的方法都是在进行继承操作。如果有那么一点成本上的可能,我们甚至应该对每时每刻每分每秒的代码都部署一个版本,这样我们总能在漫漫时间长河里找到能用的那个版本。

附录

示例项目:https://github.com/msyfls123/nest-electron.git

如何从前端到客户端

我是如何从一个工作全部内容只是 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 注册表

心理建设

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

控制技术的复杂度

学习新技术的恐慌

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 开发时间较短,行文中多是开发中所见所闻所感,如有错误纰漏之处,还望读者不吝包涵指正。后续文章将介绍在跨端开发中处理兼容性时遇到的问题,以及如何优雅地在产品设计和功能间进行取舍。