React Hooks 究竟有多慢?

14,982 阅读15分钟

自从 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

具体的业务逻辑的实现,请看后面的 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 就会导致最终测量出来的结果普遍慢一些。

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

针对 CC 来说

  • 当运行 render 方法的时候,记录时间
  • 当运行 componentDidUpdate 或者 componentDidMount 的时候,打印耗时。这两个钩子都是在组件挂载或者更新 DOM 之后同步调用,与 useLayoutEffect 调用时机一致。
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 Hook(Self) Class Class(Self) Self Hook Slow
第一次平均时间 0.2546703217776267 0.04549450906259673 0.20939560484263922 0.02357143663115554 93.0069421499% 21.6216175927%
第二次平均时间 0.23439560331158585 0.045824176785382593 0.2072527365001676 0.02346153545019391 95.3161884168% 13.0965058748%
第三次平均时间 0.22417582970644748 0.04109888910674132 0.1931868181410399 0.022967028748858104 78.9473490722% 16.0409555184%
第四次平均时间 0.22082417118516598 0.04082417709159327 0.18879122377096952 0.02120880942259516 92.4868873031% 16.96739222%
第五次平均时间 0.22747252228577713 0.04126375367107627 0.1941208809532307 0.024725271102327567 66.8889837458% 17.1808623414%
五次平均时间 0.23231 0.0429 0.19855 0.02319 85.329% 16.981%

简单解释下数据,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 面板的数据,虽然只能在开发模式下才能看到这部分数据,但依旧具有参考意义

Hooks

Class

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

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

  • 挂载性能如何?也就是第一次渲染组件
  • 大量列表渲染性能如何?没准渲染的组件多了,性能就不会呈现线性叠加呢?
  • 当 Class 被很多 HOC 包裹的时候呢?

其他对比

挂载性能

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

Avg. Time(ms) Hook Hook(Self) Class Class(Self) Hook Slow(%)
第一次平均时间 0.5608108209295047 0.04027024968653112 0.5409459180727199 0.025810805980015446 3.6722530281%
第二次平均时间 0.6013513618224376 0.041216209128096294 0.5285134916571347 0.02486483395301007 13.7816482105%
第三次平均时间 0.5672973001728187 0.04797298587053209 0.5154054158845464 0.024729729252489837 10.0681682204%
第四次平均时间 0.5343243404216057 0.04378377736822979 0.5293243023491389 0.025405410073093465 0.9446076914%
第五次平均时间 0.5371621495263802 0.041081066671255474 0.5078378347428264 0.025540552529934292 5.774346214%
五次平均时间 0.56019 0.04286 0.52441 0.02527 6.848%

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

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

大列表性能

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

Avg. Time(ms) Hook Hook(500) Class Class(500) Hook Slow(%) Hook Slow(%,500)
第一次平均时间 2.5251063647026077 9.55063829376818 2.335000020313136 8.795957447604296 8.1415992606% 8.5798601307%
第二次平均时间 2.6090425597701934 9.59723405143682 2.3622340473168073 8.702127664211266 10.4480973312% 10.286063613%
第三次平均时间 2.5888297637488615 9.64329787530005 2.344893603684737 8.731808533218313 10.4028668798% 10.438723417%
第四次平均时间 2.567340426662184 9.604468084673615 2.334893631570517 8.76340427574642 9.95534837% 9.5974553092%
第五次平均时间 2.571702087694343 9.597553207756992 2.230957413012994 8.719042523149797 15.273472846% 10.075770158%
五次平均时间 2.5724 9.59864 2.3216 8.74247 10.844% 9.796%

我们先不计算下慢了多少,先看看这个数值,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.2710439444898249
第二轮 0.2821977993289193
第三轮 0.278846147951189
第四轮 0.27269232207602195
第五轮 0.25384614182697546
五轮平均时间 0.27173

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

量化娱乐一下?

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

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

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

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

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

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 慢上远比用数据证明要有意义的多。