因为疫情的原因,在家里实在是无聊,外加最近公司里的事情不是很忙,于是我就开始研究捡垃圾事宜。而且之前在学校薅的 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 慢上远比用数据证明要有意义的多。

注释和共享

  • 第 1 页 共 1 页

XGHeaven

一个弱弱的码农


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


Weifang Shandong, China