目录

  1. jsscript
  2. 与 QuickJS 的对照
    1. 启动流程
    2. 数据结构
    3. 错误处理
    4. 更多
  3. CLI
  4. API
    1. 基本使用
    2. Feature
  5. 心里话

不知道有多少前端,曾经像我一样,尝试去学习 V8 让自己可以从底层更好的了解 JS 的实现。但是由于太复杂而放弃,光下载、编译、调试足够让人望而却步了,更别说你需要有足够的 C++ 经验。

幸运的是,Bellard 大佬不久便开源用纯 C 实现的轻量且高性能的 QuickJS,这让我重新燃起了学习的热情,这个项目简单到仅仅有高中的 C 语言知识和数据结构就可以阅读。

无论如何,其还是用 C 实现的,内部会有很多 C 语言的 hack 写法(为了性能),阅读起来也不是那么的流畅。正巧我就是研究小程序的,小程序内部限制了动态脚本的执行,不如顺势把 QuickJS 迁移到 TypeScript 上,这样在小程序中也可以做到动态脚本执行了!

于是,这个项目便诞生了,我将其命名为 jsscripthttps://github.com/XGHeaven/jsscript

jsscript

目前这个项目还在开发中,部分能力缺失,只是我个人能力有限,JS 引擎又是一个很大的内容,很难短时间内完全支持,但最终的目标是不变的。

在立项之初,我了解到社区内有很多类似的项目都可以实现相同的功能,所以在一开始,我就觉得尽量不合他们的特性或者能力重叠,而是将更多的精力放在学习、易用性上:

  • 原生 ES6、ESNext 支持。截止写文时,test262 覆盖率为 25%。

  • 易维护。项目用纯 TypeScript 编写,所有的操作函数和参数都有完整的类型提示。

  • 易阅读。代码核心逻辑和 QuickJS 几乎无异,核心 API 命名大部分也保持一致,数据结构有所不同,使之更加适合于 TypeScript。

  • 易修改。项目内几乎所有能力都是可插拔的,你甚至可以把所有内置的对象(例如 Array、Function)删除,变身成普通的表达式计算器,按需定制。

  • Tree Shaking 友好。代码全部以无副作用和函数的方式提供(部分封装会用 class),只要你不用,就不会被打包进去,体积随你所控。

  • 架构简易。所有的事件循环依赖外部环境(例如 Node、Browser)、部分内置对象的原型方法直接代理到外部环境、只支持严格模式等。

有人好奇,为啥我这里没有列出来性能呢?一般这种项目不都是要做到第一的性能么?

其实,自从我决定将可读性、易用性、灵活性作为首要目标的时候,性能就不再是核心目标了。原因也很简单,这两者很难兼得。当然,性能也会是考虑的点,但不是现在的目标。

与 QuickJS 的对照

启动流程

QuickJS 的基本启动流程可以简单理解为:

  • 创建 Runtime

    • Runtime 提供了执行 JavaScript 代码所需的基础设施和功能,如内存管理、垃圾收集、事件循环等。

    • Runtime 是整个 JavaScript 执行过程的基础,简单理解就是初始化一个引擎。

  • 然后通过 Runtime 创建 Context

    • Context 指的是 JavaScript 代码执行时的执行环境或上下文。

    • 这两者之间的关系是一对多的,也就是说一个 Runtime 可以创建多个 Context,Context 之间可以相互访问。

    • 之所以这样设计,是因为浏览器中有 iframe,如果没有 Context,每次创建一个 iframe,就需要起一个引擎,这是很浪费的。而引入 Context 可以避免创建多个引擎的情况。

  • 初始化 Context 内的对象和属性。会初始化各类内建对象和其原型方法,例如 Array、Boolean、String 等。

  • 通过 Eval 方法执行代码

在 jsscript 中,大部分流程还是一致的,只不过我们将最后 Eval 的过程拆分成了先编译后运行的过程

数据结构

在 QuickJS 中,所有的 JS 的值都会用一个叫做 JSValue 的结构体表示,它不和任何 Context 或者 Value 绑定,是一个独立的存在,具体的结构体定义这里就不再详细解释。而在 jsscript 中,自然也是沿用了一样的设定,唯一的不同是采用了 TypeScript 的 Tagged Union 而非 C 中的 union,并且采用了更加适合前端的命名规则。

1
2
3
4
export type JSSaftHostValue = JSNumberValue | JSBoolValue | JSStringValue | JSUndefinedValue | JSNullValue
export type JSHostValue = JSSaftHostValue | JSSymbolValue
export type JSInstrinsicValue = JSHostValue | JSObjectValue | JSSymbolValue
export type JSValue = JSInstrinsicValue | JSExpectionValue | JSTryContextValue

这里的细节可能会有些多,比如:

  • null 和对象其实是用不同的 tag 表示,虽然 typeof 这两者都返回的是 "object",但在引擎层面是不一样的

  • 所有的 Object 都是使用 JSObjectValue 所表示,不论是普通对象还是函数。当然,在 JSObjectValue 内也会有具体对象类型的细分,但这不在 JSValue 这个层面所讨论的内容。

错误处理

在 QuickJS 中,错误本身和 JS 内的实现是不同的,引擎将错误状态和错误内容拆分成了两部分:

  • 错误状态会通过一个特殊的 JSValue 实现,在 jsscript 内叫做 JSExceptionValue。

  • 错误内容则会直接存储在 runtime 内,也就是说每次运行完想去拿错误信息的时候,需要去 runtime 上拿,而不是直接从返回的 value 中获取。

所以在引擎的实现过程中,如果你想表示某个过程发生了错误,只需要:

  • 创建错误对象,并存储到 runtime 中

  • 返回 JSExceptionValue,告知调用者,这里有错误

所以,当我们尝试调用一个函数的时候,都需要先检查返回值是否为错误,虽然这很麻烦,但这是必要的做法。(Golang 开发者是不是很眼熟,捂脸)

1
2
3
4
5
6
7
8
9
10
11
12
function JSDiv(ctx, leftVal, rightVal) {
const left = JSToNumber(ctx, leftVal)
if (isExceptionValue(left)) {
// 检查错误,如果出错了,就直接返回就好
return left
}
const right = JSToNumber(ctx, rightVal)
if (isExceptionValue(right)) {
return right
}
return createNumberValue(left.value - right.value)
}

更多

更多内容可以看源码,包括如何创建值、新增属性、函数调用等。

进入 https://github.com/XGHeaven/jsscript/tree/main/src 文件夹之后,可以按照入口(Runtime)的方式阅读,也可以按照文件命名的方式,找寻自己好奇的那部分点进去看即可。

CLI

最简单的体验的方式,就是安装 CLI

1
2
3
4
5
# 全局安装
npm install @xgheaven/jsscript -g

# 运行脚本
jsscript run file.js
1
2
3
4
5
// script.js
const fn = () => {
console.log(1 + 1)
}
fn()

不过目前还有些问题还没有处理:

  • 不支持 REPL

  • 不支持模块,只能运行单一脚本

  • 部分 API 缺失,不确保能够运行所有脚本

API

目前 API 的定义都处于 unstable 状态,预计后续会大改,目前仅限于了解和学习即可,请勿在生产环境使用。

基本使用

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
import { Runtime, Features, parseScript, parseBytecode, toHostValue } from '@xgheaven/jsscript'

const runtime = new Runtime({
features: [
// 注入所有在 ECMA262 中定义的方法
new Features.ECMA262Feature(),
// 注入和系统相关,例如 setTimeout 等
new Features.OsFeature(),
],
})

const context = runtime.newContext()

const script = `<your-script>`;

// 直接解析成函数
const fn = parseScript(context, script)
// 或者分成两步,先解析为字节码,然后再将字节码解析成函数。
// 字节码是可序列化的,你可以将其 stringify 之后存储下来,方便下次直接使用
const bc = compileToBytecode(script)
const fn = parseBytecode(context, bc)

// 运行刚才生成的函数
const ret = context.run(fn)

// 返回值是 vm 内的值,需要通过 toHostValue 转换成 JS 可识别的对象
console.log(toHostValue(ret))

Feature

Feature 是灵活性的根本,vm 内的各种行为和功能,都可以通过 Feature 的方式进行组合和定制。

目前提供以下几种:

  • ECMA262Feature 提供 ECMA262 规范中提供的对象,例如 Array/Boolean/String 等构造函数和其原型方法。如果不引用这个,VM 环境内将不会有这些构造函数和相关的原型方法,但是并不影响其字面量的使用。

  • JobSchedulerFeature 用于提供 Promise 的任务调度,如果代码不曾使用 Promise,则无需引用。当然,你也可以定义自己的任务调度方法

  • BrowserFeature 用于一定程度上模拟浏览器的环境,例如 window 对象

  • OsFeature 提供和系统交互的一些方法,例如 setTimeout 等

除了以上几种,也可以自定义 Feature:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Feature, createNumberValue } from '@xgheaven/jsscript'

class DemoFeature extends Feature {
initContext(ctx) {
// Context 初始化回调,你可以注入任何你想要的东西
// 在 VM 内的全局对象中注册一个 xxx 的全局属性
ctx.defineGlobalValue('xxx', createNumberValue(1))
}

initRuntime(rt) {
// Runtime 初始化回调
}
}

const runtime = new Runtime({ features: [new DemoFeature] })

心里话

其实吧,这个项目主要还是以个人学习目的为准,目前没有准确的实用目标。如果你看完之后觉得一般般,我完全表示理解,因为本身就没有特别出众的功能和能力。

但如果你也对 JSVM 感兴趣,又奈何很难、没时间直接学习 QuickJS/V8,可以尝试来一起来学习和实现。

注释和共享

前言

小程序,作为新一代的应用开发方式,虽然在业务上已经证明了巨大的价值,但是在开发者友好性上却非常的差,例如框架难用,调试困难,跨平台兼容性差。本文便是在尝试提高针对视图的开发者友好性和兼容性,同时进一步提升性能。

为了能够更好的理解本文,需要您最好了解以下内容:

  • 有小程序相关的经验,能够理解基本小程序的架构、基本的框架使用方式、主流的跨端三方框架等

  • 有前端相关的经验,能够理解什么是 DOM 接口,以及如何操作 DOM 接口

本文将不再赘述过多的基础概念内容。

背景

目前本人任职飞书开放平台小程序组,负责小程序相关的运行时的工作内容。由于飞书对小程序有着一些特殊的需求:

  1. 飞书大量官方应用都是通过小程序承载的,这些应用对运行体验和开发效率有着远比外界更高的要求。而且由于是官方应用,且只服务于飞书,所以他们对于小程序的跨端兼容性要求并不高。这也给了我们更大的灵活性去设计更好的方案。

  2. 飞书是一个面向全球 B 端的市场,先不论国内是否每个 B 端企业都能有精力去开发小程序,单论海外市场对小程序的认知就非常浅薄。所以如何尽可能的降低小程序的认知学习门槛,降低接入成本,就显得尤为重要。在国内可以通过类似于 Taro 的框架,但是海外这部分的学习成本依旧很高,所以需要有新的方式去降低这部分的成本。

正是因为如此,我们这边决定不再以兼容社区小程序的方案为首要目标,而是转为提高开发者的开发体验和运行性能为主目标去进行设计和优化。

现有架构的「病区」

小程序在诞生之后,其实做了很多的设定,有的设定是合适的,但更多的设定在逐渐的发展过程中变得不再合适,而后人却依旧沿着这个思路和方向去做。接下来,让我们重新思考一下这些设定:

为什么会诞生 XXML 的视图开发方案?

XXML 在这里是对通过模板开发小程序的一种开发范式(也可以叫开发框架或者 DSL)的统称。因为不同宿主内对应的模板文件的后缀不同,但都有 ML 而因此得名。例如微信是 .wxml ,支付宝是 .axml,而飞书和抖音则为 .ttml

在我的认知中,小程序最开始并不是为资深的前端开发而设计的,相反,他是为了客户端乃至一些没有开发经验的人所设计。

所以借鉴了 Vue 这类非常容易入门的社区框架,再结合上小程序自身的需求所定制了一套 DSL。这套 DSL 针对从来没有写过前端的人来说,确实算是非常友好的,几乎不需要学习就可以上手。

其实让开发者更容易的入门上手算是小程序最开始的目标。例如,一开始是不提供 CLI 工具只提供了可视化的 IDE。如果小程序的受众是不懂开发的人,那其实没问题。

但最终事与愿违,开发小程序最多的人其实就是资深的前端开发,在前端开发的视角来看,这套 DSL 可以用一坨屎来形容。而小程序又必须要要用这套 DSL 去开发,就导致每一个写过小程序的人必须吃一次这个屎。

XXML 会带来什么问题?

虽然以微信为首的厂商做了很多能力去补救,尽可能在保证较低入手门槛的情况下,不断丰富能力,提高对前端开发人员的友好程度。但最开始的设计就不是面向这部分人群,即使后面修补的再多也无济于事了。这其中所带来的的问题随着小程序的发展而愈发严重:

  • 由于这套框架的实现是内置在基础库内,其能力和功能的实现必然有滞后性,同时灵活度差。一旦框架概念落后,开发者将不得不继续咬牙学习落后内容。

  • 虽然最开始借鉴自 JS 和 Web,但最终却无法的很好的利用开源社区的能力,几乎所有都要重造轮子。国内已经发展了这么多年,社区才将将都造完稳定的轮子,如果将这一套推向海外,那成本根本无法估量。

  • 规范缺乏一致性。由于 XXML 本身其实是一个业务开发框架,但又作为唯一开发方式,非常容易变得越来越臃肿和随意,可能一个实习生随便定出的接口都将会影响整个社区,能力的实现也缺乏一致性。例如 PageComponent 的生命周期就非常不一样。

  • 为了解决 XXML 这些问题,降低开发者的开发成本,在社区中诞生了许许多多的三方跨端框架。导致开发小程序的人不仅仅要懂得如何写原生的 XXML,更要懂得如何使用这些跨端框架,学习成本陡增。

  • Taro 这类模拟一套 DOM 接口来嫁接社区框架的做法,最终会让运行架构变成 React => Taro DOM => TTML => DOM 这种有点脱裤子放屁的方式。

小程序的视图开发一定要使用 XXML 么?

一个支持通用应用的宿主,可以提供一套它认为合适的模板 DSL 去生成渲染树,但一定也需要提供一套更加灵活的方式去生成渲染树。例如:

  • 安卓默认使用 XML 作为模板,去渲染内容。但如果不满足使用,同时可以使用命令的操作方式去构建一个渲染树。iOS 也是用类似的逻辑

  • Flutter 定义了一套类似于 React 的 Weight 渲染框架。但依旧暴露了底层的 RenderObject 方便开发者绕过 Weight 直接去构建渲染树。

而小程序,却选择了只提供 XXML,并没有提供其他的方式。如果小程序面向的特定需求场景,这倒也无可厚非,可实际上小程序面向的却是通用需求场景。

于是飞书内部在思考,如果我们一定要再提供一套通用的 DSL,会是什么?React、Vue 这类前端框架么?如果提供了,又会面临和 XXML 类似的局面。

其实在 Web 领域中,早就给出了答案,那么就是 DOM。不管是 React 还是 Vue 还是其他的框架,底层都是基于 DOM 接口所存在的。如果我们能够在小程序提供一套底层的 DOM 接口,那么 XXML 所带来的问题是不是就全都迎刃而解了呢?

为什么之前不提供 DOM 接口?

当我们想明白这个点之后,其实就会更加好奇,为什么这么简单的想法为啥一开始乃至后续的人都没做过呢?

我猜测最开始不这么做的原因有这么几个,但我觉得都不是很成立,或者说在飞书的场景中很难成立(如果大家有不同的想法和意见,随时可以和我沟通交流)。

