前言

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

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

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

  • 有前端相关的经验,能够理解什么是 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 的(防杠声明)。

注释和共享

自从 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.6688898374583169
第四次平均时间 0.1696739222 0.04082417709159327 0.18879122377096952 0.02120880942259516 0.22082417118516598 0.9248688730306829
第三次平均时间 0.160409555184 0.04109888910674132 0.1931868181410399 0.022967028748858104 0.22417582970644748 0.7894734907224213
第二次平均时间 0.130965058748 0.045824176785382593 0.2072527365001676 0.02346153545019391 0.23439560331158585 0.953161884168321
第一次平均时间 0.216216175927 0.04549450906259673 0.20939560484263922 0.02357143663115554 0.2546703217776267 0.9300694214990858

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

注释和共享

在平时的开发中,我发现大家特别喜欢将很多自己常用或者公司常用的脚本封装成一个 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. 给予用户想查看所有日志的能力。可以通过 -v -vv -vvv 或者 --log-level debug 来控制显示级别
  5. 对所有命令的描述都不要超过一行。不论屏幕宽度如何(一般默认 80),最好不要超过 70。如果需要大量描述,请尝试通过 man 或者单独的页面。
  6. 帮助是最好的文档,写好 cli 的 help 远比去写好一个文档网站要关键

注释和共享

思来想去,我决定还是要写一篇文章分享一下我在网易的经历和生活,我对网易的观点或者想法以及评论也许并不是客观公正的,我只是想从一个校招生的角度来讲述。

2018 年 7 月 01 日,我从杭电毕业,进入了网易,从事大数据前端管理页面开发;
2019 年 3 月 15 日,我主动离开了网易,一共 257 天、6168 小时、370080 分、22204800 秒。

心路历程

进入网易后,我的心态也一点一点的发生着变化,这其中有一些是因为自己的原因,也有一部分是网易的原因,如果你愿意,我愿和你慢慢阐述

意外 —— 校招季拿到了网易 Offer

我原本没想给网易投递 Offer,那个时候我心里只有阿里,至于原因么,有句话说的好,『所有的 Node 大佬不是在阿里,就去在去阿里的路上』,虽然我不是大佬,但是我也有一颗想成为技术大佬的心。那个时候我只投递了阿里的 Offer。

可是事与愿违,18 届的校招可谓说是及其严格,基本都是社招要求,而且听说就招了 300 人(只是听说)。投了两次阿里云,都以失败告终(毕竟我的朴神来阿里云)。这个时候正好有同学在网易实习,我就看了下,有前端岗位,那就去下试试吧。

说实话,那个时候对网易的前端、Node 没有抱有任何希望。网易在前端的开源社区几乎没有任何动静,也几乎从来没参加过任何技术论坛,也从来没听说他们有用任何前端框架,可以说对网易前端没有任何的概念。

面试的时候我也就啥都没准备,纯裸考,只是觉得这公司进不进无所谓吧,甚至 HR 面的时候,稍微顶撞了一下 HR,HR 问了一个问题,我回答了,结果他说我回答的不是他问的问题,我思考了一下,坚定的回了他一句『我回答的就是你的问题,那您不是想问这个?』。结果意外的,我竟然进了,我当时自己都蒙了。直到入职之后才回过神来,原来是他们太缺人了。。。

但是毕竟校招,怎么样还是要试一下么,就安慰自己说可能他们内部用的技术挺好的,只不过按照丁磊的作风,可能不喜欢招摇吧。但后来结果实力打脸,事与愿违~

说实话,当时也找过其他的公司,可是杭州这边,真正既有技术实力、有大佬助阵、面向开源的公司几乎少的可怜,除了阿里几乎找不到几个,于是当时在好朋友的推荐下,投了有赞的前端、七牛的后端。

七牛的后端笔试直接跪了,有赞的拿到了 offer,但是当时脑抽觉得有赞技术不好(另外是我和室友都拿到了有赞的 offer,但是前端的老大跟我的室友说带着他一起搞波大事情,和我却啥都没说,就加了个微信,我觉得受到了某种歧视),而且觉得有赞的平台有点小,于是给推了。

