深入浅出React之第五章:React组件的性能优化

1.单个React组件的性能优化

react是通过Virtual DOM来提高渲染性能,虽然每一次页面更新都是对组件的重新渲染,但是并不是将之前渲染的内容全部抛弃重来,而是通过借助Virtual DOM,计算出对DOM树的最小修改。这就是为什么React在默认情况下渲染都很快速的原因。

不过,虽然Virtual DOM能够将每次DOM的修改量减少到最少,但是计算和比较Virtual DOM依然是一个很复杂的过程。如果能够在开始计算Virtual DOM之前就可以判断渲染结果不会发生变化,那么就可以直接不要进行Virtual DOM计算和比较,这样速度就会更快。

react-redux的shouldComponentUpdate

我们之前介绍过的shouldComponentUpdate可能是React组件生命周期函数中除了render之外最重要的函数了。render函数决定了‘组件渲染出什么’,而shouldComponentUpdate函数则决定了‘什么时候不需要重新渲染’。

React组件类的父类Component提供了shouldComponentUpdate的默认实现方式,但是这个默认实现方式只是简单的返回一个true,也就是说每次更新的时候都要调用所有的生命周期函数,包括调用render函数,根据render函数的返回结果计算Virtual DOM.

回顾一下,使用react-redux库,我们把完成一个功能的React组件分为两部分:

  • 傻瓜组件:只负责视图部分,处理的是’组件看起来怎么样’的事情,这个傻瓜组件往往用一个函数的无状态组件就足够表示,甚至不需要是一个类的样子,只需要定义一个函数就足够了。
  • 容器组件:负责逻辑部分,处理的是’组件如何工作’的事情。这个容器组件有状态,而且保持和Redux Store上状态的同步,但是react-redux的connect函数把这部分同步的逻辑封装起来了,我们甚至在代码中看不见这个类的样子,往往直接导出connect返回函数的执行结果就行了。

export default connect(mapStateToProps,mapDispatchToProps)(TodoItem)

虽然代码上不可见,但是connect的过程中实际上产生了一个无名的React组件类,这个类定制了shouldComponentUpdate的实现,实现逻辑是比对这次传递给内存傻瓜组件的props和上一次的props。如果props没有变化,那就可以认为渲染结果肯定也一样。

相比React组件的默认shouldComponentUpdate函数实现,react-redux的实现方式当然是前进了一大步。但是在对比prop和上一次渲染所用的prop方面。依然用的是尽量简单的方法,做的是‘浅层比较’,即使用javascript默认的===来比较。如果prop的类型是字符串或者数字,只要值相同,那么’浅层比较’也会认为两者相同。但是如果prop的类型是对象,那么‘浅层比较’只会对比两者是不是同一个对象的引用,如果不是,哪怕这两个对象中的内容完全一样,也会认为是两个不同的prop。同样,函数类型的prop也存在这样的问题,要想让它知道两个函数类型的prop是相同的,就必须让这两个prop指向同一个函数,如果每次传入的prop都是一个新创建的函数,就肯定不行了。

2.多个react组件的性能优化

当一个React组件被装载,更新和卸载的时候,组件的一系列生命周期函数会被调用。不过,这些生命周期函数是针对一个特定的React组件的。那么,在一个应用中,从上到下有很多React组件组合起来,那么他们之间的渲染过程是怎么样的呢?

在装载和卸载阶段没什么性能优化的事情可以做,我们来着重看一下更新阶段:

2.1 React的调和阶段

首先,什么是调和?React在更新阶段,通过对比Virtual DOM的差异,根据不同来修改DOM树,以此来做到最小限度的修改。React在更新中这个找不同的过程,就叫做调和。

React的diff算法并不复杂,当要对比两个Virtual DOM的树形结构时,从根节点开始递归往下比对,在树形结构上,每个节点都可以看做一个这个节点以下部分子树的根节点。所以这个diff算法可以从Virtual DOM上任何一个节点开始执行。

算法具体原理可以参考另一篇博客:React diff算法浅析

React首先会检查两个树形的根节点类型是否相同,根据相同或者不同有不同的处理方式。

节点类型不同的情况