Q: 无法完整实现,成本高。
双线程无法实现完整的 DOM 接口,且在逻辑层实现一套 DOM 接口的成本过高。

确实,如果想要一比一的复刻 DOM 接口,有些依赖渲染能力的接口确实无法实现。但如果只是实现一套子集,能够满足主流框架的使用,那么成本和难度并不高。比如 Taro 就是这么做的,社区也证明了其可行性。

Q: 首屏性能差。
如果实现 DOM 接口,就必须要等待逻辑层完整渲染并操作完 DOM 接口之后,渲染层才能渲染,那么将失去双线程逻辑层和渲染层并行运行的优势。

我觉得这个是一个屁股决定脑袋的事情。在 90% 的场景中,渲染层就是需要等待逻辑层计算完毕之后才能渲染的。就像是你不能说为了前端页面首屏速度快,要求所有网站都用 SSR 吧。

另外,首屏其实分为 FP 和 TTI 的,和 SSR 场景类似,XXML 可以做到 FP 快,但是 TTI 并不如 DOM 快的。飞书场景更在乎的是 TTI 而非 FP,所以影响不大。

再说,用了 DOM 接口之后自然是有对应的优化策略和方式的。

Q: DOM 指令的通讯压力大。
如果采用 DOM 接口,那么逻辑层往渲染层发送的将是 DOM 指令,如果我要渲染 100 个普通的 view,这部分体积明显比直接发送 data 要大。

没错,某些场景下,DOM 指令的数量确实是会比 data 体积大。

但是在飞书的业务场景中,通常都会有着重度的逻辑以及大量国际化文案,这部分会导致 data 的体积飞涨。而且并不是所有的开发都有合理使用 data 的能力的,有些人就喜欢啥都往 data 里面塞,这更加会加剧其体积的膨胀。

所以对于飞书来说,这个的影响其实并不大。

Q: 觉得 TTML 就够用了

懂的都懂,不多说了

Q: 不希望对外认知上小程序就是前端,所以在一定程度上屏蔽了前端所用的东西。

我觉得这其实是微信小程序的烟雾弹,让大家觉得小程序技术很厉害,它底层是 Native 实现,只是借鉴了前端的一些概念而已。

但实际上缺恰恰相反,从设计开始,就是在 Web 上面打补丁实现的,只是借鉴了一点点 Native 中的概念。

所以如果相信了这个烟雾弹,那么才是将自己埋入深坑。

Q: 不希望利用 DOM 接口实现太多灵活的动态更新能力。
例如审核之后通过 API 更改 UI 界面。

这个也是我见过最多的说法了,也是最站不住脚的说法。
XXML 本身就无法避免动态更新,更别说 DOM 了。

Q: DOM 接口就不会有实现上的兼容问题了么?

这是个好问题,虽然 DOM 接口本身是有标准的,但实现上其实各家都会遇见的不一样。

这个自然没有完美的解决方案,但是我觉得以兼容主流框架而不是提供完整兼容的 DOM 接口为目标是更加合理的。不管各家接口实现成什么样子,只要能兼容前端框架就是对的。

至此,其实我们可以看到,DOM 接口本身的引入并不存在什么实质性的阻碍。

而且最重要的事,DOM 本身是作为 XXML 的一种补充开发能力所引入的,每个开发者可以根据自己的架构特点选择 XXML 甚至的 DOM 来作为开发方式。

提供 DOM 接口能带来哪些好处?

最后的最后,我们其实要明白,如果要提供 DOM 接口,将会带来那些好处呢?

  • 能够几乎无缝的兼容市面上主流的前端框架,目前测试下来支持的有 Vue Svelte React18。极大的降低开发者的学习和开发成本。

  • 可以提供极致的性能优化,做到非常细粒度的组件更新。

  • 和前端主流开发方式对齐,不再分割与割裂,提高开发效率和降低接入成本。

  • 针对三方的跨端框架,可以降低其兼容难度和运行时的体积,提高执行效率。

最关键最关键的是,终于可以抛弃掉 XXML 这套恶心的东西了。没有难用的数据绑定,支持传递函数,不需要考虑 data 内数据是否是视图需要的,自定义组件之间的时序能够得到天然的保障,能够支持 CSS-In-JS 等等。

社区中很多优秀的方案都可以拿来使用,总之,就是优雅,优雅,还是优雅。

全新架构

在理解了上文所讲述的内容之后,就该讲解下我们为了实现上面的功能所做的全新架构了:

最简单的理解,就是在新架构中,渲染层和逻辑层之间的通讯不再发送 data 了,而是通过逻辑层实现的一套 DOM 接口,将其转换成 DOM 的操作指令。

如果说渲染的操作是 fn(data) 的过程,那么以前这个过程是在渲染层完成,而新架构下,这个操作将在逻辑层完成。

在新架构中,依旧保留了对原先 XXML 的兼容性,开发者可以通过 renderingModel 控制。控制以 Page 为粒度,保证在开发中可以做到渐进式迁移。另外,DOM 接口与 XXML 只能二选一,两者无法在一个 Page 上同时开启。

我并不认为提供 Page 级别的控制在技术设计上是一个好方案,但这个方案确实是可以最大程度上提供更好的兼容性,方便开发者渐进式的替换。

Document 结构

当开启 DOM 的渲染模型之后,整个小程序的文档模型都将与 Web 有着极其类似的结构:

1
2
3
4
5
6
7
8
9
10
11
<html> <!-- document.documentElement 一般不会用到这个 -->
<head> <!-- document.head -->
<!-- 可以在这里利用 meta 标签配置一些全局内容 -->
</head>
<body> <!-- document.body -->
<page route="xxx"> <!-- Page 实例所对应的渲染节点 -->
<view>Hello World</view>
</page>
<page route="xxx"></page>
</body>
</html>

例如每一个 Page 都对应着 document.body 内的一个 <page/> 元素。页面栈也与 document.body.childNodes 保持一致,页面栈的进出相当于 page 元素的插入和剥离。

当然,这里只是一个类比,实际上会有些许的不同,本文不再展开(其实就是我还没设计好,这是个预期模型)。

小程序内置组件

小程序内置组件在 DOM 接口下名字、属性、使用方式依旧保持不变,并且遵守 Web 的通用规范。提供了对应的 DOM Interface 方便开发者直接操作和使用。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// tagName 都是和 XXML 内保持一致
const view = document.createElement('view')
const button = document.createElement('button')
const picker = document.createElement('picker')
const pickerView = document.createElement('picker-view')

// 属性名字也与原先的保持一致,只不过当用作属性的时候,需要将其转换成驼峰的格式。
button.openType = 'share'
// or
button.setAttribute('open-type', 'share')

// 原先组件的使用方式依旧不变,例如 picker-view 下面需要插入 picker-view-column
for (let i = 0; i < 3; i++) {
pickerView.appendChild(document.createElement('picker-view-column'))
}

// 当需要赋值非字符串类型的值的时候,尽量使用 property 的方式
picker.range = [['a', 'b'], ['0', '1', '2']]

// 使用 attribute 可能会得到预期以外的行为,不推荐使用
picker.setAttribute('range', [...])

或许有人会问,为什么使用了 DOM 接口缺不能使用前端的组件呢?

这是因为小程序的组件体系确实是比较特殊的,目前暂时没有很好的办法去兼容。但是在未来,可能会选择开放部分 Web 的组件,例如 div span svg 等。

如何使用

目前该方案还在内部开发阶段,外部没有全量发布。下文中提到的所有接口都属于不稳定接口,在没有正式对外前,只能作为参考。

可能看完上面之后大家依旧会是一头雾水,我这里通过一个简单的 DEMO 来让大家有更加清晰的认识。

  1. 打开飞书开发者工具中的新框架开关
  1. 在要开启 DOM 接口的 Page 所对应的 JSON 中添加 renderingModeldom
1
2
3
{
"renderingModel": "dom"
}
  1. 此时可以在 Page 的 onLoad 生命周期内通过 this.pageElement 获取到 Page 的元素实例了,可以将其理解为前端开发中的 <div id="app"></div> 元素。后续就可以按照前端开发流程去操作这个元素了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Page({
onLoad() {
const root = this.pageElement

// 可以直接控制页面渲染
const view = document.createElement('view') // 这里不能使用 Web 组件,只能使用小程序组件
view.style = 'color: blue;' // style 只能设置不能读取,读取到的值是不准确的
view.className = 'root'
view.appendChild(document.createTextNode('Hello world'))
root.appendChild(view)

// 或者使用主流前端框架进行渲染
React.createRoot(root).render(<App/>) // JSX 小程序不支持,需要进行转换,这里只是举例
// 或
Vue.createApp(App).mount(root)
}
})
  1. 其他的例如样式、API 等与普通小程序无异。样式依旧通过 XXSS 使用,API 则继续可以使用 tt 如果在飞书内的话。

性能对比

性能自然是为什么要引入 DOM 最重要的一个因素了,让我们来看一下性能对比测试

测试方式

仓库:https://github.com/XGHeaven/perf-gadget-dom-vs-framework

使用 Vue3 的框架,Demo 使用 mdn/todo-vue,在此基础上使用了 Taro 将代码分别编译到飞书、微信、支付宝。

  • taro-wx Taro 编译到微信,微信版本为 8.0.35

  • taro-alipay Taro 编译到支付宝,开启 2.0 lib,支付宝版本为 10.3.80

  • taro-tt Taro 编译到抖音(Tiktok),抖音版本为 25.5.0

  • taro-lark Taro 编译到飞书,飞书版本为 6.5.3

  • dom-lark Vue 源码直接编译到飞书新架构 DOM 接口上,飞书版本为 6.5.3

每一个测试项目,有 5 次的预热运行,之后会再运行 10 次,取其平均值。同时每次运行之间都会相隔一段时间,保证系统有时机去做别的事情,从而尽可能避免影响到测试结果。

之所以选择 Taro 而非 XXML 原生写法或者其他跨端框架,其一是因为更加接近于开发者的使用体验,因为不可能有人会选择直接去操作 DOM。其二是因为 Taro 已经是最快的跨端框架了,再比较其他的没有什么太多的意义。

除此以外,其他的内容基本一致:

  • 测试设备为华为 P40 Pro,麒麟 990 处理器,鸿蒙 OS 3.0 系统。

  • Taro 框架统一经过 build:xx 命令后运行,Vue 直接通过 Vite 编译后运行

  • 运行方式都是打开对应平台的开发者工具并上传之后,在后台生成对应的二维码扫码预览,避免被认为是开发状态从而添加对结果有影响的能力。

由于 Taro 框架以及飞书新架构的情况下,组件的 mounted 生命周期只能保证在逻辑层侧完成了渲染,不能保证此时组件更新指令已经发送到渲染层,我们需要寻找一个全新的方式去衡量什么时候组件真正渲染上屏。

幸运的是,image 组件有一个特性,当图片被渲染出来并成功加载之后,会触发 load 事件,当此事件触发的时候,可以认为组件已经正确被渲染到屏幕上了。此时,我们只需要在某次更新组件的最后一个组件内,插入一个高宽为 0 的 image 组件作为渲染标记,当事件被触发时,就可以测量出整体的渲染时间。

虽然图片加载会占用一定的时间,但资源本身就是离线保存在代码包内,加载速度很快,且这并不影响最终结果的相对大小。

Taro 默认关闭资源内联功能,避免 Taro 将小资源文件内联成 base64 字符串从而增大通讯成本。

测试内容

这里还需要再补充几点:

  • 不测试应用首屏的性能,由于首屏开始的时间点不同宿主下测量会是不一样的,所以我只在私下里在飞书上通过录屏测试过 Uniapp 对比 DOM,差不多 DOM 可以快 300ms。如果采用 Taro 理论上差距会缩小,但是依旧是快的。

  • 而且首屏性能主要由 JS Parse 和 Execution 的时间组成,这两部分分别由包体积和渲染性能决定,可以分别对比这两项测试从而大体可以得出首屏的性能。

  • 不包含纯静态内容,因为测试使用的是 Taro 框架,其会在逻辑层搞一套虚拟 DOM,会导致最终发送给渲染层的数据量其实基本一致,测试的意义不大。如果有需要,后续可以对比 DOM 和 XXML 的在纯静态内容上的速度差异。

列表组件渲染

渲染 100 个 Todo 项,记录对应的耗时

  • mounted 耗时,指从组件 created 到 mounted 所花费的时间

  • 完整渲染耗时,指从组件 created 到渲染层上屏渲染完毕(图片 onload 触发)所花费的时间

目标 mounted 耗时 完整渲染耗时
taro-wx 141.3 (156%) 1457.4 (443%)
taro-alipay 141.1 (156%) 387.2 (177%)
taro-tt 148.5 (165%) 608.7 (185%)
taro-lark 140.8 (156%) 620.6 (188%)
dom-lark 89.9 (100%) 328.9 (100%)

列表组件内部更新

将 Todo 项内的每一个 checked 进行反转,记录耗时

目标 完整渲染耗时
taro-wx 1374.5 (2220%)
taro-alipay 138.7 (224%)
taro-tt 205.3 (331%)
taro-lark 231.7 (374%)
dom-lark 61.9 (100%)

体积比较

  • 核心逻辑,只包含样式、JS、XXML、SJS 的大小,不包含图片文件,json 等

  • 整包大小,包含图片资源、JS、CSS等整个包的大小

目标 核心逻辑 整包大小
taro-wx 308K (452%) 336K (323%)
taro-alipay 256K (376%) 276K (265%)
taro-tt 248K (364%) 272K (261%)
taro-lark 248K (364%) 272K (261%)
dom-lark 68K (100%) 104K (100%)

测试解读

  • 微信不知为何,性能表现非常差,我也不再倾向于继续和它进行对比,否则数据会非常夸张。

  • 采用 DOM 接口的开发方式,可以简化掉繁重的 Taro runtime 的实现,从而在速度和体积上获取一定的优势。

  • 相比飞书自身,各种情况下速度至少可以提高一倍以上,体积可以降低到原先的 30%。

  • 相比支付宝,体积上依旧保持优势,渲染速度上的优势略低一些,平均不超过 50% 的提升。但这个问题主要是飞书对内置组件(checkboxbutton)实现的性能较差,如果飞书对这部分进行优化后,相信可以获得更好的优势。

新框架会面临那些问题

虽然新架构的 DOM 接口在上面的测试和预期中会带来很多的优势,但这并不意味着一点问题都没有。

  • 新架构虽然保留了对 XXML 的兼容性,但依旧会产生一定的 Break Chagne,具体可以看飞书官方文档

  • 由于双线程的架构,在逻辑层模拟的那一层 DOM 无法实现任何和渲染结果相关的 API,例如 getComutedStyle 。还包括 querySelector 依旧要通过原先的异步方式获取。

  • 由于 DOM 接口某些能力实现难度较高,例如 innerHTML css parser 等,目前与这些相关的能力只有写入没有读取的能力,如果一定要读取,将无法保证一致性。

  • 目前样式还不支持动态插入,后续会支持在 <page> 或者 <head> 内动态插入 <style> 标签来实现。

  • 首屏性能。这个上文也说过,后续会有很多办法去优化和实现的,但优先级不高

对我来说,我觉得整体瑕不掩瑜,未来可期。

未来规划与设计

  • 目前新框架能力的开启和 DOM 接口的使用还在内测状态,对外的时间暂时无法确定

  • 未来 DOM 接口还有许多的优化方向,例如如何降低 DOM 指令的数量,降低一些重复字符串的发送等等

  • 由于 DOM 接口在一些非 Web 标准组件中和前端框架有一些冲突,例如 React 无法很好的支持 picker 这类需要针对某些属性设置一个对象的情况。后续会推出一些兼容手段去解决这些问题。

  • 主流的前端组件组(例如 antd/element/ud)都无法很好的运行在这个上面。一方面是因为这些组件库多多少少都依赖了前端的一些接口,而小程序没有;另一方面,组件库使用的也都是 Web 的标签元素,而不是小程序的组件。后续会尝试推出一定的解决方案去处理这个问题,能够让社区做较少的修改就可以兼容。

  • 会考虑以飞书官方的身份推出一套适配于 DOM 接口的主流前端组件库,根据情况会选择是否需要添加 WebComponent 能力的支持。

  • 未来飞书小程序的规划都是尽可能的向前端标准实现,未来可能会逐渐添加例如 fetch navigator 在内的 BOM 接口能力支持。