后来真正进入到网易之后,觉得网易还不如有赞呢。或许大家认识有赞是因为 996,都认为有赞 996 不好,可是有赞和普通的互联网企业一样,并没有 996,相反比其他公司还有活力,有各种兴趣组织,甚至还有官方的游戏比赛,Dota/王者,比赛还有专人解说呢。而且钱多福利好。而且杭州除了阿里能数上的有赞也是接入企业滴滴。

反正说了这么多,最终我算是说服自己,现在网易学习 1 年,然后去我喜欢的公司。

期待 —— 嗨,猪行动

期待着进入网易的生活会是什么样子;期待着会不会遇见很多大佬;期待着网易的食堂有多么好吃;期待着能不能和同事很好的相处;期待能不能用到自己喜欢的技术;期待着别人能不能承认自己的能力。

一切的期待,都在拿到校招 Offer 的那一刻开始。

这期间参加了 『嗨,猪行动』,第一次以校招的身份进入网易,我们在 HR 小哥哥小姐姐们的安排下,一起参加了一些有趣有意思的活动。大家分成各个小队,以小队的身份参加比赛,每完成一个比赛,都会有一定的积分奖励,在活动结束之后,可以去换取很多严选的礼物,比如有行李箱、抱枕、牙刷、拖鞋、杯子、笔等等。

我觉得这场活动最成功的便是在最后,来了一场上百人吃鸡游戏,在园区散落着很多盒子,每个盒子里面武器或者,然后找到其他小队成员消灭之。虽然我们的枪只是简单的水枪,子弹也是可消除墨水,命只是一个简单的丝带绑在头上。而且我不得不承认这个游戏 Bug 太多,不过不得不说真的是很好玩的。

很快,虽然我很轻松的活到了前 10 名的样子,但是我是用某些不正常的方法(在某个门后躺着,假装自己已经死掉了,悠闲的等着;而且我们在这个游戏开始前就已经偷偷攒了好多武器和命,就是在玩吃鸡之前的游戏的时候),感觉不是太很公平,于是我就主动退出了。

静静的看着他们战斗,很快场上就只剩下 2 名妹子,一枚来自其他的组,还有一枚便是我们组的。于是最精彩的决赛来了,我们给他们腾出了场地,每人补足武器。

piupiupiu,piupiupiu,真的是太精彩了,我无法描述,但是结果有点遗憾,我们组的妹子惜败第二名。

一天如此开心,感谢 HR 小哥哥小姐姐们的辛勤付出,也感谢队友的努力奋斗,或许在此刻,我对网易的印象有了那么一些改变,感觉让人期待网易的生活会是怎么样子。

满足 —— Mini 项目 『来人吖』

2018 年 7 月 1 日,这一天终于到来了。我真正的不如了网易的大门,发现一切都是如想象的那么美好。

免费的四餐(包含夜宵喽),除了人太多以外味道还是很不错的;各种严选考拉的内购。有各种各样的折扣,而且有些还会直接在食堂门口摆摊。免费的班车简直不要太舒服,就是要每天 7 点半起床赶班车,真的是难受。园区免费停车、新能源免费充电,虽然这个时候我还没有买车,不过我已经在考虑中了。

入职之后,参加了 2 天的素质拓展,终于圆梦了我一直想去尝试的高空断桥,刺激、爽,还想再来一次。哈哈哈。

很快,最令我欣喜激动的事情来了,『Mini 项目』,简单点来讲,就是网易花了 100w 做了 8 个没啥用的产品。当然了,其实不是这样,重新讲一下,就是大家在一个月的时间里面,从开始,做出一个可以上线的产品,从中体验整个产品的开发流程,学习工作中用到的技术栈,培养同学之间的合作意识。这其实是一个非常有意义的事情,从中你可以体会到创业的过程,需求分析、竞品分析、优势分析、未来产品规划、UI 设计、技术架构、团队合作、测试保证、产品推广,每一步都可谓非常有价值。