如果树形结构根节点类型不相同,那就意味着改动太大了,也不要费心考虑是不是原来那个树形的根节点被移动到其他地方去了,直接认为原来那个树形结构已经没用了,可以扔掉,需要重新构建新的DOM树,原有的树形上的React组件会经历‘卸载’的生命周期。
比如:

1<div> <span> 2 <Todos/> ===> <Todos/> 3</div> </span> 4

那么,在比较时,一看根节点是div,新节点是span,类型不一样。那么这个算法就认为必须要废除之前的div节点及其下面的所有子节点,然后重新渲染一个span节点以及其子节点。

显然,这是一个巨大的浪费,但是为了避免原始diff算法的O(N^3)的时间复杂度,React必须要选择一个更简单更快捷的算法,只能采取这种方式。

所以,作为一个开发者,我们一定要避免上面这种浪费的情景出现。

节点类型相同的情况

如果两个树形结构的根节点类型相同,React就认为原来的根节点只需要更新过程,不会将其卸载,也不会引发根节点的重新渲染。

  • DOM元素类型:React

会保留节点对应的DOM元素,只对树形结构根节点上的属性和内容做一下比对,然后只更新修改的部分。

  • React组件类型:React此时并不知道如何去更新DOM树,因为这些逻辑还在组件里面,React能做的只是根据新节点的props去更新原来根节点的组件实例,引发这个组件实例的更新过程。也就是按顺序引发下列函数:

  • shouldComponentUpdate

    • componentWillReceiveProps
    • componentWillUpdate
    • render
    • componentDidUpdate

在这个过程中,如果shouldComponentUpdate返回false,那么更新过程就此打住,不在继续,所以为了保持最大的性能,每个react组件都必须重视shouldComponentUpdate,如果发现根本没有必要重新渲染,那么久直接返回false。

多个子组件的情况
当一个组件包含多个子组件的情况,React的处理方式也非常简单直接。
拿TODO应用中代办事项列表作为例子,假设最初的组件形态是这样的:

1<ul> 2 <TodoItem text='first' completed={false}/> 3 <TodoItem text='second' completed={false}/> 4</ul> 5

在更新之后,新的组件形态变成了下面这样:

1<ul> 2 <TodoItem text='first' completed={false}/> 3 <TodoItem text='second' completed={false}/> 4 <TodoItem text='third' completed={false}/> 5</ul> 6

那么React会发现多出了一个Item,会创建一个新的Item实例,这个Item组件实例需要经历装载过程,对于前两个TodoItem实例,React会引发他们的更新过程,但是只要这两个Item的shouldComponentUpdate做的好的话,在检查props之后返回false,并不会发生实质性的更新。

下面我们再来看一个例子,假如现在我们想要在序列前面增加一个Item实例,代码如下:

1<ul> 2 <TodoItem text='zero' completed={false}/> 3 <TodoItem text='first' completed={false}/> 4 <TodoItem text='second' completed={false}/> 5</ul> 6 7

从直观上来看,内容是zero的新代办事项被插入了第一位,只需要创造一个新的组件实例TodoItem实例插入到第一位就可以了,剩下两个内容为first和second的组件实例经历更新过程,由于shouldComponentUpdate的作用,其实并不会发生实质性的更新。然而,事实真的如此吗?

假如要让React按照我们预想的方式来做的话,就必须要找出两个子组件序列的不同之处,那么计算出两个序列差异的算法复杂度就会变成O(N^2)。脱离了React高效的初衷。
所以,React选择了一个看起来很傻的方法,不是寻找两个序列的精确差别,而是直接比较每个子组件。

在上面的例子中,React会首先认为把text为first的TodoItem组件实例的text改为zero,text为second的TodoItem组件实例的text改为first。最后面多出来一个TodoItem组件实例,text内容改为second。这样做的结果是,现存的两个TodoItem实例的text属性被改变了,强迫他们完成了一个更新过程,创造出一个新的TodoItem实例用来显示second。

理想的情况只需要增加一个TodoItem组件,但是实际上却引发了两个TodoItem实例的更新,这个明显就是一种浪费。

当然,React也意识到这种问题的存在,所以提供了一种方法来克服这种浪费,这就是key。

2.2 key的用法