为什么要写这篇文章

  • 希望能促进小程序的前进。
    小程序业务上的成功并不是其可以恶心开发者的理由,也不应该放弃对其优化的决心。我希望我这一篇有点颠覆传统小程序风格的优化文章,能够探索更多的思路和想法。

  • 寻求合作。
    由于 DOM 接口的特性,需要更多社区的配合才能将这个发挥到极致,例如 Taro/Uniapp 等三方框架,Vant 等小程序原生组件库等等。

  • 沟通交流,拓展思路。
    希望能够得到更多人对这件事情的想法和思路,也希望能够得到更多的输入。

注释和共享

本文主要是以「飞书」小程序为准,兼容「微信」小程序,如果没有了解过「飞书」的同学,可以点击此处去官网了解

什么是 NFC

近距离无线通信(英语:Near-field communication,NFC),又简称近距离通信近场通信,是一套通信协议,让两个电子设备(其中一个通常是移动设备,例如智能手机)在相距几厘米之内进行通信。

近场通信技术由非接触式射频识别(RFID)演变而来,由飞利浦半导体(现恩智浦半导体,缩写 NXP)、诺基亚和索尼共同于2004年研制开发,其基础是RFID及互连技术。近场通信是一种短距高频的无线电技术,在13.56MHz频率运行于20厘米距离内。其传输速度有106 Kbit/秒、212 Kbit/秒或者424 Kbit/秒三种。

NFC 其实在刚诞生的时候我就一直在关注,但是不仅仅应用少,而且搭载的设备也少,甚至小米还出现过前一代搭载 NFC 后一代却不搭载的神奇情况。除此之外,使用起来也是特别复杂,想当初,要用 NFC 去实现刷公交卡,你需要去换一个特殊的 SIM 卡才能够支持(当初不理解,现在想来大概率是因为安全问题)。

在现在,随着安卓厂商的不懈努力,现在不论是应用还是设备的安装率都已经逐渐普及开来。从最初 NFC 也就能在支付宝中扫银行卡快速输入卡号,到现在的公交刷卡、X Pay,甚至传输文件,华为甚至给这个东西换了个名字叫做一碰系统(率感无语)。

除此之外,还能推进智能化的发展。比如以后家庭中加入了一个新设备,那么不再需要繁杂的联网过程,直接扫一下机器身上的 NFC 识别码就可以直接将设备加入到家庭网络中。或者说华为路由器上的一碰连接 Wifi 我觉得就是一个极好的应用。当家里来客人的时候,就不再需要一个人一个人的输入密码了。

NFC 技术一览

运行模式

NFC 现在主要有三种运行模式,分别是卡模拟模式(Card Emulation Mode)、主机模拟模式(Host Emulation Mode)、读卡写卡模式(Reader/Writer Mode)、P2P 模式(P2P Mode)

卡模拟模式

  • NFC手机可以模拟成为一张非接触卡,通过 POS 机(非接触读卡器)的 RF 来供电,即使 NFC 手机没电也可以工作。

  • 现在很常见的比如 Apple Pay,BYD NFC 钥匙,都是能够实现在断电情况下的刷卡

主机模拟模式

  • 该模式与卡模拟模式很类似,只不过卡模拟无需供电或者说无需 App 的参与就可以完成,但是主机模拟模式是不行的,他是通过将所有的消息转发给应用,由应用去决定该模拟什么内容,也就说该返回什么内容

  • 现阶段很多支付钱包,比如云闪付、京东闪付等等都是通过该模式实现的。

读卡写卡模式

  • NFC手机可以通过触碰NFC标签(Tag),从中读取非接触标签中的内容,采集数据并发送到对应的应用进行处理。

  • 最常见的应用其实就是华为的一碰系列,除此之外,支付宝支持直接读取信用卡、储蓄卡的卡号。

P2P 模式

  • 两个NFC设备可以近距离内互相连接,直接传递数据,实现点对点数据传输。

  • 例如协助快速建立蓝牙连接、交换手机名片和数据通信等。

  • 最常见的是手机互传、Android Beam。

协议标准类型

因为 NFC 的发展过程的原因,曾经出现过多个协议,甚至每家公司都有不同的协议内容。但现在主要是有一下几个协议标准:

ISO / IEC

主要定义了一下几个协议:

  • ISO/IEC 18092 / ECMA-340— (NFCIP-1)

    Near Field Communication Interface and Protocol-1

  • ISO/IEC 21481 / ECMA-352— (NFCIP-2)

    Near Field Communication Interface and Protocol-2

除此以外,还有一个协议标准很常用,是 ISO-14443 协议,其实这个协议是 RFID 的协议,和上面的唯一区别就是上面的多了一些其他模式的标准,比如点对点模式。

ISO-14443 协议有两个子类,分别是 Type-A 和 Type-B,这两个在 Android 也被称为 NFC-A 和 NFC-B。

但不幸的是,这些协议也不能免费看,要花钱的

NFC Forum

NFC Forum 是一个在 2004 年创建的非盈利行业协会,其成员来自NFC生态系统的各个部分。另外我主要关注了下国内公司,比较知名的有小米、中国移动通讯。

但是你想要从该组织获取任何关于 NFC 相关的技术标准,首先你的公司要成为该组织的成员才行,因为字节根本不在该组织,所以没法从这里获得一手的信息。

不过办法也是有的,该协会的创办者 NXP 公司网站上是有相关的数据资源,后文的参考此资源。

其他

不用管.jpg

沟通协议

以 NFC-A 为例

整个 NFC 卡片其实内部就是一个有限状态机,根据当前不同的状态需要不同的操作。这个图看起来很复杂,其实主要额外包含了两个操作:

  • 密码校验

    • 这个是说 NFC 卡是经过加密的,只有在密码校验通过之后,才能够进行相关的操作。

    • 有一点特殊的是,NFC 的密码长度其实是固定的,即 32 位,4 个字节。

  • 防冲突

    • 之所有有这个设计,是因为在使用过程中,可能会出现同时扫描到多个 NFC 设备的情况,此时就需要通过 READY1/READY2 两个状态来选择正确的 NFC 设备进行操作。

    • 每个卡片都有一个唯一 UID,长度为 7 字节,而每次操作只能选择 4 个字节,所以不得不拆分成两个状态两步去操作。

当没有上面两个操作的时候,可以简单的执行 IDLE -> ACTIVE -> HALT 的状态流程,也就是说连接、操作(也就是读写)、关闭。

存储设计

NFC 在存储上设计了页的概念,一个页表示 4 个字节,以页为最小单位进行操作。所以 NFC 卡片的存储容量其实都是 4 的整数倍。

这里以 NTAG213 180 字节的存储结构为例

这里只需要关注两点:

  • 用户数据存储的空间是从第四页开始

  • 用户可存储空间其实只有 144 字节

只需要记住这两点,在开发 NFC 需求的时候,不要去修改非用户空间的数据,不要存储过长的内容。

设备准备

在有了上面的基础之后,别急你还是不能开始开发 NFC,因为你还缺少至关重要的一个东西,设备

遗憾的是,不是所有的设备都有 NFC 硬件的,也不是说有了 NFC 硬件就能用的

  • 苹果设备只有升级到 iOS 13 以上才能具有开发 NFC 读卡器的能力,不能写入,除此之外,几乎没有其他的 NFC 能力可以使用。机圈也会叫做阉割版 NFC。(暂时没有能力去调研 NFC 的能力一定是需要硬件支持还是说只是软件限制)

  • 安卓设备理论上可以使用几乎所有的 NFC 能力,机圈内叫全功能 NFC,包括读卡、写卡、卡模拟、P2P 等模式。但是不同的手机有着不同的操作系统的限制,所以要选择一个合适的操作系统(原生安卓、类原生安卓是最推荐的)。

所以,请准备好一台安卓手机!

NFC 卡片

在有了设备之后,还要选择正确的 NFC 卡片,因为不是任意一个 NFC 卡片都是可以用的,比如工牌、银行卡等等。这是因为 NFC 卡片是带有加密的,在操作之前必须要通过验证才能操作,所以建议去淘宝买一些可读可写无加密的 NFC 贴纸用于测试。

不过你在淘宝上买的可能是写着 NTAG213 的型号,其实这个是 NXP 出的一款设备,但是支持兼容 ISO NFC-A 协议以及 NFC Forum Type 2 协议,所以大家可以放心使用。

当然了,你也可能看到 NTAG215/NTAG216,这两个都没有任何区别,只是存储空间不同而已。

小程序 API

此时在有了上述的基础知识后,别急,还要了解下小程序的 API 才能更好的开发。

就目前来说,所有与 NFC 相关的操作都被封装到了 NfcAdaptar 类中,通过 tt.getNFCAdaptar() 获取 nfcAfaptar 对象。

具体的 API 参数细节请参考「飞书开放平台

NFC 整体流程

  • 注册 NFC 发现事件回调 nfcAdaptar.onDiscovered

  • 开启 NFC 扫描 nfcAdaptar.startDiscovered

  • NFC 卡片贴近设备

  • 触发回调,通过回调可以获得 NFC 支持的协议

    • 回调参数内的 techs 字段可以用于判断当前卡片支持的协议
  • 根据协议去读写 NFC 卡片内容 nfcAdaptar.getNfcA()

  • 关闭 NFC 扫描,关闭事件监听 nfcAdaptar.offDiscovered / nfcAdaptar.stopDiscovered()

可以发现这个流程非常容易理解,也非常容易操作。那么接下来我们看下重头戏

读写 NFC 卡片

这里以 NFC-A 协议为主

通过 nfcAdaptar.getNfcA() 获取操作 NFC-A 卡片的操作类实例 nfca,流程如下

  • 连接卡片 nfca.connect()

  • 读写卡片 nfca.transceive()

  • 读写完成之后关闭连接 nfca.close()

关键点来了,NFC 卡片的读写不和其他的 IO 设备类似,有专门的 read 和 write 函数。对 NFC 来说,通过给 NFC 卡片发送不同的指令来做到完成不用的操作。

这些指令都在 NTAG213 文档中有写,这里简单列一下常用的数据

命令 代码 功能 参数
Read 0x30 一次读取四个页的数据 <Addr:1B>
Write 0xA2 一次写入一个页的数据 <Addr:1B> <Data:4B>
Fast Read 0x3A 一次读取多个页的数据 <StartAddr:1B> <StopAddr:1B>

比如我要读取第四页的数据,可以写如下代码