当然了,这其中最让我满足的便是我在 Mini 项目中担任了技术负责人的身份,终于可以满足我想玩各种技术的心,把各种想用的技术都集成在一起,话不多说,先申请 10 台机器走起,Docker Swarm;Kafka;Node.js;NestJs;小程序;mpvue;Redis;TiDB;ES;CI/CD;Spring Cloud,简直不要太爽。

让我最欣慰满足的是虽然我这个技术负责人当的不是很称职,但是大家不嫌弃,而且一起推动团队的进步,而且对我定下的技术方向没有太多的异议,尤其是后端龙哥定的微服务化的 Spring Cloud 框架简直不要太符合我的胃口。感觉大家都是有技术追求,热爱技术。

另外最爽的就是入职一个月的便天天加班,甚至有几次我都是凌晨 12 点下班,到家都 1 点了,第二天还要 7 点半爬起来赶班车,谢天谢地那个时候还有顺风车。即便如此,我并没有觉得加班有多累,因为我们都有一个共同的目标——产品上线,拿到第一。

哦顺便说一句,那个时候我们就是想做顺风车匹配,只不过我们是做拼滴滴车,简单来讲就是大家都是乘客,匹配到一起了,由其中一个人打滴滴,然后大家一起走。这样做最大的好处便是省钱,比如说我回家快车 80,拼车 60,如果找齐 4 人,便只要 20 快,甚至比顺风车还便宜,而且有快车的服务。但是即便如此,开始产品评审的时候,评委一直无法绕开滴滴顺风车这个大对手,也对我们的产品产生了质疑,而且我们在这期间也质疑过很多次,直到我们做完了之后没多久,2018 年 8 月,顺风车下架了,顺风车下架了,顺风车下架了,重要事情说三遍,我们这才意识到我们的产品是有多么正确,多么正确,多么正确。

向往 —— 就要步入真正的业务线去了

一个月的 Mini 项目结束之后,虽然我们并没有取得第一名,但是我们在这期间收获了更多的东西。

小组里面的每个人就要到对应的业务线去了,有去云信的,有去严选的,有去云音乐的,也有去 AI、安全部门的。虽然这些部门无一例外经历了裁员。

有了 Mini 项目的经历之后,我对接下来步入真正的工作充满了无比的向往,会不会他们也和我们的组员一样有着技术追求,我能不能发挥我自己最大的力量给项目贡献代码,能不能维护以为开源项目,能不能听到很多大佬们的讲座。

以前这些问题我是有所担心的,但是有了 Mini 项目的经历之后,我更相信我所说的。可是事实却是……

愁 —— 为什么和我想象的不一样,甚至说是非常非常非常不一样

可是事实却是事与愿违,进入部门之后,我才发现是我想多了。

有的时候我一直在吐槽说我们项目技术差,比如什么只能用 ES5;没有 webpack;没有测试;没有 CI;虽然是前后端分离,但是代码却和后端放在一个仓库里面;Git 提交没有规范,各种 pull merge;Git Message 也没有规范;……这是一个已经三年的项目了,而且最有意思的是原本开始的时候是孟导定的用 Regular,想兼容 IE。但是我问现在不是只兼容 Chrome 么,这是为啥呢?他们和我说原因就是因为 IE 实在是兼容不了。这个时候我真想说一句,这个理由真的很棒。

这个问题不仅仅体现在前端,后端也是有如此的问题,觉得只要能解决问题就好了,要什么格式,要什么技术追求。这碗面条(代码)我只要保证计算机能吃就行,乱成啥样关我啥事。我只说一句,看了后端 Java 代码,我是第一次见识到 Space 和 Tab 缩进同时使用的,牛啤。

我甚至还开过一个玩笑说,前端越来越追求规范,开始越来越多的使用 TS 强类型的语言,而后端却想着如果绕开 Java 类型限定,内部几乎都是 JSONObject。当然这是一句玩笑,毕竟有些框架的东西你是没法直接用 JSONObject 的,但也能反映一些问题。

愁啊愁啊,当初的期待全部破灭,想推重构,可是我一个刚入职的又如何推的动?

