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
首先会检查两个树形的根节点类型是否相同,根据相同或者不同有不同的处理方式。
节点类型不同的情况
如果树形结构根节点类型不相同,那就意味着改动太大了,也不要费心考虑是不是原来那个树形的根节点被移动到其他地方去了,直接认为原来那个树形结构已经没用了,可以扔掉,需要重新构建新的DOM树,原有的树形上的React
组件会经历‘卸载’的生命周期。 比如:
复制代码===>
那么,在比较时,一看根节点是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
应用中代办事项列表作为例子,假设最初的组件形态是这样的:
在更新之后,新的组件形态变成了下面这样:
那么React
会发现多出了一个Item
,会创建一个新的Item
实例,这个Item
组件实例需要经历装载过程,对于前两个TodoItem
实例,React
会引发他们的更新过程,但是只要这两个Item
的shouldComponentUpdate
做的好的话,在检查props
之后返回false,并不会发生实质性的更新。
下面我们再来看一个例子,假如现在我们想要在序列前面增加一个Item
实例,代码如下:
从直观上来看,内容是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
每个组件的唯一身份标识,具体事例如下:
在第一位新增一个TodoItem
实例
React
根据key
值,可以知道现在第二和第三个TodoItem
实例就是之前的第一个和第二个实例,所以React
就会把新创建的TodoItem
实例插在第一位,对于原有的两个TodoItem
实例只用原有的props
来启动更新过程,这样shouldComponentUpdate
就会发生作用,避免无谓的更新操作。
3. 用reselect
提高数据获取性能
在前面的例子中,都是通过优化渲染过程来提高性能,既然React
和Redux
都是通过数据驱动渲染过程,那么除了优化渲染过程,是不是还可以考虑一下优化获取数据的过程呢?
示例如下:
const selectVisibleTodos=(todos,filter)=>{ switch(filter){ case FilterTypes.All: return todos; case FilterTypes.COMPLETED: return todos.filter(item=>item.completed); case FilterTypes.UNCOMPLETED: return todos.filter(item=>!item.completed); default: throw new Error('unSupported filter'); }}const mapStateToProps=(state)=>{ return { todos:selectVisibleTodos(state.todos,state.filter) }}复制代码
作为从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
import {createSelector} from 'reselect';import {FilterTypes} from './constants.js';export const selectVisibleTodos=createSelector([getFilter,getTodos],(filter,todos)=>{ switch(filter){ case FilterTypes.ALL: return todos; case FilterTypes.COMPLETED: return todos.filter(item=>item.completed); case FilterTypes.UNCOMPLETED: return todos.filter(item=>!item.completed); default: throw new Error('unsupported filter') }})复制代码
src/todo/todoList.js
import {selectorVisibleTodos} from '../selector.js';const mapStateToProps=(state)=>{ return { todos:selectorVisibleTodos(state) }}复制代码