1
2
3
4
5
6
7
nfca.transceive({
data: new Uint8Array([0x30, 0x04]).buffer, // 必须要传入 ArrayBuffer
success: (res) => {
// res.data 是 ArrayBuffer,转成数组方便查看
console.log(Array.from(new Uint8Array(res.data))
}
})

根据协议,其实还要传 CRC:2B,也就是校验位,不过这个操作已经由 Android 去做掉了,所以就不需要传了,也不需要去了解校验算法

结语

至此,NFC 开发算是入门了,不过这里要注意不同的 NFC 卡片不同的协议会有不同的读写方式,这里要根据你们各自具体的卡片来看。而且有的还有密码保护,还需要额外再走校验的逻辑。

Refs

注释和共享

因为疫情的原因,在家里实在是无聊,外加最近公司里的事情不是很忙,于是我就开始研究捡垃圾事宜。而且之前在学校薅的 vps 羊毛也快到期了,基本上各大平台都薅过了,没法继续薅了,也使我决定了继续捡垃圾去搞一套家庭服务器。

开门见山,直接说我捡垃圾的结果,总价 3000 左右

  • 主板:华硕 z10pa-u8 10G-2S 12 ¥1250

  • CPU:e5 2660 v3 10 核 20 线程 ¥510

  • 内存:2 16 ECC DDR4 2133 ¥250 2

  • 电源:海韵 550W 全模组金牌电源 ¥450

  • 散热器:超微 E5 2011 服务器专用散热器 ¥155

  • 机箱:航嘉 S400 4u 工控机箱 ¥239

  • 系统:Unraid,暂时是试用版,所以不计入总价。等后面磨合好了会购买正版

  • 硬盘:家里淘汰下来的 500G 垃圾机械,不计入总价

整机装好 unraid 系统后空盘待机功耗 35W 左右,CPU 温度 40 度;系统满载在 130W 左右,温度 75 度左右。整体来说我是非常满意的,因为我另外一台 j1900 的 nas 待机也要 15W,虽然高了 20W 但是带来的性能提升可不止 20W 这么一点。

可能你会有很多疑惑,为什么要搞这个,为什么选用这样的配置,那么接下来让我一一来解释下我为啥选用这套配置,也给想要相同想法的朋友一个选择方案。

目标

在具体讲选择配件过程中,我们现在对齐目标,只有我们的目标相同,才能更好的理解我为什么选择这套配置:

  • 需要一台家庭强性能服务器,用于跑我个人的项目以及一些常用的 app,要求 CPU 核心数足够多,方便跑多任务

  • 服务器单核性能也要足够的强,因为会用来游戏开服,比如 minecraft,这个比较吃单核性能

  • 需要能够较好的以虚拟化的方式运行群辉,并且最好能够支持万兆网络,方便有时候心血来潮拷贝素材剪辑视频等

  • 偶尔要做家庭影音啥的,所以最好能够硬件解码的能力

  • 因为要跑群辉,所以要能够有较多的 SATA 接口,或者足够的 PCI 接口

  • 待机功耗要足够的低,毕竟我还是租房子住,不希望电费太贵

  • 服务器体积尽可能不要太大,同时要保证静音,而且家里有宠物,所以会考虑走线,避免宠物触碰到,所以机箱的选择可能不是很适合所有人

  • 最后的最后,价钱要便宜,挑选起来可就简单多了

CPU

一个服务器的核心就是他的 CPU,只要 CPU 定好之后,其他的配件都可以围绕着它展开。

先说一句,因为我是要做高性能服务器,所以什么 j1900 j3455 奔腾啊这些低功耗的 CPU 全部 pass。

其实挑选 CPU 是我最纠结的地方,因为我有两个自相矛盾的,是核心数的数量和单核性能之间的矛盾,众所周知,服务器级别 CPU 核心数多但单核性能羸弱,而消费级 CPU 核心数少但单核性能强。于是我在服务器和消费级之间来回摇摆,虽然消费级一般不支持 ECC,但是核心强更吸引我。我也一直不能下定决心。我目标的是至少 8 核 16 线程,并且单核性能与现有的消费级别处理器差不多。

说道这里,可能就有人会说 AMD 线程撕裂者不香么。确实,当初看到觉得特别符合我的要求,核心多单核强,但问题就在于这玩意上万块啊,就算是线程撕裂者一代,也要 1w,这对于我来说太难以接受了。

逛了一圈,实在是找不到,于是我不得不降低要求,就是放弃消费级别 CPU。一是因为没有核心数合适的,在锐龙以前的时代,intel 一直在四核心徘徊,就算是在锐龙之后,intel 的核心数也少。而锐龙核心够,但这又牵扯出另外一个问题,就是消费级别的 U 实在是贵啊,7700K 都还 1800 块呢,想要搞个便宜的,只能去找 4 代 3 代的 intel U,但这个时代的 U 和 E5 洋垃圾也差不多。所以最后将目光投向了服务器 E5 洋垃圾

而 E5 最难选择的其实就是 v2 系列还是 v3 系列了。v2 系列意味着可以用 DDR3 内存以及更便宜的主板,但是他的待机功耗要大不少。但 v3 系列相比要用更贵的 DDR4 内存和主板,但他的性能更强,待机功耗更低。具体对比可以看图

可以发现,同样是 2660,v3 比 v2 的性能提升了 20% 还要多,单核心性能比 r5 1600 来说才低了 20% 左右,比我想象中的好多了,一般来说同代的服务器都要比同代的消费级性能至少低 30% 多,如果是更高端的消费级可能要低 50%。而且总分更是比万元的 1900x 一代线程撕裂者还要高。

当然了,这里应该拿 intel 的做对比,拿 AMD 不太恰当,AMD 本身同代单核就比 intel 低不少,不过我手上只有 AMD 的 u,所以就拿 AMD 的来对比了。

那我为啥选择 2660 而不是 2650 或者 2678 呢?其实原因很简单,2650 以上基本就符合我的需求了,但是我发现 2660 竟然比 2650 还要便宜,那为啥不用 2660 呢?如果等以后我对性能有更高要求的时候,再换也不迟。

准系统?

在考虑的过程中,我也曾经看过一些准系统,二手服务器 dell r620 r730xd 准系统、二手的塔式服务器准系统,但都被我 pass 掉了,主要原因是:

  • 二手塔式服务器太贵了,光一个准系统就要 3000+ 了,而且还是 v2 的 u。

  • 机架式的服务器虽然便宜,但是噪音功耗都太大,而且体积也很大,放到哪里都不合适,因为租的房子没有专门的机房或者书房。

  • r620 是 v2 的 u,功耗太大。而 r730xd 又太贵了,最后也 pass 了

主板

既然将准系统 pass 掉之后,我不得不开始自选主板的道路。因为我不会用来做把服务器用来做视频渲染,需要核心多,但不需要那么多,所以这里我主要挑选的是单路主板,而且单路的便宜啊。如果小伙伴需要服务器拿来做视频渲染,建议直接上双路主板。PS:其实自从三代锐龙出现之后,建议视频渲染啥的还是直接上 3900x 3950x 这类吧,E5 做视频渲染已经不香了。

支持 V3 的主板基本有两种,一种是国产的寨板,另外一种就是拆机的服务器主板。

寨板有一个最大的好处,就是便宜,基本上五六百就可以搞定,但是缺点就是可扩展性太差了,内存插槽少,SATA 少,PCI-E 更少,而且还容易 BOOM,最终我放弃了寨板

那就只有拆机服务器主板可以选了,这其中就有微星、华硕的可以选,我最后选定了华硕 z10pa-u8 10g-2s 只有一个原因,便宜。微星的拆机件某宝基本上要 2000 左右,而话说的这个只需要 1400 多,运气好的话还能找到 1200 多的,就比如我下单的这个,而且还是湖北店铺,就当支持湖北朋友了。

简答介绍一下我这个主板,大家来感受下这 1200 块到底值不值:

  • 双板载千兆网卡,双板载万兆网卡,一个 IPMI 管理端口(板载万兆啊,普通的万兆扩展卡都要三四百呢,注意,不是所有的板子都有万兆网卡的,不带 10g-2s 的就没有)

  • 8 条内存插槽

  • 10 SATA 接口(足够我的硬盘使用了,而且 4 个侧插,6 个直插,还是比较丰富的)

  • 板载 m.2 NGFF 接口(因为是上年纪的板子,没有 nvme,不过也很不错了)

  • 双 PCI-Ex16,3 个 PCI-Ex8,一个 PCI-Ex1,不过其中一个 x16 是一个 x8 是共用的,当插了一个 x8 之后,x16 会自动变成 x8。

  • 板上搭载一个 USB,方便直接做启动盘

总的来讲,在单路主板里面,我觉得这个算是比较值的,尤其是板载万兆网卡。

机箱

前面也说了,我不想有一个太大的机箱,所以当时就没想直接买个 2u 机架服务器的机箱。而比较符合的是各种 nas 机箱,比如 8 盘位的,但问题依旧是太贵。8 盘位的要上千了,4 盘位的基本也在五百左右。

于是我就去看了看普通的塔式机箱,基本上比较符合我的心意,最多有 10 盘位的,支持 E-ATX 主板,而且价钱也才 300 多块,最主要是能够支持普通的机箱配件,而且还有一定的热插拔能力。简直太完美了,唯一的缺点就是外观不够有范

直到有一天无意间看到 4u 的工控机箱,发现这玩意好帅气,很符合我对一个服务器的定位。虽然只有 7 盘位,但是配合光驱位也能有 10 盘位。最主要的这个带钥匙,就不用怕我家里的猫一不小心碰到开关就给我关机了。而且体积比塔式的还要小巧一点,毕竟是租的方式,能小一点是一点,不过就是损失了热插拔的能力。好在价格更便宜,而且还躺着,于是心血来潮的我就定了这款机箱。

PS:在我实际装机之后,我觉得奉劝大家,还是塔式的好啊,工控机内部走线实在是太难了,没有热插拔能力测试的时候太难了。不过样子很好看,很有感觉,一次装机之后只要是不加硬盘基本不会动他了,也算是能接受吧。

其他配件

其他的配件基本上就是随便买的,内存选了 2133 频率的,为了保证兼容性。

有个好玩的事情就是电源,原本想买个金牌的 450W 直出电源就够了,毕竟就几个硬盘,最多可能外加一个计算卡,其他的也不会需要了。但正好赶上 618 活动,550W 金牌全模比 450W 金牌直出还便宜,于是我就买了 550W 了。但后来经过朋友提醒,想起来有个最佳转换效率区间,如果负载太低的话,就算是金牌,转换效率也不会太高的,理论上搞个 200W 就够了。

哎,就这样吧,买都买了。

使用

一切装好之后,我就安装了 unraid 作为宿主系统,原因很简单:

  • U 盘就能启动

  • 界面友好,EXSI 实在是有点丑

  • Docker 友好,这点太重要了,作为一个开发,深知 Docker 有多好用

  • 插件丰富,很多东西都能安装

  • 虚拟机太好用了,直通啥的一点问题都没有,而且还支持 XML 编辑,真棒

  • 基于 Linux 系统,直接提供了命令行工具,作为一个开发,能搞的东西太多了,太喜欢了

话不多说,直接一个群辉,一个 debian 虚拟机就搞起来了,把我之前在群辉里面跑的那个 Docker 转移到了 unraid 的 Docker 上。

就此,我心心念的服务器算是告一段落了,接下来就是把云服务器上的业务逐渐迁移到本地来,另外还要折腾下本地域名映射,让泛域名直接解析到内网的网关服务器上,这样就可以通过内网域名直接访问服务器上的业务了。就是内网的域名证书不好搞,用自签名的话需要每一台机器上都要安装根证书,用 CA 签名的吧,泛域名证书太贵了。

注释和共享

概要

本文主要讲解了下我平时在工作开发中遇到的关于 Hooks 的一些缺点和问题,并尝试配合 Mobx 解决这些问题的经历。我觉得两者的配合可以极大的降低开发过程中有可能出现的问题以及极大的提高开发体验,而且学习成本也是非常的低。如果你对 Hooks 以及 Mobx 有兴趣,想知道更进一步的了解,那么这篇文章适合你。这篇文章会介绍如下内容,方便你决定是否要仔细阅读,节省时间:

  • 本文不会介绍太过于基础的内容,你需要对 Mobx 以及 Hooks 有基础的了解

  • 本文介绍了平时开发中的一些最佳实践,方便小伙伴们对两者有更加深入的认识

  • 如果你使用过一部分 Mobx,但是不太了解如何和 Hooks 更好的合作,可以尝试来看看

另外 Hooks 本身真的就是一个理解上非常简单的东西,所以本文也不长,我也不喜欢去写什么万字长文,又不是写教程,而且读者看着标题就失去兴趣了。

Hooks 究竟有什么问题?

首先,在这里我不再说 Hooks 的优点,因为他的优点用过的人都清楚是怎么回事,这里主要讲解一下他存在的缺点,以及如何用 Mobx 来进行改进。

  • 依赖传染性 —— 这导致了开发复杂性的提高、可维护性的降低

  • 缓存雪崩 —— 这导致运行性能的降低

  • 异步任务下无法批量更新 —— 这也会导致运行性能的降低

换句话说,造成这种原因主要是因为 Hooks 每次都会创建一个全新的闭包,而闭包内所有的变量其实都是全新的。而每次都会创建闭包数据,而从性能角度来讲,此时缓存就是必要的了。而缓存又会牵扯出一堆问题。

说到底,也就是说没有一个公共的空间来共享数据,这个在 Class 组件中,就是 this,在 Vue3 中,那就是 setup 作用域。而 Hooks 中,除非你愿意写 useRef + ref.current 否则是没有办法找到共享作用域。

而 mobx 和 Hooks 的结合,可以很方便在 Hooks 下提供一个统一的作用域来解决上面遇到的问题,所谓双剑合并,剑走天下。

Hook1 useObserver

在传统的使用 mobx 的过程中,大家应该都知道 observer 这个 api,对需要能够响应式的组件用这个包裹一下。同样,这个 api 直接在 hooks 中依旧可以正常使用。 但是 hooks 并不推荐 hoc 的方式。自然,mobx 也提供了 hookify 的使用方式,那就是 useObserver

1
2
3
4
5
6
const store = observable({})
function App() {
return useObserver(() => {
return <div>{store.count}</div>
})
}

看到这里,相信使用过 mobx 的应该可以发现,useObserver 的使用几乎和 Class 组件的 render 函数的使用方式一致。事实上也确实如此,而且他的使用规则也很简单,直接把需要返回的 Node 用该 hooks 包裹后再返回就可以了。

经过这样处理的组件,就可以成功监听数据的变化,当数据变化的时候,会触发组件的重渲染。至此,第一个 api 就了解完毕了

Hook2 useLocalStore

简单来讲,就是在 Hooks 的环境下封装的一个更加方便的 observable。就是给他一个函数,该函数返回一个需要响应式的对象。可以简单的这样理解

1
2
3
const store = useLocalStore(() => ({key: 'value'}))
// equal
const [store] = useState(() => observable({key: 'value'}))

然后就没有了,极其简单的一个 api 使用。而后面要讲的一些最佳实践更多的也是围绕这个展开,后文简化使用 local store 代指。

这两个 API 能带来什么?

简单来讲,就是在保留 Hooks 的特性的情况下,解决上面 hooks 所带来的问题。

第一点,由于 local store 的存在,作为一个不变的对象存储数据,我们就可以保证不同时刻对同一个函数的引用保持不变,不同时刻都能引用到同一个对象或者数据。不再需要手动添加相关的 deps。由此可以避免 useCallback 和 useRef 的过度使用,也避免很多 hooks 所面临的的闭包的坑(老手请自动忽略)。依赖传递性和缓存雪崩的问题都可以得到解决

直接上代码,主要关注注释部分

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
// 要实现一个方法,只有当鼠标移动超过多少像素之后,才会触发组件的更新
// props.size 控制移动多少像素才触发回调
function MouseEventListener(props) {
const [pos, setPos] = useState({x: 0, y: 0})
const posRef = useRef()
const propsRef = useRef()
// 这里需要用 Ref 存储最新的值,保证回调里面用到的一定是最新的值
posRef.current = pos
propsRef.current = propsRef

useEffect(() => {
const handler = (e) => {
const newPos = {x: e.xxx, y: e.xxx}
const oldPos = posRef.current
const size = propsRef.current.size
if (
Math.abs(newPos.x - oldPos.x) >= size
|| Math.abs(newPos.y - oldPos.y) >= size
) {
setPos(newPos)
}
}
// 当组件挂载的时候,注册这个事件
document.addEventListener('mousemove', handler)
return () => document.removeEventListener('mousemove', handler)
// 当然这里也可以监听 [pos.x, pos.y],但是性能不好
}, [])

return (
props.children(pos.x, pos.y)
)
}

// 用 mobx 改写之后,这种使用方式远比原生 hooks 更加符合直觉。
// 不会有任何 ref,任何 current 的使用,任何依赖的变化
function MouseEventListenerMobx(props) {
const state = useLocalStore(target => ({
x: 0,
y: 0,
handler(e) {
const nx = e.xxx
const ny = e.xxx
if (
Math.abs(nx - state.x) >= target.size ||
Math.abs(ny - state.y) >= target.size
) {
state.x = nx
state.y = ny
}
}
}), props)

useEffect(() => {
document.addEventListener('mousemove', state.handler)
return () => document.removeEventListener('mousemove', state.handler)
}, [])

return useObserver(() => props.children(state.x, state.y))
}

第二,就是针对异步数据的批量更新问题,mobx 的 action 可以很好的解决这个问题

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
// 组件挂载之后,拉取数据并重新渲染。不考虑报错的情况
function AppWithHooks() {
const [data, setData] = useState({})
const [loading, setLoading] = useState(true)
useEffect(async () => {
const data = await fetchData()
// 由于在异步回调中,无法触发批量更新,所以会导致 setData 更新一次,setLoading 更新一次
setData(data)
setLoading(false)
}, [])
return (/* ui */)
}

function AppWithMobx() {
const store = useLocalStore(() => ({
data: {},
loading: true,
}))
useEffect(async () => {
const data = await fetchData()
runInAction(() => {
// 这里借助 mobx 的 action,可以很好的做到批量更新,此时组件只会更新一次
store.data = data
store.loading = false
})
}, [])
return useObserver(() => (/* ui */))
}

不过也有人会说,这种情况下用 useReducer 不就好了么?确实,针对这个例子是可以的,但是往往业务中会出现很多复杂情况,比如你在异步回调中要更新本地 store 以及全局 store,那么就算是 useReducer 也要分别调用两次 dispatch ,同样会触发两次渲染。而 mobx 的 action 就不会出现这样的问题。// 如果你强行 ReactDOM.unstable_batchedUpdates 我就不说啥了,勇士受我一拜

Quick Tips

知道了上面的两个 api,就可以开始愉快的使用起来了,只不过这里给大家一下小 tips,帮助大家更好的理解、更好的使用这两个 api。(不想用而且也不敢用「最佳实践」这个词,感觉太绝对,这里面有一些我自己也没有打磨好,只能算是 tips 来帮助大家拓展思路了)

no this

对于 store 内的函数要获取 store 的数据,通常我们会使用 this 获取。比如

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
this.count++
}
}))

const { add } = store
add() // boom

这种方式一般情况下使用完全没有问题,但是 this 依赖 caller,而且无法很好的使用解构语法,所以这里并不推荐使用 this,而是采用一种 no this 的准则。直接引用自身的变量名

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
store.count++
}
}))

const { add } = store
add() // correct,不会导致 this 错误
  • 避免 this 指向的混乱

  • 避免在使用的时候直接解构从而导致 this 丢失

  • 避免使用箭头函数直接定义 store 的 action,一是没有必要,二是可以将职责划分的更加清晰,那些是 state 那些是 action

source

在某些情况下,我们的 local store 可能需要获取 props 上的一些数据,而通过 source 可以很方便的把 props 也转换成 observable 的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function App(props) {
const store = useLocalStore(source => ({
doSomething() {
// source 这里是响应式的,当外界 props 发生变化的时候,target 也会发生变化
if (source.count) {}
// 如果这里直接用 props,由于闭包的特性,这里的 props 并不会发生任何变化
// 而 props 每次都是不同的对象,而 source 每次都是同一个对象引用
// if (props.count) {}
}
// 通过第二个参数,就可以完成这样的功能
}), props)
// return Node
}

当然,这里不仅仅可以用于转换 props,可以将很多非 observable 的数据转化成 observable 的,最常见的比如 Context、State 之类,比如