愁啊愁啊,不仅仅是因为技术,而且内部其他的问题也很多,比如说 QA 测试问题、产品乱接需求、文档书写不全。。。夸张点说,我的大学技术社团除了干活没钱、人力不足以外,算是吊打我们部门,至少从技术上是这么讲的。至少社团的服务器环境是全 Docker 化的。都 9102 年了,还不会用 Docker 是来搞笑的么……

更愁的是,网易似乎不接受内部的合作,我曾经给网易云写了一个更好用对象存储 Node.js SDK,nos-node-sdk,想合并到他们的代码库里面,结果他们一直不给我回复。问他们为啥不改进代码,他说人手不够……我 go……我帮你写了,我帮你维护还不乐意啊,再说我就不吐槽你们的文档写的有多差了,错误一片一片的,也就我愿意认认真真的看下来了。

迷茫 —— 完全看不到方向

时间一久,我就陷入了迷茫期,找不到未来发展的方向,就像是我周围的同学都在踏步前进,有研究 Node 内核的,有使用 Egg 开发后台的,有用 React+Antd 的,还有 Typescript 写 IOS 组件的。而我,现在只能看着 Regular,对未来几乎没有任何帮助。换句话说,他们所在的环境至少给他们拓宽了不少眼界,而网易却没给我拓宽任何的眼界。

我也在安慰自己,毕竟学习是自己的事情,公司不给力,只能靠我们自己啦。确实我也在不停的努力,尝试学习其他的东西,可是这种学习毕竟是没有方向性的,我现在缺的是项目经历,而不是基础。基础就像是地基,只要地基够用,如何快速搭建起高楼是最重要的,而不是说连高楼都没造起来的时候想着拓宽地基。

另外,你见过那个专家是可以脱离他们的环境的?工作不用 React,却想成为 React 专家;工作不用 Java,却想成为 Java 专家;工作不接触 Node 内核,怎么成为 Node 专家;工作不接触浏览器内核;怎么成为浏览器专家。一个公司的天花板牵扯着你能早就多高的喽,也能让你知道你现在的基础够不够用,如何更好的提高自己。自我驱动的学习最大的用处是帮你补足基础,而只有真正的项目经历,才能真正让你有质的成长。

但是,这些东西在网易,很难寻得。

也许有人会出来说,网易里面技术栈也是很多的,React/Vue 都是有的。确实,有的确有,可能这又能如何?况且使用这部分技术的人也是少之又少,最关键的还是人!

离开 —— 却没有任何留念

最终我选择了离开网易,去寻找新的家园。

当我真正离开的时候,我才发现原来网易并不是那么的不堪。有的地方还是有许多值得学习借鉴的地方,可惜对于我们这些下层人民来讲,上层优秀的想法无法渗透到我们这里,在我们眼里,网易只是一个完成需求就可以下班的地方,不需要你有太多的想法,只需要按照上面的指示一步一步来做就可以了。

难得说网易的福利好,可惜我并没有沾上多少,食堂 Mini 项目之后我就搬离本部园区,在外面写字楼包了一块办公区,所以食堂基本吃不上了;免费停车就更不用说了,只能停本部,然后走一公里到我的工作地点;内部的技术分享全在园区,根本没时间赶过去,只能在线上看看了;各种摆摊更别说看见了;网易二期别人都搬过去了,为啥我们还不搬。简单点来讲,我培养不了我对网易的感情,对我来我,我就像是给大公司做外包,那我为啥不去一个钱多技术好的公司呢。

离开的时候,最大的愧疚便是对 HR 们,愧对了你们对我们的培养,真的真的真的非常抱歉,路过 HR 办公区的时候,害怕你看到我,虽然你还是看到了我。不过我们是主动离职的,比被裁的要好一点点,可惜就是没钱拿。

但是,这并不能阻止我离开网易。

感谢

  • 感谢陪伴和我一起走过 mini 项目的同学们
  • 感谢导师的帮助
  • 感谢身边的同事们
  • 最最感谢校招 HR 小姐姐小哥哥们的努力,为你们实力打 Call。感谢你们对我们的照顾和指导,让我们更快的融入到了网易的大世界中。谢谢,谢谢,尤其感谢森森和 6 姐。