React不会使用一个复杂度为O(N^2)的算法来比较前后两列子组件的差别,默认情况下,React确认每一个子组件在组件序列中的唯一标识就是通过他的位置。所以,他也完全不懂哪些子组件实际上没有发生改变,为了让React更加智能,我们需要给它一些帮助。

我们可以使用key值来告诉React每个组件的唯一身份标识,具体事例如下:

1<ul> 2 <TodoItem key={1} text='first' completed={false}/> 3 <TodoItem key={2} text='second' completed={false}/> 4</ul> 5

在第一位新增一个TodoItem实例

1<ul> 2 <TodoItem key={0} text='zero' completed={false}/> 3 <TodoItem key={1} text='first' completed={false}/> 4 <TodoItem key={2} text='second' completed={false}/> 5</ul> 6

React根据key值,可以知道现在第二和第三个TodoItem实例就是之前的第一个和第二个实例,所以React就会把新创建的TodoItem实例插在第一位,对于原有的两个TodoItem实例只用原有的props来启动更新过程,这样shouldComponentUpdate就会发生作用,避免无谓的更新操作。

3. 用reselect提高数据获取性能

在前面的例子中,都是通过优化渲染过程来提高性能,既然React和Redux都是通过数据驱动渲染过程,那么除了优化渲染过程,是不是还可以考虑一下优化获取数据的过程呢?

示例如下:

1const selectVisibleTodos=(todos,filter)=>{ 2 switch(filter){ 3 case FilterTypes.All: 4 return todos; 5 case FilterTypes.COMPLETED: 6 return todos.filter(item=>item.completed); 7 case FilterTypes.UNCOMPLETED: 8 return todos.filter(item=>!item.completed); 9 default: 10 throw new Error('unSupported filter'); 11 } 12} 13const mapStateToProps=(state)=>{ 14 return { 15 todos:selectVisibleTodos(state.todos,state.filter) 16 } 17} 18 19

作为从Redux Store上获取数据的重要一环,mapStateToProps函数一定要快,从代码上看,运算本身并没有什么可优化空间,要获取当前显示的代办事项,就是要根据Redux Store状态树上的todos和filter两个字段的值计算出来。不过这个计算过程需要遍历todos字段上的数组,当数组比较大时,每一次重新渲染都需要重新计算一遍,就显得有点太麻烦了。

实际上,并不是每一次对TodoItem的重新渲染都必须要执行selectVisibleTodos中的计算过程,如果Redux Store状态树上代表所有代办事项的todos字段没有变化,而且代表当前过滤器的filter字段也没有变化,那么实在没有必要重新遍历整个todos数组来计算一个新的结果,如果上一次的计算结果可以被缓存起来的话,那么就可以重用缓存中的数据了。

这就是reselect库的工作原理:只要相关状态没有改变,那就直接使用上一次的缓存结果。

reselect库被用来创造’选择器’。所谓选择器,就是接受一个state作为参数的函数,这个选择器函数返回的数据就是我们某个mapStateToProps需要的结果。

reselect认为的一个选择器的工作可以分为两部分,把一个计算步骤分为两个步骤:

  • 步骤一:从输入参数state抽取第一层结果,将这第一层结果和之前抽取的第一层结果做比较,如果发现完全相同,那就没有必要进行第二部分的运算了,选择器直接把之前第二部分的运算结果返回就可以了。注意,这里的比较用的javascript中的===比较。

  • 步骤二:根据第一层结果计算出选择器需要返回的最终结果。

例如:

src/todo/selectors.js

1import {createSelector} from 'reselect'; 2import {FilterTypes} from './constants.js'; 3 4export const selectVisibleTodos=createSelector( 5[getFilter,getTodos], 6(filter,todos)=>{ 7 switch(filter){ 8 case FilterTypes.ALL: 9 return todos; 10 case FilterTypes.COMPLETED: 11 return todos.filter(item=>item.completed); 12 case FilterTypes.UNCOMPLETED: 13 return todos.filter(item=>!item.completed); 14 default: 15 throw new Error('unsupported filter') 16 } 17} 18) 19

src/todo/todoList.js

1import {selectorVisibleTodos} from '../selector.js'; 2const mapStateToProps=(state)=>{ 3 return { 4 todos:selectorVisibleTodos(state) 5 } 6} 7

代码交流 2021