1
2
3
4
5
6
7
const context = useContext(SomeContext)
const [count, setCount] = useState(0)
const store = useLocalStore(source => ({
getCount() {
return source.count * source.multi
}
}), {...props, ...context, count})

自定义 observable

有的时候,默认的 observable 的策略可能会有一些性能问题,比如为了不希望针对一些大对象全部响应式。可以通过返回自定义的 observable 来实现。

1
2
3
4
5
6
7
const store = useLocalStore(() => observable({
hugeObject: {},
hugeArray: [],
}, {
hugeObject: observable.ref,
hugeArray: observable.shallow,
}))

甚至你觉得自定义程度不够的话,可以直接返回一个自定义的 store

1
const store = useLocalStore(() => new ComponentStore())

类型推导

默认的使用方式下,最方便高效的类型定义就是通过实例推导,而不是通过泛型。这种方式既能兼顾开发效率也能兼顾代码可读性和可维护性。当然了,你想用泛型也是可以的啦

1
2
3
4
5
6
7
8
9
// 使用这种方式,直接通过对象字面量推导出类型
const store = useLocalStore(() => ({
todos: [] as Todo[],
}))

// 当然你可以通过泛型定义,只要你不觉得烦就行
const store = useLocalStore<{
todos: Todo[]
}>(() => ({todos: []}))

但是这个仅仅建议用作 local store 的时候,也就是相关的数据是在本组件内使用。如果自定义 Hooks 话,建议还是使用预定义类型然后泛型的方式,可以提供更好的灵活性。

memo?

当使用 useObserver api 之后,就意味着失去了 observer 装饰器默认支持的浅比较 props 跳过渲染的能力了,而此时需要我们自己手动配合 memo 来做这部分的优化

另外,memo 的性能远比 observer 的性能要高,因为 memo 并不是一个简单的 hoc

1
2
3
4
5
6
export default memo(function App(){
const xxx = useLocalStore(() => ({}))
return useObserver(() => {
return (<div/>)
})
})

不再建议使用 useCallback/useRef/useMemo 等内置 Hooks

上面的这几个 Hooks 都可以通过 useLocalStore 代替,内置 Hooks 对 Mobx 来说是毫无必要。而且这几个内置 api 的使用也会导致缓存的问题,建议做如下迁移

  • useCallback 有两种做法

    • 如果函数不需要传递给子组件,那么完全没有缓存的必要,直接删除掉 useCallback 即可,或者放到 local store 中也可以

    • 如果函数需要传递给子组件,直接放到 local store 中即可。

  • useMemo 直接放到 local store,通过 getter 来使用

useEffect or reaction?

经常使用 useEffect 知道他有一个功能就是监听依赖变化的能力,换句话说就是可以当做 watcher 使用,而 mobx 也有自己的监听变化的能力,那就是 reaction,那么究竟使用哪种方式更好呢?

这边推荐的是,两个都用,哈哈哈,没想到吧。

1
2
3
useEffect(() =>
reaction(() => store.count, () => console.log('changed'))
, [])

说正经的,针对非响应式的数据使用 useEffect,而响应式数据优先使用 reaction。当然如果你全程抛弃原生 hooks,那么只用 reaction 也可以的。

组合?拆分?

逻辑拆分和组合,是 Hooks 很大的一个优势,在 mobx 加持的时候,这个有点依旧可以保持。甚至在还更加简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useCustomHooks() {
// 推荐使用全局 Store 的规则来约束自定义 Hooks
const store = useLocalStore(() => ({
count: 0,
setCount(count) {
store.count = count
}
}))
return store
}

function App() {
// 此时这个 store 你可以从两个角度来思考
// 第一,他是一个 local store,也就是每一个都会初始化一个新的
// 第二,他可以作为全局 store 的 local 化,也就是你可以将它按照全局 store 的方式来使用
const store = useCustomHook()
return (
// ui
)
}

App Store

Mobx 本身就提供了作为全局 Store 的能力,这里只说一下和 Hooks 配合的使用姿势

当升级到 mobx-react@6 之后,正式开始支持 hooks,也就是你可以简单的通过这种方式来使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function App() {
return (
<Provider sa={saStore} sb={sbStore}>
<Todo/>
</Provider>
)
}

export function Todo() {
const {sa, sb} = useContext(MobxProviderContext)
return (
<div>{sa.foo} {sb.bar}</div>
)
}

Context 永远是数据共享的方案,而不是数据托管的方案,也就是 Store

这句话怎么理解数据共享和组件通讯呢?举个例子

  • 有一些基础的配置信息需要向下传递,比如说 Theme。而子组件通常只需要读取,然后做对应的渲染。换句话说数据的控制权在上层组件,是上层组件共享数据给下层组件,数据流通常是单向的,或者说主要是单向的。这可以说是数据共享

  • 而有一些情况是组件之间需要通讯,比如 A 组件需要修改 B 组件的东西,这种情况下常见的做法就是将公共的数据向上一层存放,也就是托管给上层,但是使用控制权却在下层组件。其实这就是全局 Store,也就是 Redux 这类库做的事情。可以看出来数据流通常是双向的,这就可以算作数据托管

曾经关注过 Hooks 的发展,发现很多人在 Hooks 诞生的时候开始尝试用 Context + useReducer 来替换掉 Redux,我觉得这是对 Context 的某种曲解。

原因就是 Context 的更新问题,如果作为全局 Store,那么一定要在根组件上挂载,而 Context 检查是否发生变化是通过直接比较引用,那么就会造成任意一个组件发生了变化,都会导致从 Provider 开始的整个组件树发生重新渲染的情况。

1
2
3
4
5
6
7
8
9
10
function App() {
const [state, dispatch] = useReducer(reducer, init)
return (
// 每次当子组件调用 dispatch 之后,会导致 state 发生变化,从而导致 Provider 的 value 变化
// 进而让所有的子组件触发刷新
<GlobalContext.Provider value={{...state, dispatch}}>
{/* child node */}
</GlobalContext.Provider>
)
}

而如果你想避免这些问题,那就要再度封装一层,这和直接使用 Redux 也就没啥区别了。

主要是 Context 的更新是一个性能消耗比较大的操作,当 Provider 检测到变化的时候,会遍历整颗 Fiber 树,比较检查每一个 Consumer 是否要更新。

专业的事情交给专业的来做,使用 Redux Mobx 可以很好的避免这个问题的出现。

如何写好一个 Store

知道 Redux 的应该清楚他是如何定义一个 Store 吧,官方其实已经给出了比较好的最佳实践,但在生产环境中,使用起来依旧很多问题和麻烦的地方。于是就诞生了很多基于 Redux 二次封装的库,基本都自称简化了相关的 API 的使用和概念,但是这些库其实大大增加了复杂性,引入了什么 namespace/modal 啥的,我也记不清了,反正看到这些就自动劝退了,不喜欢在已经很麻烦的东西上为了简化而做的更加麻烦。

而 Mobx 这边,官方也有了一个很好的最佳实践。我觉得是很有道理,而且是非常易懂易理解的。

但还是那个问题,官方在有些地方还是没有进行太多的约束,而在开发中也遇到了类似的问题,所以这里在基于官方的框架下有几点意见和建议:

  • 保证所有修改 store 的操作都只能在 store 内部操作,也就是说你要通过调用 store 上的 action 方法更新 store,坚决不能在外部直接修改 store 的 property 的值。

  • 保证 store 的可序列化,方便 SSR 的使用以及一些 debug 的功能

    • 类构造函数的第一个参数永远是初始化的数据,并且类型保证和 toJSON 的返回值的类型一致

    • 如果 store 不定义 toJSON 方法,那么要保证 store 中的数据不存在不可序列化的类型,比如函数、DOM、Promise 等等类型。因为不定义默认就走 JSON.stringify 的内置逻辑了

  • store 之间的沟通通过构造函数传递实现,比如 ThemeStore 依赖 GlobalStore,那么只需要在 ThemeStore 的构造参数中传入 GlobalStore 的实例即可。不过说到这里,有的人应该会想到,这不就是手动版本的 DI 么。没错,DI 是一个很好的设计模式,但是在前端用的比较轻,就没必要引入库来管理了,手动管理下就好了。也通过这种模式,可以很方便的实现 Redux 那种 namespace 的概念以及子 store

  • 如果你使用 ts 开发,那么建议将实现和定义分开,也就是说分别定义一个 interface 和 class,class 继承 Interface,这样对外也就是组件内只需要暴露 interface 即可。这样可以很方便的隐藏一些你不想对外部暴露的方法,但内部却依旧要使用的方法。还是上面的例子,比如 GlobalStore 有一个属性是 ThemeStore 需要获取的,而不希望组件获取,那么就可以将方法定义到 class 上而非 interface 上,这样既能有良好的类型检查,又可以保证一定的隔离性。

是的,基本上这样就可以写好一个 Store 了,没有什么花里胡哨的概念,也没有什么乱七八糟的工具,约定俗成就足以。我向来推崇没有规则就是最大的规则,没有约束就是最大的约束。很多东西能约定俗成就约定俗成,落到纸面上就足够了。完全没必要做一堆 lint/tools/library 去约束,既增加了前期开发成本,又增加了后期维护成本,就问问你司内部有多少 dead 的工具和库?

俗话说的话,「秦人不暇自哀而后人哀之,后人哀之而不鉴之,亦使后人而复哀后人也」,这就是现状(一巴掌打醒)

不过以上的前提是要求你们的开发团队有足够的开发能力,否则新手很多或者同步约定成本高的话,搞个库去约束到也不是不行(滑稽脸)

缺点?

说了这么多,也不是说是万能的,有这个几个缺点

  • 针对一些就带状态的小组件,性能上还不如原生 hooks。可以根据业务情况酌情针对组件使用原生 hooks 还是 mobx hooks。而且针对小组件,代码量可能相应还是增多。因为每次都要包裹 useObserver 方法。

  • mobx 就目前来看,无法很好在未来使用异步渲染的功能,虽然我觉得这个功能意义不大。某种程度上说就是一个障眼法,不过这个思路是值得一试的。

  • 需要有一定 mobx 的使用基础,如果新手直接上来写,虽然能避免很多 hooks 的坑,但是可能会踩到不少 mobx 坑

总结

Mobx 在我司的项目中已经使用了很久了,但 Hooks 也是刚刚使用没多久,希望这个能给大家帮助。也欢迎大家把遇到的问题一起说出来,大家一起找解决办法。

我始终觉得基于 Mutable 的开发方式永远是易于理解、上手难度最低的方式,而 Immutable 的开发方式是易维护、比较稳定的方式。这两者没必要非此即彼,而 Mobx + React 可以认为很好的将两者整合在一起,在需要性能的地方可以采用 Immutable 的方式,而在不需要性能的地方,可以用 Mutable 的方式快速开发。

当然了,你就算不用 Mobx 也完全没有问题,毕竟原生的 Hooks 的坑踩多了之后,习惯了也没啥问题,一些小项目,我也会只用原生 Hooks 的(防杠声明)。

注释和共享

目录

  1. A Primer on Element Resize
  2. overflow & underflow
  3. 大大大
  4. 小一点
  5. bomb!撞在一起
  6. 你看的见我,又看不见我了,偷偷摸摸搞点事情
  7. ResizeObserver
  8. 还有别的办法么?
  9. 结语
  10. Refs

A Primer on Element Resize

监听元素尺寸的变化一直以来,都是一个很常见的需求,但是又不那么容易去实现。因为浏览器都有实现针对窗口变化的监听,而却没有针对元素变化的监听。这个常常发生在一些内部元素大小变化的情况。

比如飞书 Admin 的管理页面,当左侧侧边栏收起或者展开的时候,右侧的宽度会发生变化,而浏览器的窗口并没有变化。

如果我们从实际角度出发,监听元素的变动其实大部分是为了监听因为窗口变化而导致的大小变化,此时最简单的方案就是直接监听浏览器窗口的 resize 事件,这个就不细说了。

其次针对上文说道的情况,社区有很多的实现方案,但很多基于 JQuery 实现的,而且性能较差,因为是使用计数器去拉取元素宽度。不过会有很多优化方案,比如当点击某些可能会导致宽度发生变化的时候才启动定时器去检查,并且检查一段时间发现没有变化的时候,就停止检查。但是不管怎样,性能都是不太好的。

overflow & underflow

于是,社区中有另外一种检测方案,那就是基于事件检测,用的就是 overflowunderflow 事件。不知道这两个事件是什么的,可以去看 MDN 或者这篇博客

简单来讲,就是当内容超出外部容器的时候,会触发 overflow 事件,当内容又小于容器宽度的时候,会触发 underflow 事件。

那怎么检测呢?也很简单,先从扩大变化检测说起

假设有这样一个容器,长度宽度为 100px,内部有一个元素,长宽分别为 101px,也就是比父容器各大 1px。那么如果你将外部容器扩大的一瞬间,长宽至少会增加一个像素,也就是说至少比内部元素大,≥ 101px,此时会触发 underflow 事件。扩大检测就实现完成了,在检测完成之后重置内部元素大小依旧比外部容器大即可。

同理缩小检测也很简单,外部容器不变,内部元素宽度和父容器一直,当缩小的时候,父容器肯定会小于内部元素,于是触发 overflow。缩小检测也完成了,再重置宽度即可。

看似一切美好,但实际上,这两个事件的兼容到现在都巨差无比。但别担心,还有别的办法

大大大

我们现在考虑下扩大的时候,如果父容器有滚动条,有一样东西是会发生变化的。那就是可滚动区域的大小——这不是废话么。别急,先想想可滚动区域变化的话,什么也会跟着变化呢?

没错,就是父元素的 scrollTopscrollLeft。具体怎么讲呢,这边我们先考虑 scrollTop 的情况。

假设有这样一个容器(绿色),高宽为 100px,内部有一个元素(红色),高 200px,宽 100px,也就是比容器固定大 100px。容器滚动到最底部,此时容器的 scrollTop: 100px,如左图所示。如果此时容器扩大 10px,很明显随着滚动区域的变大,scrollTop 也会自动变化成 90px,如右图。

那么问题来了,scrollTop 怎么监听呢?

神奇就神奇在这里,因为元素尺寸的变化而导致 scrollTop 的变化竟然会触发容器的 scroll 事件,而且这个事件触发还是跨平台的,兼容性甚至可以下探到 ie7

小一点

这个时候你会发现,想要变小一点,那么问题就麻烦了。你会发现上面那个方式,你不管怎么搞,都无法让他在缩小的时候触发 scrollTop 的改变。

但是换个思路来考虑,你会发现另外一种让 scrollTop 发生改变的情况。依旧从上面那个方式来讲,如果容器缩小了 10px,那么内容至少要缩小 11px 才能导致滚动发生变化,那么我们会想到什么呢?

百分比对不对?这里假设设置内容高度为 200%,就可以做到当容器缩小 10px 的时候,内部元素可以缩小 20px,也就是 200px(100px 200%) ⇒ 180px(90px 200%)。如图所示

bomb!撞在一起

把上面两个结合起来,就可以实现监听 resize 事件了。但实际上使用需要处理一些样式问题,不过这个问题很简单,不单独说了。

如果前面你能看懂,那么到这里你心中有没有考虑一个情况,就是在重置的时候,会导致 scrollTop/Left 又发生了修改,从而又导致的 scroll 事件的触发,而这个怎么消除呢?

有这么两种办法:

  1. 通过检测元素是否发生变动,如果有变动那么就是因为元素缩放导致的,而如果没有缩放,那么就是通过重置导致的。

  2. 通过 raf,在同一帧内,先触发因为元素变动导致的 scroll,再触发因为重置导致的事件,然后将对事件的处理统一推迟到下一阵开始的时间。从而消除循环事件

你看的见我,又看不见我了,偷偷摸摸搞点事情