回首

现在我入职了字节跳动,现在回过头来看了看,对我来说,这真的算是一个非常正确的决定,我也就不吐槽网易跟字节跳动相比的基础设施有多差了,我也不吐槽网易有多扣了。

如果你现在问我推不推荐网易,我觉得还是值得推荐的,但是一定一定一定要看部门,一定一定一定要看 Leader,一定一定一定要看环境。看部门有没有发展前景,看 Leader 愿不愿意培养自己,看环境会不会限制到你的天花板。

另外这里实力打 Call 考拉前端,足够开放,也有大佬,如果真的去可以去这个部门。当然严选的就是有点封闭了,搞了一套很不错的 npm 私有仓库,权限系统也做了,却不尝试在公司内部推广,搞不懂。云音乐和严选差不多,这里又要吐槽一下内部的 nei 是好难用,不仅仅是 ui 丑,而且这么久了,才上线在线 mock 功能,还不支持 swagger 导入,还不如之前我实习的公司大搜车推出的 easy-mock 呢。

过去就是过去,迎接未来,至少字节跳动能给我保证多劳多得,这就足够了!!!

另外说一句,有想要内推字节跳动的学弟学妹么,社招也可以哦,请用简历砸我吧,xutianyang.bradley@bytedance.com,如果想要和我交流的话,可以 Telegram 找我,@xgheaven。

注释和共享

目录

  1. 可控组件?不可控组件?
    1. 什么是可控和不可控?
    2. 为啥要区分呢?
    3. value? defaultValue? onChange?
    4. propName in this.props?
    5. Independence
    6. 如何使用?
    7. 总结

前言:本人入职之后算是第一次真正去写 React,发现了 React 的组件系统和其他框架的组件系统有些许的不同,这也触发了我对其中组件的可控性的一些思考和总结。

可控组件?不可控组件?

自从前端有了组件系统之后,有一个很常见但是却又被大家忽视的概念,就是可控组件(Controlled Component)和不可控组件(Uncontrolled Component)。

什么是可控和不可控?

官方详细讲解了什么事可控和不可控组件,虽然只是针对 input 组件的 value 属性来讲的。但是对于很多第三方组件库来讲,一个组件不止有一个数据属于可控。比如 Ant Design 的 Select 组件,valueopen 都属于可控的数据,如果你让 value 可控 open 不可控,那这到底是可控组件还是不可控组件呢?

所以从广义来讲使用可控/不可控组件其实不是很恰当,这里使用可控数据不可控数据更加合理一点。一个组件可能可能同时有可控的数据和不可控的数据。

可控数据是指组件的数据被使用者所控制。不可控数据是指组件的数据不由使用者来控制而是由组件内部控制。

之所以会有可控和不可控,主要是跟人奇怪的心理有关。如果把框架比作一个公司,组件比作人,组件之间的关系比作上下级。那么上级对下级的期望就是你既能自己做好分内的事情,也可以随时听从我的命令。这本身就是一件矛盾的事情,一边撒手不管,一边又想全权掌控。遇到这样的上级,下级肯定会疯了吧。

为啥要区分呢?

在 Vue 中,其实都忽视了这两者的区别,我们来看下面这个例子。

1
<input/>

上面是一个最简单 Input 组件,我们来思考一下如下几种使用场景:

  • 如果我只关心最后的结果,也就是输入的值,中间的过程不关心,最简单的方式是用 v-model 或者自己在 change 事件里面获取值并保存下来。

    1
    2
    3
    <input v-model="value"/>
    <!-- OR -->
    <input @change="change"/>

    这种场景是非常普遍,Vue 可以很好的完成,结果也符合人们的预期。

  • 如果我也只是关心结果,但是想要一个初始值。
    也很简单,通过 value 传入一个静态字符串不就好了,或者传入一个变量,因为 Vue 的 props 是单向的。

    1
    2
    3
    <input v-model="value"/> <!-- value 有初始值 -->
    <input value="init string" @change="change"/>
    <input :value="initValue" @change="change"/>

    其中第三个方案并不是非常正确的方式,如果 initValue 在用户输入期间发生了更新,那么他将覆盖用户的数据,且不会触发 change 事件。

  • 我不仅仅关心结果,还关心过程,我需要对过程进行控制。比如说把输入的字符串全部大小写,或者锁定某些字符串。
    熟练的工程师肯定可以写出下面的代码。

    1
    2
    <input v-model="value"/> <!-- watch "value",做修改 -->
    <input :value="value" @change="change"/> <!-- 在 change 中修改数据 -->

    但是这会有问题:

    1. 数据的修改都是在渲染 dom 之后,也就是说你不管怎么处理,都会出现输入的抖动。
    2. 如果通过第二种方法,恰巧你做的工作是限制字符串长度,那么你这样写 change(e) {this.value = e.target.slice(0, 10)} 函数会发现没有效果。这是因为当超过 10 字符之后,value 的值长度一直是 10,vue 没有检测到 value 的变化,从而不会更新 input.value。

出现这个问题最关键的是因为没有很好的区分可控组件和不可控组件,我们来回顾一下上面的某一段代码:

1
<input :value="value" @change="change"/>

你能从这块代码能看出来使用这个组件的用户的意图是什么呢?他是想可控的使用组件还是说只是想设置一个初始值?你无法得知。我们人类都无法得知,那么代码层面就不可能得知的了。所以 vue 对这一块的处理其实是睁一只眼闭一只眼。用户用起来方便,

用一个例子来简单描述一下:上级让你去做一项任务,你询问了上级关于这些任务的信息(props),然后你就开始(初始化组件)工作了,并且你隔一段时间就会向上级汇报你的工作进度(onChange),上级根据你反馈的进度,合理安排其他的事情。看起来一切都很完美。但是有的上级会有比较强的控制欲,当你提交了你的工作进度之后,他还会瞎改你的工作,然后告诉你,按照我的继续做。然后下级就懵逼,当初没说好我要接受你的修改的呀(value props),我这里也有一份工作进度呀(component state),我应该用我自己的还是你的?

对于人来说,如何处理上级的要求(props)和自身工作(state)是一个人情商的表现,这个逻辑很符合普通人的想法,但是对于计算机来说,它没有情商也无法判断究竟应该听谁的。为了克服这个问题,你需要多很多的判断和处理才可以,而且对于一些不变的值,你需要先清空再 nextTick 之后赋值才可以出发组件内部的更新。

最近入职之后,公司用到了 React,我才真正的对这个有所理解。

value? defaultValue? onChange?

如果对 React 可控组件和不可控组件有了解了可以跳过这块内容了。

让我们来看一下 React 如何处理这个的?我们还是拿上面的那三种情况来说:

  • 如果我只关心最后的结果,也就是输入的值,中间的过程不关心

    1
    <input onChange={onChange}/>
  • 如果我也只是关心结果,但是想要一个初始值

    1
    2
    <input defaultValue="init value" onChange={onChange}/>
    <input defaultValue={initValue} onChange={onChange}/>
  • 我不仅仅关心结果,还关心过程,我需要对过程进行控制

    1
    <input value={value} onChange={onChange}/>

当看完了这段你会很清楚的知道什么样的结构是可控,什么结构是不可控:

  • 如果有 value 那么就属于可控数据,永远使用 value 的值
  • 否则属于不可控数据,由组件使用内部 value 的值,并且通过 defaultValue 设置默认值

不论什么情况修改都会触发 onChange 事件。

React 对可控和不可控的区分其实对于计算机来说是非常合理的,而且也会让整个流程变的非常清晰。当然,不仅仅只有这一种设置的方式,你可以按照一定的规则也同样可以区分,但是保证可控和不可控之间清晰的界限是一个好的设计所必须要满足的

propName in this.props?

了解上面的概念之后,我们进入到实战环节,我们怎么从代码的层面来判断当前组件是可控还是不可控呢?

根据上面的判断逻辑来讲:

1
2
3
const isControlled1 = 'value' in this.props // approval 1
const isControlled2 = !!this.props.value // approval 2
const isControlled3 = 'value' in this.props && this.props.value !== null && this.props.value !== undefined // approval 3

我们来观察上面几个判断的方式,分别对应一下下面几个模板(针对第三方组件):

1
2
3
4
<Input value={inputValue} /> // element 1,期望可控
<Input value="" /> // element 2,期望可控
<Input /> // element 3,期望不可控
<Input value={null} /> // element 4,期望???

可以得到如下表格

是否可控 approval 1 approval 2 approval 3
element1 true true true
element2 true false true
element3 false false false
element4 true false false

大家第一眼就应该能看出来方法二其实是不正确的,他无法很好的区分这两种状态,所以直接 pass 掉。

眼尖的同学也会发现为什么 element 4 的期望没有填写呢?这是因为有一条官方的规则没有讲,这条规则是这样的:当设置了 value 属性之后,组件就变成了可控组件,会阻止用户修改 input 的内容。但是如果你想在设置了 value prop 的同时还想让用户可以编辑的话,只可以通过设置 valueundefinednull

在官方的这种规则下面,element 4 期望是不可控组件,也就是说 approval 3 是完全符合官方的定义的。但是这样会导致可控和不可控之间的界限有些模糊。

1
2
<Input value={inputValue} />
// 如果 inputValue 是 string,组件是什么状态?如果是 null 又是什么状态?

所以这里其实我推荐使用 approval 1 的方式,这也是 antd 所采用的。虽然不符合官方的定义,但是我觉得符合人们使用组件的一种直觉。第六感,=逃=

Independence

有了判断的方法,那么我们可以画出一个简单的流程图(Input 组件为例):

图片有点复杂,简单来讲就是每一次需要获取可控数据或者更新可控数据的时候,都需要检测一下当前组件的状态,并根据状态选择是从 props 中获取数据还是从 state 中获取数据已经更新的时候调用的是那个函数等等。图中有一些箭头的方向不是很正确,而且部分细节未画出,大家见谅。

如果只是添加这一个可控的属性 value ,这样写未尝不可,但是如果我们要同时考虑很多属性呢?比如说 Antd Select 组件就同时有 valueopen 两个可控属性,那么整个代码量是以线性方式增长的。这很明显是无法接受的。

于是这里我引入了 Independence 装饰器来做这件事情。架构如下:

我们可以这么理解,一个支持可控和不可控的组件本质上可以拆分成内部一个展示型的无状态受控的组件和外面的包装组件,通过包装(也就是高阶组件的方式)让内部受控组件支持不可控。

这样写其实有如下几个好处:

  1. 组件逻辑复杂度降低,只需要将组件的受控情况
  2. 可以将任意受控组件包装成不受控组件,尤其是对第三方组件的封装上
  3. 组件复杂度降低,代码冗余变少
  4. 非常方便的添加和删除受控属性,只需要修改装饰器即可

如何使用?

目前我简单实现了 Independence 装饰器,代码在网易猛犸开源的组件库 bdms-ui(建设中,组件不全、文档不全、时间不够,敬请期待)中,代码在此

他遵循这样的规范:假如属性名称为 value,那么默认值为 defaultValue,change 事件为 onValueChange。支持通过 onChangeName 修改 change 事件名称,通过 defaultName 修改默认值名称。

另外最简单的使用方式就是通过装饰器了,拿 Select 组件举例。

1
2
3
4
5
6
7
8
9
@Independence({
value: {
onChangeName: 'onChange'
},
open: {} // 使用默认值
})
export default class Select extends Component {
// blahblah,你就可以当受控组件来编写了
}

从此编写可控和不可控的数据从未如此简单。另外 Independence 还实现了 forward ref 的功能。

不过现在功能还比较薄弱,需要经过时间的检验,等完备之后可以封装成一个库。

总结

本文简单讲解了一下什么是可控和不可控,以及提出了一个 React 的解决方案。

这些只是基于我的经验的总结,欢迎大家积极交流。

注释和共享

XGHeaven

一个弱弱的码农


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


Weifang Shandong, China