众所周知,当你给一个元素设置 display:none; 的时候,元素不会渲染,同理也不会监听外部容器尺寸的变化。那么这就给了被监听元素可乘之机,他可以让 DOM 先隐藏,然后再改变大小,再显示 DOM,此时尺寸就乱了,于是就无法很好的监听 resize 了。

不过,也不是没有办法。

当 DOM 显示在浏览器的时候,会执行一个操作——播放动画

而动画事件是可以被监听到的,于是骚操作来了,给 DOM 元素添加一个 1ms 的动画,然后监听动画开始的事件,然后重置并检测大小变化。

ResizeObserver

这么通用的需求,怎么能逃过 w3c 的双眼,于是就推出了 ResizeObserver,方便做 resize 的监听。

而这个 api 所能做的远比我们想象的强大很多:

  • 能够监听不同 box-sizing 的变化,比如 border-box/content-box

  • 能够监听元素挂载和卸载

  • 能够监听非容器类型的 DOM,比如 input canvas 等等

正好说到了这个,顺便讲下一个有意思的事情。自古监听,从来没逃过循环计算的问题,大家熟悉的 angular 脏检查的次数限制,而 ResizeObserver 是怎么解决这个问题的呢。

这里先说明几个概念:

  • observer 每一个 ResizeObserver 实例

  • observation 当 observer 实例调用 observe 方法之后创建的每一个监听,每个 observer 可能包含多个 observation

  • target 被监听的 DOM 元素

  • depth 深度,表示一个 DOM 元素距离根元素的距离,在网页中,也就是距离 html 标签的距离。

在每一次 Event Loop 中,会检查每一个 observationtargetdepth ,并取一个最小值。然后顺便检查有那些 observation 产生了变化,并创建对应的 entity ,最后作为参数传给 observer 的回调。当上面这一操作之后,就完成了一轮检测,然后会再重复一遍这样的操作,只不过这次有个要求,不仅仅要求 observation 有变化,还要要求对应的 depth 比上次检查的最小值还要大,才可以创建 entity。就这样一直一直循环检测跑下去,直到没有任何东西被检测到发生变化。

用一点通俗的话来说,除了第一轮的检查外,其他的每一轮检查都要求元素的高度要大于在上一轮检查元素的高度最小值,从而保证每一次检查,深度都会越来越大,直到达到最小的根节点,进而检查结束。

不过也许你会好奇,难道有些元素就会被跳过不检查么?其实不会的,对于那些深度小于上一次的最小深度的 observation 会自动到下一个 Event Loop 的时机去检查。

以上内容确实有点绕,我自己写的再看第二遍都看不懂了,原本好像补一个图仔细讲讲,但是发现上图也讲不清,算了弃疗,大家有兴趣的看看 Spec 吧。还有就是兼容性有点差,微软家的和苹果家的都不支持,摊手

还有别的办法么?

当然有了,还有我们 IntersectionObserver 呀。

怎么用?和最上面使用 scroll 的差不多,但是要略微简单一些,其实也没简单多少,因为该 api 判断的是相交的面积,也就是不能通过一个哨兵来判断,而是一个方向需要有一个哨兵。

至于兼容性么,除了 IE 不支持,其他都支持,这就很完美了么,对吧。(诡异的双眼,实际上坑很多,不建议尝试)

结语

本文没有特别深入的去讲解一些细节,只是讲了一下我觉得有意思的地方,如果你觉得哪里不清楚或者想了解更多,可以尝试去看源码。/ 我又没说包教包会 /

另外,我也是没懂为啥各大浏览器厂商这次这么一致,针对容器大小变化导致的 scrollTop 变化会触发 scroll 事件。可能是我太年轻了吧,这种上古事件的在各大浏览器之间的爱情纠葛我实在是无从考证。

另外,上面说 HC 很多,要多多推荐人,我也不喜欢专门打广告这种事情,但……杭州 Lark 部门招人,前端后端设计产品,应该(因为我也不确定,逃)是都要的。另外我觉得字节跳动是个好公司,如果你有其他部门或者其他城市的部门想要内推或者了解详情的,我也可以帮忙引荐下,只要进了字节我觉得就是对字节最大的贡献,不一定要来我们部门的,滑稽脸。

Refs

注释和共享

自从 Hooks 诞生以来,官方就有考虑到了性能的问题。添加了各种方法优化性能,比如 memo、hooks deps、lazy initilize 等。而且在官方 FAQ 中也有讲到,Function 组件每次创建闭包函数的速度是非常快的,而且随着未来引擎的优化,这个时间进一步缩短,所以我们这里根本不需要担心函数闭包的问题。

当然这一点也通过我的实验证实了,确实不慢,不仅仅是函数闭包不慢,就算是大量的 Hooks 调用,也是非常快的。简单来说,1 毫秒内大约可以运行上千次的 hooks,也就是 useState useEffect 的调用。而函数的创建,就更多了,快的话十万次。

很多人都觉得既然官方都这么说了,那我们这么用也就好了,不需要过分担心性能的问题。我一开始也是这样想的。但是直到最近有一次我尝试对公司项目里面一个比较复杂的组件用 Hooks 重写,我惊奇的发现重渲染时间竟然从 2ms 增长到了 4ms。业务逻辑没有任何变化,唯一的变的是从 Class 变成了 Hooks。这让我有点难以相信,我一直觉得就算是慢也不至于慢了一倍多吧,怎么着两者差不多吧。于是我开始无差别对比两个写法的性能区别。

懒人阅读指南

我相信肯定很多懒人不想看下面的分析,想直接看结果。没问题,满足你们,直接通过目录找到最后看「总结」就好了,如果你觉得有问题或者觉得我说的不对,可以重新仔细阅读一下文章,帮我指出哪里有问题。

为什么有这篇文章

其实我原本不是很想写一篇文章的,因为我觉得这个只是很简单的一个对比。于是我只是在掘金的沸点上随口吐槽了两句,结果……我决定写一篇文章。主要是觉得这群人好 two,就算是质疑也应该先质疑我的测量方式,而不是说我的使用方式。都用了这么多年了,还能用错)滑稽脸

不过既然要写,就写的完备一些,尽量把一些可能的情况都覆盖了,顺便问问大家是否有问题。如果大家对下面的测试方法或者内容有任何问题的话,请大家正常交流哦,千万不要有一些过激或者偏激的言论。因为性能测试这东西,一人一个方法,一人一个想法。

既然说道这里,其实有一点我要说,沸点里面说到的 50% 这个测量数据确实有些问题。主要有这么几个原因,第一,我当初只是想抱着试试的心态,于是就直接在开发模式下运行的。第二,平时写代码写习惯了,就直接用了 Date.now() 而没有使用精度更高 performance.now() 从而导致了误差略微有点大。虽然误差略大,但是大方向还是没错的

后文的测试中,我也将这些问题修复了,尽量给大家一个正确的数据。

开始之前,我们要知道……

假设现在有 HookCompClassComp 两个组件分别表示函数式组件和类组件,后文用 Hook(HC) 和 Class(CC) 代替。

功能定义

为了更加贴近实际,这里假设两个组件都要完成相同的一个功能。那就是用户登录这个流程:

  • 有用户名输入框和密码输入框

  • 有一个登录按钮,点击之后校验用户名是否为 admin 且密码为 admin

  • 如果校验成功,下方提示登录成功,否则提示用户名或者密码错误

  • 每次输入内容,都将清空内容

  • 另外为了消除误差,额外添加一个按钮,用于触发 100 次的 render,并 log 出平均的渲染时间。

具体的业务逻辑的实现,请看后面的 DEMO 地址。

另外因为 Class 组件有 setState 可以自动实现 batch 更新,但是 Hook 不行,所以这里实现的时候把所有的更新操作都放在 React 事件中同步更新,众所周知,React 的事件是自带 batch 更新的,从而保证只有一次渲染。保证两者功能效果一致。

对比常量

  • 2018 款 15 寸 MacBook Pro 入门款,i7-8750H 6 核 12 线程 + 16g + 256g

  • Chrome Stable 79.0.3945.117

  • react 16.12.0 PS: 其实我从 16.8.0 就开始测试了,懒癌发作一直没有继续搞

  • react-dom 16.12.0

    React 全家桶版本全部使用生产模式,降低开发模式的影响。

衡量标准:从函数调用到渲染到 DOM 上的时间

这个时间其实当组件量非常大的时候其实是不准的,因为大家调用的时间是不同的,但是渲染到 DOM 上的时间基本是一致的,就会导致在组件树越浅越前的组件测量出来的时间就会越长。但是这里的情况是页面只有一个对比组件,所以可以暂时用这个作为衡量标准。

针对 HC 来说

  • 在组件运行的一开始就记录为开始时间

  • 使用 useLayoutEffect 的回调作为结束时间。该 Hook 会在组件挂载或者更新 DOM 之后同步调用。而 useEffect 会在下一个 tick 调用,如果使用该 hook 就会导致最终测量出来的结果普遍慢一些。

1
2
3
4
5
function Hooks() {
const now = performance.now()
useLayoutEffect(() => console.log(performance.now() - now))
return (/* ui */)
}

针对 CC 来说

  • 当运行 render 方法的时候,记录时间

  • 当运行 componentDidUpdate 或者 componentDidMount 的时候,打印耗时。这两个钩子都是在组件挂载或者更新 DOM 之后同步调用,与 useLayoutEffect 调用时机一致。

1
2
3
4
5
6
7
8
9
class Class extends Component {
componentDidMount = () => this.log()
componentDidUpdate = () => this.log()
log = () => console.log(performance.now() - this.now)
render() {
this.now = performance.now()
return (/* ui */)
}
}

测试流程和结果计算

  • 页面刷新,此时要针对测试内容先进行 5 轮预热测试。目的是为了让 Chrome 对热区代码进行优化,达到最高的性能。

  • 每一轮包含若干次的渲染,比如 100 次或者 50 次,对于每一轮测试,都会抛弃 5% 最高和最低一共 10% 的数据,只保留中间的值,并对这些值计算平均值得到该轮测试结果

  • 然后进行 5 轮正常测试,记录每次的结果,统计平均值。

  • 将此时的值计算作为最终的数据值

DEMO 地址

PS: CodeSandBox 似乎不能以生产模式运行,不过你可以将它一键部署到 ZEIT 或者 netlify 上面,查看生产环境的效果。

开胃菜-重渲染测试结果

最为开胃菜,用一个最常见的场景来测试实在是最合适不过了,那就是组件的重渲染。话说不多,直接上测试结果

Avg. Time(ms) Hook Slow Hook(Self) Class Class(Self) Hook Self
第五次平均时间 0.171808623414 0.04126375367107627 0.1941208809532307 0.024725271102327567 0.22747252228577713 0.668889837468
第四次平均时间 0.1696739222 0.04082417709159327 0.18879122377096952 0.02120880942259516 0.22082417118516598 0.924868873031
第三次平均时间 0.160409555184 0.04109888910674132 0.1931868181410399 0.022967028748858104 0.22417582970644748 0.789473490728
第二次平均时间 0.130965058748 0.045824176785382593 0.2072527365001676 0.02346153545019391 0.23439560331158585 0.95316188416
第一次平均时间 0.216216175927 0.04549450906259673 0.20939560484263922 0.02357143663115554 0.2546703217776267 0.93006942148

简单解释下数据,Hook 和 Class 是通过上面规定的方式统计出来的数据,而 Hook(Self) Class(Self) 是计算了 HC 和 CC 函数调用的时间,最后的 Self 和 Hook Slow 则是 Hook 相比 Class 慢的百分比。这里只需要关注不带 Self 的数据即可。

让我们来细细「品味」一下,Hook 比 Class 慢了 16%。

等等??? 16%,emmm……乍一听这是一个多么惊人的数字,5 % 的性能降低都很难接受了,何况是 16%。如果你的页面中有上百个这样组件,想想都知道……咦~~~那酸爽

Wait!!! 或许有人会说了,抛开数值大小谈相对值,这根本就是耍流氓么。每个组件组件都是毫秒级别的渲染,这么小的级别作比较误差也会很大。而且你的测试的测量方式真的很对么?为啥看到很多文章说 Hooks 性能甚至比 Class 组件还高啊。而且你这个测量真的准确么?

这里先回答一下测量的问题,上面也说了,useLayoutEffect 和 CDU/CDM 基本是一致的,而且为了佐证,这里直接上 Performance 面板的数据,虽然只能在开发模式下才能看到这部分数据,但依旧具有参考意义

当然因为我这里只是截取了一个样例,没法给大家一个平均的值,但是如果大家多次尝试可以发现就算是 React 自己打的标记点,Class 也会比 Hook 快那么一点点。

而针对更多的疑问,这里我们就基于这个比较结果,引申出更多的比较内容,来逐步完善:

  • 挂载性能如何?也就是第一次渲染组件

  • 大量列表渲染性能如何?没准渲染的组件多了,性能就不会呈现线性叠加呢?

  • 当 Class 被很多 HOC 包裹的时候呢?

其他对比

挂载性能

通过快速卸载挂载 40 次计算出平均时间,另外将两者横向布局,降低每次挂载卸载的时候 Chrome Layout&Paint 上的差异。话不多说,直接上结果

Avg. Time(ms) Hook Slow(%) Hook(Self) Class(Self) Hook Class
第三次平均时间 0.100681682204 0.04797298587053209 0.024729729252489837 0.5672973001728187 0.5154054158845464
第二次平均时间 0.137816482105 0.041216209128096294 0.02486483395301007 0.6013513618224376 0.5285134916571347
第四次平均时间 0.009446076914 0.04378377736822979 0.025405410073093465 0.5343243404216057 0.5293243023491389
第五次平均时间 0.05774346214 0.041081066671255474 0.025540552529934292 0.5371621495263802 0.5078378347428264
第一次平均时间 0.036722530281 0.04027024968653112 0.025810805980015446 0.5608108209295047 0.5409459180727199

通过交替运行连续跑 5 轮 40 次测试,可以得到上面这个表格。可以发现,不管那一次运行,都是 Class 时间会少于 Hook 的时间。通过计算可得知,Hook 平均比 Class 慢了 (0.53346 - 0.49811) / 0.49811 = 7%,绝对差值为 0.03535ms。

这个的性能差距可以说是很少了,如果挂载上百个组件的时候,两者差距基本是毫秒内的差距。而且可以看出来,绝对值的差距可以说是依旧没有太多的变化,甚至略微微微微减少,可以简单的认为其实大部分的时间依旧都花费在一些常数时间上,比如 DOM。

大列表性能

通过渲染 100 个列表的数据计算出平均时间。

Avg. Time(ms) Hook(500) Hook Hook Slow(%,500) Hook Slow(%) Class(500) Class
第二次平均时间 9.59723405143682 2.6090425597701934 0.10286063613 0.104480973312 8.702127664211266 2.3622340473168073
第三次平均时间 9.64329787530005 2.5888297637488615 0.10438723417 0.104028668798 8.731808533218313 2.344893603684737
第一次平均时间 9.55063829376818 2.5251063647026077 0.085798601307 0.081415992606 8.795957447604296 2.335000020313136
第五次平均时间 9.597553207756992 2.571702087694343 0.10075770158 0.15273472846 8.719042523149797 2.230957413012994
第四次平均时间 9.604468084673615 2.567340426662184 0.095974553092 0.0995534837 8.76340427574642 2.334893631570517

我们先不计算下慢了多少,先看看这个数值,100 次渲染一共 2ms 多,平均来说一次 0.02ms,而而我们上面测试的时候发现,单独渲染一个组件,平均需要 0.2ms,这中间的差距是有点巨大的。

而如何合理解释这个问题呢?只能说明在组件数小的时候,React 本身所用的时间与组件的时间相比来说比例就会比较大,而当组件多了起来之后,这部分就变少了。

换句话说,React Core 在这中间占用了多少时间,我们不得而知,但是我们知道肯定是不少的。

HOC

Hook 的诞生其实就是为了降低逻辑的复用,简单来讲就是简化 HOC 这种方式,所以和 Hook 对线的其实是 HOC。最简单的例子,Mobx 的注入,就需要 inject 高阶组件包裹才可以,但是对于 Hook 来讲,这一点完全不需要。

这里测试一下 Class 组件被包裹了 10 层高阶组件的情况下的性能,每一层包裹的组件做的事情非常简单,那就是透传 props。

啥?你说根本不可能套 10 层?其实也是很容易的,你要注意这里我们所说的 10 层其实是指有 10 层组件包裹了最终使用的组件。比如说大家都知道 mobx inject 方法或者 redux 的 connect 方法,看似只被包裹了一层,其实是两层,因为还有一层 Context.Consumer。同理你再算上 History 的 HOC,也是一样要来两层的。再加上一些其他的东西,再加一点夸张不就够了,手动滑稽)

Avg. Time(ms) Class With 10 HOC
第五轮 0.25384614182697546
第四轮 0.27269232207602195
第二轮 0.2821977993289193
第三轮 0.278846147951189
第一轮 0.2710439444898249

这结果也就是很清楚了吧,在嵌套较多 HOC 的时候,Class 的性能其实并不好,从 0.19855ms 增加到 0.27173ms,时间接近有 26% 的增加。而这个性能不好并不是因为 Class,而是因为渲染的组件过多导致的。从另一个角度,hook 就没有这种烦恼,即使是大量 Hook 调用性能依旧在可接受范围内。

量化娱乐一下?

有了上面的数据,来做一个有意思的事情,将数据进行量化。

假设有如下常数,r 表示 React 内核以及其他和组件数无关的常数,h 表示 hook 组件的常数,而 c 表示 Class 组件的常数,T 表示最终所消耗的时间。可以得知这四个参数肯定不为负数。

通过简单的假设,可以得到如下等式:

1
2
3
T(n,m) = hn + cm + r
// n 表示 hook 组件的数量
// m 表示 class 组件的数量

想要计算得到 r h c 参数也很–简单–,简单个鬼,因为数据不是准确的,不能直接通过求解三元一次方程组的方式,而是要通过多元一次拟合的方式求得,而我又不想装 matlab,于是千辛万苦找到一个支持在线计算多元一次方程的网站算了下,结果如下:

1
2
3
4
5
h = 0.0184907294
c = 0.01674766395
r = 0.4146159332
RSS = 0.249625719
R^2 = 0.9971412136

这个拟合的结果有那么一点点差强人意,因为如果你把单个 Class 或者 Hook 的结果代入的话,会发现偏差了有一倍多。所以我上面也说道只是娱乐娱乐,时间不够也没法细究原因了。不过从拟合的结果上来看,也能发现一个现象,那就是 h 比 c 要大。

另外观察最后的拟合度,看起来 0.99 很大了,但实际上并没有什么意义。而且这里数据选取的也不是很好,做拟合最好还是等距取样,这样做出来的数据会更加准确。这里只是突然奇想想要玩玩看,所以就随便做了下。

总结

不管你是空降过来的还是一点点阅读到这里的,我这边先直接说基于上面的结论:

  • 当使用 Hook 的时候,整体性能相比 Class 组件会有 10 - 20% 的性能降低。

  • 当仅仅使用函数式组件,而不使用 Hook 的时候,性能不会有降低。也就是说可以放心使用纯函数式组件

  • Hook 的性能降低不仅仅体现在渲染过程,就算是在第一次挂载过程中,也相比 Class 有一定程度的降低

  • Hook 的性能降低有三部分

    • 第一部分是 Hook 的调用,比如说 useState 这些。但是这里有一点需要注意的是,这里的调用指的是有无,而不是数量。简单来说就是从 0 到 1,性能降低的程度远远高于 从 1 到 n。

    • 第二部分是因为引入 Hook 而不得不在每次渲染的时候创建大量的函数闭包,临时对象等等

    • 第三部分是 React 对 Hook 处理所带来的额外消耗,比如对 Hook 链表的管理、对依赖的处理等等。随着 Hook 的增加,这些边际内容所占用的时间也会变得越来越大。

  • 但 Hook 有一点很强,在逻辑的复用上,是远高于 HOC 方式,算是扳回一局。

所以 Hook 确实慢,慢的有理有据。但究竟用不用 Hooks 就全看,我不做定夺。凡事都有两面,Hooks 解决了 Class 一些短板,但是也引入了一些不足。如果一定要我推荐的话,我推荐 Hooks+Mobx。

Refs

One More

以上内容是我花了快一个月一点点整理出来的,甚至还跨了个与众不同的「年」。性能测试本身就是一个很有争议的东西,不同的写法不同的测试方式都会带来不同的结果。我也是在这期间一点点修改我的测试内容,从最开始只有单组件测试,到后来添加了组件列表的测试,以及挂载的测试。另外对数据收集也修改了很多,比如多次取平均值,代码预热等等。每一次修改都意味着所有测试数据要重新测试,但我只是想做到一个公平的对比。

就在现在,我依旧会觉得测试里面有很多内容依旧值得去改进,但是我觉得拖的时间太长了,而且我认为把时间花在从源码角度分析为什么 Hook 比 Class 慢上远比用数据证明要有意义的多。

注释和共享

作为一个喜欢折腾的人,个人搞了很多东西放在自己的服务器上,但是为了方便,能够在世界各地随时随地的打开查看和使用,我将服务器暴露到了公网中,当然了有些在公有云上的本来就暴露出来的。

那么这里就有一个问题,我如何保护我的信息只能我来查看呢?

  • 最简单的方法就是通过 HTTP Basic Auth + HTTPS。记住一定要上 https,否则你的密码也是会泄漏的。为什么说简单呢?因为只需要在 Nginx 或 Traefik 上配置下就可以了。但是这个方案有一个非常麻烦的问题,就是过一段时间之后就要输入用户名和密码。时间短了,到无所谓,时间一长就会觉得很烦。

  • 构建一套 token 验证体系,不管是使用 oauth 也好还是 jwt 也好,都是可以的。安全性也是可以保证的,而且设置好 token 的时间长度,也能保证避免频繁的输入密码。但是这有一个问题就是实现起来太过于复杂,都快赶上公司的一套系统了。而且还要有各种登录页面,想想都烦。

  • 与上面类似,不过验证方式使用 Two Auth,也就是基于时间的 6 位数组。但是依旧比较复杂。

  • 使用 OpenVPN 的方式。这在一定程度上也能使用,但是对于我来说,OpenVPN 的限制还是比较大的。首先安卓手机无法开启两个 VPN,而且我也不能一直连着 VPN,因为我会部署一些经常用的服务。而且我不是为了能够连接到内网,而是想对外网使用的服务添加验证。

我想了许久,有没有一种不需要输入密码,就可以验证安全的呢?因为是我一个人使用的,所以我根本不需要多用户系统,也就是说验证方式只需要一个密码就可以了。这我突然想起了之前在写 gRPC 的时候有一个双向验证的参数,也可以验证客户端可以不可以。当时觉得只是他们基于 h2 改的协议,结果我一查发现这原来就包含在 https 里面,准确说是 SSL 规范里面。(怪自己当初上计算机网络的时候没好好学这部分,竟然连这个都不知道)

那么至此,思路就很清晰了,给我的所有个人服务都添加 https 客户端校验。只要我的证书够安全,我的页面就是安全的(反正都是我个人的东西,直接拿着 U 盘到处拷贝,手机 Pad 用数据线发送,我就不信这样谁还能盗走我的证书,傲娇脸)

关于 SSL 证书的一些知识

  • 生成证书我们主要采用 openssl 具体的安装教程我就不讲解了,有兴趣的小伙伴自行查阅,主要有下面几个步骤:

    • openssl genrsa:生成 Private Key,用于生成请求文件使用,这里用 .key 后缀。

    • openssl req:依赖上面生成的 Key 去生成 CSR,也就是证书请求文件。使用 .csr 后缀。这期间要填写一些信息,前面的几个大写字母是缩写,后面在命令行使用的时候会用到。

      • C(Country) 国家

      • ST(State/Province) 州或者省

      • L(Locality) 地区,国内写区即可

      • O(Organization) 组织

      • OU(Organization) 组织单位

      • CN(Common Name) 通用名,这个是非常重要的,影响了证书的显示名称和 HTTPS 的域名。

    • openssl x509:根据 x509 规范,利用 CA 的证书和私钥将 CSR 文件加密成真正可以使用到的证书。使用 .crt 后缀

  • SSL 证书必须要采用 sha-2 加密算法。2015 年 12 月 31 日前,CA 机构还会颁发 SHA-1 签名的证书,但是之后只会签发 SHA-2 签名的证书了。Chrome 也会对 SHA-1 签名的证书提示不安全。在 openssl 中用的是 -sha-256 参数。

  • CRTPEM 的关系,大家可以简单的认为 PEM 是将证书 base64 之后的文件,而 CRT 是既能 base64 也能 binary 的一种文件格式。但是通常 openssl 产出的是 base64 的文件,你可以通过 -outform 参数控制产出的类型。

CA 的生成

有了 CA 我们才能去给其他的证书签名,生成 CA 的过程很简单

创建根钥

💡 这个秘钥非常重要,任何获得了这个秘钥的人在知道密码的情况下都可以生成证书。所以请小心保存

1
openssl genrsa -des3 -out root.key 4096
  • -des3 标明了私钥的加密方式,也就是带有密码。建议添加密码保护,这样即使私钥被窃取了,依旧无法对其他证书签名。你也可以更换其他的加密方式,具体的请自行 help。

  • 4096 表示秘钥的长度。

创建自签名证书

因为是 CA 证书,所以没法让别人去签名,只能自签名。这里可以认为是生成 CSR 和签名两部合成一步走。

1
openssl req -x509 -sha256 -new -key root.key -sha256 -days 1024 -out root.crt

服务端证书生成

生成证书私钥

1
openssl genrsa -out your-domain.com.key 2048

和 CA 证书不同,这个私钥一般不需要加密,长度也可以短一些。

生成证书请求文件

1
openssl req -new -key your-domain.com.key -out your-domain.com.csr

这期间要填入一些信息,注意 CN 的名字一定要是你的域名。

使用 CA 对 CSR 签名

在 Chrome 58 之前,Chrome 会根据 CN 来检查访问的域名是不是和证书的域名一致,但是在 Chrome 58 之后,改为使用 SAN(Subject Alternative Name) 而不是 CN 检查域名的一致性。

而 SAN 属于 x509 扩展里面的内容,所以我们需要通过 -extfile 参数来指定存放扩展内容的文件。

所以我们需要额外创建一个 your-domain.com.ext 文件用来保存 SAN 信息,通过指定多个 DNS 从而可以实现多域名证书。

1
2
3
4
5
6
subjectAltName = @alt_names

[alt_names]
DNS.1 = your-domain.com
DNS.2 = *.your-domain.com
DNS.3 = *.api.your-domain.com

以此类推,如果域名较少,还可以用另外一种简写方案。

1
subjectAltName = DNS: your-domain.com, DNS: *.your-domain.com

关于语法的更多内容请查看官方文档。在有了 ext 文件之后就直接可以开始签名了。

1
openssl x509 -req -sha256 -in your-domain.com.csr -CA root.crt -CAkey root.key -CAcreateserial -out your-domain.com.crt -days 365 -extfile your-domain.com.ext

CAcreateserial 这个参数是比较有意思的,意思是如果证书没有 serial number 就创建一个,因为我们是签名,所以肯定会创建一个。序列号在这里的作用就是唯一标识一个证书,当有两个证书的时候,只有给这两个证书签名的 CA 和序列号都一样的情况下,我们才认为这两个证书是一致的。除了自定生成,还可以通过 -set_serial 手动指定一个序列号。

当使用 -CAcreateserial 参数之后,会自动创建一个和 CA 文件名相同的,但是后缀是 .srl 的文件。这里存储了上一次生成的序列号,每次调用的时候都会读取并 +1 。也就是说每一次生成的证书的序列号都比上一次的加了一。

现在,只需要将 your-domain.com.crtyour-domain.com.key 放到服务端就可以使用了。别忘了将 CA 添加系统当中,要不然浏览器访问会出现问题。

客户端证书生成

服务端有了之后,就需要生成客户端的证书,步骤和服务端基本一致,但是不需要 SAN 信息了。

1
2
3
4
5
6
7
openssl genrsa -out client.key 2048
# 这里也可以采用非交互形式,方便制作成命令行工具
openssl req -new \
-key client.key \
-subj "/C=CN/ST=Zhejiang/O=X/CN=*.your-domain.com" \ # 这里的缩写就是文章一开始所说的那些缩写
-out client.csr
openssl x509 -req -in client.csr -CA root.crt -CAkey root.key -out client.crt -days 365

只不过客户端验证需要的是 PKCS#12 格式,这种格式是将证书和私钥打包在了一起。因为系统需要知道一个证书的私钥和公钥,而证书只包含公钥和签名,不包含私钥,所以需要这种格式的温江将私钥和公钥都包含进来。

1
openssl pkcs12 -export -clcerts -in client.crt -inkey client.key -out client.p12

这期间会提示你输入密码,用于安装的时候使用。也就是说不是什么人都可以安装客户端证书的,要有密码才行,这无疑又增加了一定的安全性。当然了,我试过不输入密码,但是好像有点问题,有兴趣的同学可以自己尝试下。

客户端校验证书的使用

这里以 Node.js 举例。使用 https 模块,在创建的时候和普通的创建方式基本一致,但是需要额外指定 requestCertca 参数来开启客户端校验。

1
2
3
4
5
6
7
8
https.createServer({
key: fs.readFileSync('your-domain.com.key'),
cert: fs.readFileSync('your-domain.com.crt'),
requestCert: true,
ca: [fs.readFileSync('root.crt')], // 校验客户端证书的 CA
}, (req, resp) => {
// blahblah
})

这样只要客户端没有安装合法的证书,那么整个请求就是失败的。而且根本不会进入请求处理的回调函数中,这也意味着显示的错误是浏览器的默认错误。那么这对用户来讲其实不太友好。

那么我们可以通过在参数中添加 rejectUnauthorized: false 来关闭这个功能,也就是说不管客户端证书校验是正确还是失败,都可以进入正常的回调流程。此时我们只需要通过 req.client.authorized 来判断这个请求是否通过了客户端证书的校验,可以给予用户更详尽的错误提示。

另外我们还可以通过 resp.connection.getPeerCertificate() 获取客户端证书的信息,甚至可以根据不同的信息选择给予不同的用户权限。

这里有一个 DEMO: https://www.xgheaven.net.cn:3443,大家打开之后应该会看到一个证书校验失败的提示。这里要说下,我这里的 DEMO 没有使用自签名的服务端证书,只是使用了自签名的 CA 去检查客户端证书。因为用自己签名的服务端证书的话,浏览器会提示不安全,因为用户么有安装自签名的 CA。

可以点击下载客户端证书按钮,安装客户端证书。因为客户端证书是有密码保护的,请输入页面上提示的密码。

再次刷新,如果是 Mac 系统,会提示你要使用哪个客户端证书登录,此时就说明安装成功了。

点击确认,可能还要输入一个系统密码允许 Chrome 访问 Keychain,一劳永逸的话在输入密码之后选择 Always Allow,从此就不需要再输入密码了。

按照道理,你就可以看到这个页面了。

结语

有了这个功能,我就可以将我的所有内容全盘私有化而且还能直接暴露在公网中。配合之前毕设搞的微服务化,简直不要美滋滋。如果之前是使用账号密码登录的,也可以接入这个方案。就是将登录页面替换成证书校验就可以了。

Refs

注释和共享

当你看到这个标题的时候,一定很好奇,React 不是很快么?为啥会变慢呢?在写这篇文章之前,我也是这么认为的,但是当我去看了一下 React 有关 Array 的 Diff 之后,我才认识到其实 React 如果你用的不正确,那么是会变慢的。

React Diff 算法

React Diff 算法相信大家不是很陌生吧,这里就不具体展开讲了。不过有一点要补充下,Diff 算法针对的是整个 React 组件树,而不仅仅是 DOM 树,虽然这样性能会比较低一些,但是实现起来却很方便。

而在 Diff 算法中,针对数组的 diff 其实是比较有意思的一个地方。在开始讲解方面,我希望你能对 React 有一定的了解和使用。

试一试有什么区别?

首先我们创建 3 个组件,分别渲染 10000 个 DOM 元素,从 [1...10000] ,渲染成如下。

1
2
const e10000 = new Array(10000).fill(0).map((_, i) => i + 1)
element10000.map(i => <div key={`${i}`}>{i}</div>)

每个组件有两个状态,会切换数据的顺序

  • 组件 A 在 [1...10000][2,1,3...10000] 之间切换。

  • 组件 B 在 [1...10000][10000,1...9999] 之间切换

  • 组件 C 在 [1...10000][10000...1] 之间切换,也就是正序和倒序之间切换。

我们简单命名下,默认的初始状态为 S1 而切换之后的状态为 S2 。大家可以思考一下,同一个组件状态切换的时候,所耗费的时间是不是都是一样的?可以直接使用这个 DEMO

可以直接点击上方的 toggle 来切换两者之间的状态,并在控制台中查看渲染的时间。因为每次时间都不是绝对准确的,所以取了多次平均值,直接揭晓答案:

组件 S2 ⇒ S1 S1 ⇒ S2
A 102ms 103ms
B 129ms 546ms
C 556ms 585ms

有么有觉得很奇怪,为什么同样是 S1 ⇒ S2 ,同样是只改变了一个元素的位置,为什么 A 和 B 的时间差距有这么多的差距。这个具体原理就要从 Diff 算法开始讲起了。

Array Diff 的原理

在讲 React 的实现之前,我们先来抛开 React 的实现独立思考一下。但是如果直接从 React 的组件角度下手会比较麻烦,首先简化一下问题。

存在两个数组 A 和 B,数组中每一个值必须要保证在对应数组内是唯一的,类型可以是字符串或者数字。那么这个问题就转变成了如何从数组 A 通过最少的变换步骤到数组 B。

其实每个元素的值对应的就是 React 当中的 key。如果一个元素没有 key 的话,index 就是那个元素默认的 key。为什么要强调最少?因为我们希望的是能够用最少的步数完成,但是实际上这会造成计算量的加大,而 React 的实现并没有计算出最优解,而是一个较快解。

顺便定义一下操作的类型有:删除元素插入元素移动元素

这里又要引申一个特殊点,React 充分利用了 DOM 的特性,在 DOM 操作中,你是可以不使用 index 来索引数据的。简单来讲,如果用数组表示,删除需要指定删除元素的索引,插入需要指定插入的位置,而移动元素需要指定从哪个索引移动到另一个索引。而利用 DOM,我们就可以简化这些操作,可以直接删除某个元素的实例,在某个元素前插入或者移动到这里(利用 insertBefore API,如果是要在添加或者移动到最后,可以利用 append )。这样最大的好处是我们不需要记录下移动到的位置,只需要记录下那些元素移动了即可,而且这部分操作正好可以由 Fiber 来承担。

举个例子说,从 A=[1,2,3] 变化到 B=[2,3,4,1],那么只需要记录如下操作即可:

有人好奇,不需要记录移动插入到那个元素前面么?其实不需要的,这是因为你有了操作列表和 B 数组之后,就可以知道目标元素在哪里了。而且采用这种方式就根本不需要关心每次操作之后索引的变化。

回到上面的简化后的问题,首先通过对比 A、B 数组,可以得到哪些元素是删除的,哪些元素是添加的,而不管采用什么样子的策略,添加删除元素的操作次数是无法减少的。因为你不能凭空产生或者消失一个元素。那么我们问题就可以再简化一下,把所有的添加删除的元素剔除后分别得到数组 A’ 和 B’,也就是 A’ 中不包含被删除的元素,B’ 中不包含被添加的元素,此时 A’ 和 B’ 的长度一定是一样长的。也就是求解出最少移动次数使得数组 A’ 能够转化成数组 B’。

如果只是简单的求解一下最少移动步数的话,答案很简单,就是最长上升子序列(LIS,Longest Increasing Subsequence)。关于如何证明为什么是最长不下降子序列这个算法,可以通过简单的反证法得到。关于这个算法的内容我就不具体讲解了,有兴趣的可以自行 Google。在这里我们只需要知道这个算法的时间复杂度是 O(n^2)

但是现在我们还无法直接应用这个算法,因为每个元素的类型可能是字符串或者数字,无法比较大小。定义数组 T 为 B’ 内元素在 A’ 的位置。举个例子,如果 A' = ['a', 'b', 'c'] B' = ['b', 'c', 'a'],那么 T = [2, 3, 1]。本文约定位置是从 1 开始,索引从 0 开始。

此时便可以对 T 求解 LIS,可以得到 [2, 3],我们将剩下不在 LIS 中的元素标记为移动元素,在这里就是 1,最后补上被剔除的删除和插入的元素的操作动作。这样 Diff 算法就可以结束了。

上面讲解的是一个个人认为完整的 Array Diff 算法,但是还是可以在保证正确性上继续优化。但是不管优化,这个复杂度对于 React 来讲还是偏高的,而如何平衡效率和最优解成为了最头疼的问题,好在 React 采用了一个混合算法,在牺牲掉一定正确性的前提下,将复杂度降低为 O(n)。下面我们来讲解下。

React 简化之后的 Array Diff

大家有过 React 开发经验的人很清楚,大部分情况下,我们通常是这样使用的:

  • 情形1:一个标签的的直接子子标签数量类型顺序不变,通常用于静态内容或者对子组件的更新

    1
    2
    3
    4
    5
    6
    7
    // 比如每次渲染都是这样的,里面的直接子元素的类型和数量是不变的,在这种情况下,其实是可以省略 key
    <div>
    <div key="header">header</div>
    <div key="content">content</div>
    <div key="footer">footer</div>
    <SubCmp time={Date.now()}/>
    </div>
  • 情形2:一个标签有多个子标签,但是一般只改变其中的少数几个子标签。最常见的场景就是规则编辑器,每次只在最后添加新规则,或者删除其中某个规则。当然了,滚动加载也算是这种。

  • 情形3:交换某几个子标签之间的顺序

  • 情形4:翻页操作,几乎重置了整个子元素

上面只是简单举了几个常见的例子,大家可以发现,大部分情况下子标签变动的其实并不多,React 利用了这个,所以将 LIS 简化成以第一个元素开始,找到最近上升子序列。简单来来讲就是从头开始遍历,只要这个元素不小于前的元素,那么就加入队列。

1
2
3
4
5
Q = [4, 1, 5, 2, 3]
// 标准算法
LIS = [1, 2, 3]
// 简化后的算法,从第一个开始,找到最近的不下降子序列即可。
LIS_React = [4, 5]

我们乍一看,这个算法不对呀,随便就能举出一个例子让这个算法错成狗,但是我们要结合实际情况来看。如果我们套回前面说的几种情况,可以看到对于情况 1,2,3 来讲,几乎和简化前效果是一样。而这样做之后,时间复杂度降低为 O(n) ,空间复杂度降低为 O(1)。我们给简化后的算法叫做 LIS' 方便后面区分。

我们将 LIS 算法简化后,配合上之前一样的流程就可以得出 React 的 Array Diff 算法的核心流程了。(为什么叫核心流程,因为还有很多优化的地方没有讲)

变慢的原因?

当我们在了解了 React 的实现之后,我们再回过来头来看看前面给出的三个例子为啥会有这么大的时间差距?

  • 组件 A 从 [1...10000] 变化到 [2,1,3...10000] 。此时我们先求解一下 LIS' 可以得到 [2,3,4...10000],那么我们只需要移动 1 这个元素就可以了,将移动到元素 3 前面。同理反过来也是如此,也就是说 S1 ⇒ S2 和 S2 ⇒ S1 的所需要移动的次数是一致的,理论上时间上也就是相同的。

  • 组件 B 从 [1...10000] 变化到 [10000,1,2...9999] 。同理,先计算 LIS' 可以得到 [10000],没错,你没看错,就是只有一次元素,那么我需要将剩下的所有元素全都移动到 10000 的后面去,换句话要进行 9999 次移动。这也就是为啥 S1 => S2 的时间会这么慢。但是反过来却不需要这个样子,将状态反过来,并重新计算索引,那么也就是从 [1...10000][2,3....10000,1],在计算一次 LIS' 得到 [2,3...10000] ,此时只需要移动一次即可,S2 ⇒ S1 的时间也就自然恢复的和组件 A 一致。

  • 组件 C 是完全倒序操作,所以只分析其中一个过程即可。首先计算 LIS' 可以得到,[10000] ,也就是说要移动 9999 次,反过来也是要 9999 次,所以时间状态是一致的。

经过这样的分析大家是不是就明白为啥会变慢了吧?

优化细节

降低 Map 的生成操作次数

上面有一点没有讲到,不知道大家有没有思考到,我怎么知道某个元素是该添加函数删除呢?大家第一反应就是构建一个 Set,将数组元素全放进去,然后进行判断就可以了。但是在 React 中,其实用的是 Map,因为要存储对应的 Fiber,具体细节大家可以不用关注,只需要知道这里用 Map 实现了这个功能。

不管怎么样,根据算法,一开始肯定要构建一遍 Map,但是我们来看下上面的 情形1。发现内容是根本不会发生变化的,而且对于 情形2 来讲,有很大的概率前面的大部分是相同的。

于是 React 一开始不构建 Map,而是假设前面的内容都是一致的,对这些元素直接执行普通的更新 Fiber 操作,直到碰到第一个 key 不相同的元素才开始构建 Map 走正常的 Diff 流程。按照这个方式,情形1根本不会创建 Map,而且对于情形2、3来讲也会减少很多 Map 元素的操作(set、get、has)。

降低循环次数

按照上面的算法,我们需要至少 3 遍循环:第一遍构建 Map,第二遍剔除添加删除的元素生成 A’ 和 B’,第三遍计算 LIS 并得到哪些元素需要移动或者删除。而我们发现第二遍和第三遍是可以合并在一起的。也即是说我们在有了 Map 的情况下,不需要剔除元素,当遍历发现这个元素是新增的时候,直接记录下来。

总结

关于 Diff 算法其实还有很多的细节,我这边没有过多讲解,因为比较简单,比较符合直觉。大家有兴趣的可以自己去看下。另外有人应该会注意到,上面的例子中,为什么切换同样的次数,有的时间长,有的时间短了。日后有时间再分析下补充了。

注释和共享

目录

  1. 写之前请思考
  2. 开发时请遵循以下几点
  3. 交互设计上请遵循以下几点

在平时的开发中,我发现大家特别喜欢将很多自己常用或者公司常用的脚本封装成一个 cli,这虽然无可厚非,但是作为一个有强迫症的患者来说,我认为这其实做不好会导致 cli 非常不好用的。所以这里总结了下平时写 cli 的经验希望和大家分享下。

写之前请思考

  1. 我是不是为了 cli 而去写 cli,换句话我觉得这样比较 cool。如果是,那么请放弃这种想法。

  2. 这个 cli 一定会减轻工作量么?

    • 有很多公司喜欢将各种 webpack/eslint/babel 工具封装成一个 cli,那么这真的会降低使用者的工作量么?如果你封装的不好,相反会增加很大的工作量。最神奇的是,封装了这么多工具,配置文件却一个都没少,babelrc/eslintrc/prettierrc,那封装了有何用。

    • cli 的不透明性就会导致使用者永远都是有使用成本的,不论这个 cli 有多简单。他不会知道你是干了什么。所以能避免写一个 cli 就避免写一个 cli。那如果真的要写,有其他方案么?请看第三条

  3. 除了写 node cli 真的没有其他方案了么?

    • 大部分情况下,写 shell 的效率远远高于写 cli,将一个命令拆分成多个 shell,然后配合简单的帮助文档即可。如果用户不知道是做什么的,那么直接就可以去看源码。

    • 而且使用 git 管理 shell 也同样高效,比如将个人的一些脚本放到 private github 上,然后直接 clone 下来就可以用了。这样不需要每次都 npm publish,进而污染 npm。

    • 大部分情况下,去写 Makefile/Rakefile 同样可行。当然,Node 生态也有 Jake,不过不推荐用,因为要装 jake 包。

    • 如果你这个 cli 是作为一个项目的脚手架工具,那么是不是用 yeoman 或者 degit 这类工具更好?除非你的项目非常热门,功能自定义程度高,否则完全不需要自己去写一个脚手架。如果你只是觉得好玩,想写一个脚手架的话,那么请去看第一条,问问自己。

    • 如果是一个团队使用,那么除非有很大的普世性,那么用 git 管理同样比 npm 管理要强的多。

  4. 最后如果决定一定要写 cli 的话,有必要发布到 npm 么?

    • 是不是发布到自己的 scope 下也是一个不错的选择?

    • 是不是直接让别人通过 npm install [github.com/user/package](http://github.com/user/package) 也是一个不错的选择?

    • 是不是上传到公司 or 个人的私有 npm 更好?

开发时请遵循以下几点

  1. 请使用 npm

  2. 不要写死版本号,优先使用 ^

    • 这是因为有可能你的 cli 会被直接当做依赖安装到项目中,如果你写死了版本号,那么可能会装多分依赖。比如项目依赖了 yargs@^13.2.0 ,但是你锁死了 `yargs@13.1.0`,就会导致安装两个 yargs。
  3. 避免引入一些功能很大,而自己只用其中一部分的包

    • 因为没有 webpack 工具,无法进行 tree shaking,所以导致安装了很多比较大的包。比如你只是用了 lodashcloneDepp 却安装了整个包。优先使用 [lodash.xxx](http://lodash.xxx) 子包。
  4. 如果你使用了某些构建工具(babel,webpack,typescript,flow),那么请将构建之后的文件也加入代码仓库。

    • 比如有一个 src 目录存放了 ts 源文件,而 dist 存放了构建之后的 js 文件。那么很多人的选择往往是将这 dist 文件夹加入 gitignore。这样其实是不太好的,原因如下:

    • 第一,方便追踪变化,比如你自己添加了一些 debug 代码,这个时候构建之后发现没有问题。又把 debug 代码删除,当你提交的时候就可以很清楚的看到自己修改了之后没有构建代码

    • 第二,方便通过 unpkg.com 等工具访问

    • 第三,在版本开发依赖升级之后,可以很方便的看到改变的内容。一般使用者会放心升级 cli 之类的开发工具,所以这部分的质量需要我们自己来保证。

交互设计上请遵循以下几点

  1. Throw as possible,将可能的所有报错向上抛出,是一个好系统所必备的能力。但是在错误展示的时候,可以向用户隐藏部分调用栈信息等,只保留关键信息即可。

  2. 尽可能遵循 linux 下的 cli 规范。

  3. 不要让用户产生无畏的等待,通过添加进度条或者输出完成列表等告诉用户你依旧是在工作中的。

  4. 给予用户想查看所有日志的能力。可以通过 vvv vvv 或者 -log-level debug 来控制显示级别

  5. 对所有命令的描述都不要超过一行。不论屏幕宽度如何(一般默认 80),最好不要超过 70。如果需要大量描述,请尝试通过 man 或者单独的页面。

  6. 帮助是最好的文档,写好 cli 的 help 远比去写好一个文档网站要关键

注释和共享

XGHeaven

一个弱弱的码农


杭州电子科技大学学生一枚


Weifang Shandong, China