一、单个React组件的性能优化
React利用Virtual DOM来提高渲染性能,虽然这能将每次DOM操作量减少到最小,计算和比较Virtual DOM依然是一个复杂的计算过程。如果能够在计算Virtual DOM之前就能判断渲染结果不会有变化,那样可以干脆不要进行Virtual DOM计算和比较,速度就会更快。
-
发现浪费的渲染时间
在Chrome浏览器中安装React Perf扩展,步骤省略(属于操作部分)
-
性能优化的时机
“我们应该忘记忽略很小的性能优化,可以说97%的情况下,过早的优化是万恶之源,而我们应该关心对性能影响最关键的那另外3%的代码” --高德纳
对于合并多个字符串,怎样合并,使用什么方法合并不大可能对整个应用造成关键的性能影响,这就是高纳德所说的97%的情况,而选择用什么样的方式去定义组件的接口,如何定义state到prop的转变,使用什么样的算法来比对Virtual DOM,这些决定对性能和架构的影响是巨大的,就是那关键的3%。
- React-Redux的shouldComponentUpdate的实现
使用React-Redux,一个典型的React组件代码文件最后一个语句代码是这样的:
export default connect(mapStateToProps)(mapDispatchToProps)(Foo)
以上,connect过程中实际上产生了一个无名的React组件类,这个类定制了shouldComponentUpdate的实现,实现逻辑是比对这次传递给内层傻瓜组件的props和上次的props,如果相同那就没必要重新渲染了,可以返回false,否则就要返回true。
但是,我们需要了解一下shouldComponentUpdate的实现方式,shouldComponentUpdate在比对prop和上次渲染所用的prop方面,依然用的是尽量简单的方法,做的是所谓的“浅层比较”。简单来说就是用JavaScript的===操作符来比较,如果prop的类型是字符串或者数字,只要值相同,那么“浅层比较”也会认为二者相同,但是,如果prop的类型是复杂对象,那么“浅层比较”的方式只看这两个prop是不是同一个对象的引用,如果不是,哪怕这两个对象中的内容完全一样,也会被认为是两个不同的prop。
比如,在JSX中使用组件Foo的时候给名为style的prop赋值,代码如下:
<Foo style={{color:"red"}} />
像上面这样的使用方法,Foo组件利用React-Redux提供的shouldComponentUpdate函数实现,每一次渲染都会认为style这个prop发生了变化,因为每次都会产生一个新的对象给style,而在“浅层比较”中,只比较第一层,不会去比较对象里面是不是相等。那为什么不用深层比较呢?因为一个对象到底有多少层无法预料,如果递归对每个字段都进行“深层比较”,不光代码更复杂,也可能会造成性能问题。
上面的例子应该改成下面这样:
const fooStyle = {color:"red"} //确保这个初始化只执行一次,不要放在render中
<Foo style={fooStyle} />
同样的情况也存在与函数类型的prop,React-Redux无从知道两个不同的函数是不是做着同样的事,要想让它认为两个prop是相同的,就必须让这两个prop指向同样一个函数,如果每次传给prop的都是一个新创建的函数,那肯定就没法让prop指向同一个函数了。
看TodoList传递给TodoItem的onToggle和onRemove,在JSX中代码如下:
onToggle = {()=>onToggleTodo(item.id)}
onRemove = {()=>onRemoveTodo(item.id)}
这里赋值给onClick的是一个匿名的函数,而且是在赋值的时候产生的。也就是说,每次渲染一个TodoItem的时候,都会产生一个新的函数,这就是问题所在。办法就是不要让TodoList每次都传递新的函数给TodoItem。有两种解决方式。
(1)第一种方式,TodoList保证传递给TodoItem的onToggle永远只能指向同一个函数对象,这是为了应对TodoItem的shouldComponentUpdate的检查,但是因为TodoItem可能有多个实例,所以这个函数要用某种方法区分什么TodoItem回调这个函数,区分的办法只能通过函数参数。
在TodoList组件中,mapDispatchToProps产生的prop中onToggleTodo接受TodoItem的id作为参数,恰好胜任这个工作,所以,可以在JSX中代码改为下面这样:
<TodoItem
key=em.id
id=em.id
text=em.text
completed=em.completed
onToggle={onToggleTodo}
onRemove={onRemoveTodo}
/>
注意,除了onToggle和onRemove的值变了,还增加了一个新的prop名为id,这是让每个TodoItem知道自己的id,在回调onToggle和onRemove时可以区分不同的Todo-Item实例。
TodoList的代码简化了,但是TodoItem组件也要做对应改变,对应TodoItem组件的mapDispatchToProps函数代码如下:
const mapDispatchToProps = (dispatch,ownProps) =>({
onToggleItem : () => ownProps.onToggle(ownProps.id)
});
mapDispatchToProps这个函数有两个参数dispatch和ownProps,ownProps也就是父组件渲染当前组件时传递过来的props,通过访问ownProps.id就能够得到父组件传递过来的名为id的prop值。
上面的mapDispatchToProps这个函数给TodoItem组件增加了名为onToggleItem的prop,调用onToggle,传递当前实例的id作为参数,在TodoItem的JSX中就应该使用onToggleItem,而不是直接使用TodoList提供的onToggle。
(2)第二种方式,干脆让TodoList不要给TodoItem传递任何函数类型prop,点击事件完全由TodoItem组件自己搞定。
在TodoList组件的JSX中,渲染TodoItem组件的代码如下:
<TodoItem
key = em.id
id = em.id
text = em.text
completed = em.completed
/>
可以看到不需要onToggle和onRemove这些函数类型的prop,但依然有名为id的prop。
在TodoItem组件中,需要自己通过react-redux派发action,需要改变的代码如下:
const mapDispatchToprops = (dispatch,ownProps) = >{
const {id} = ownProps.id;
return {
onToggle : () => dispatch(toggleTodo(id)),
onRemove : () => dispatch(removeTodo(id))
}
};
对比这两种方式,看一看到无论如何TodoItem都要使用react-redux,都需要定义产生定制prop的mapDispatchToProps,都需要TodoList传入一个id,区别只在于actions是由父组件导入还是组件自己导入。
相比而言,没有多大必要让action在TodoList导入然后传递一个函数给TodoItem,第二种让TodoItem处理自己的一切事物,更符合高内聚的要求。
二、多个React组件的性能优化
和单个组件的生命周期一样,React组件也要考虑3个阶段:装载阶段、更新阶段、卸载阶段。其中,装载阶段基本没什么可以优化的空间,因为这部分工作没有什么可以省略的。而卸载阶段,只有一个生命周期函数componentWillUnmount,这个函数做的事情只是清理componentDidMount添加的事件处理监听等收尾工作,做的事情要比装载过程少很多,所以也没什么可以优化的空间。所以值得关注的过程,只剩下更新过程。
-
React的调和过程
React在更新阶段,很巧妙的对比原有的Virtual DOM和新生成的Virtual DOM(存在于内存中),找出两者的不同,根据不同修改DOM树,这样只需做最小的必要改动。
React在更新中找不同的过程,就叫做调和(Reconciliation)。
React实际采用的算法的时间复杂度是O(N)。React的Reconciliation算法并不复杂,当React要对比两个Virtual DOM的树形结构的时候,从根节点开始递归往下对比,在树形结构上,每个节点都可以看做这个节点以下子树部分的根节点,所以其实这个对比算法可以从Virtual DOM上的任何一个节点开始执行。
React首先检查两个根节点的类型是否相同,根据相同或者不同有不同处理方式。
(1)节点类型不同的情况
这时可以直接认为原来的树形结构已经没用,需要重新构建新的DOM树,原有树形上的React组件会经历“卸载”的生命周期。这时,componentWillUnmount的方法会被调用,取而代之的组件则会经历装载过程的生命周期,组件的componentWillMount、render和componentDidMount方法会被依次调用。
(2)节点类型相同的情况
这时React就会认为原来的根节点只需要更新,不必将其卸载,也不会引发根节点的重新装载。
这时,有必要区分一下节点的类型,节点的类型可以分为两类:一类是DOM元素类型,对应的就是HTML直接支持的元素类型,比如<div />,<span />和<p />;另一类是React组件,也就是利用React库定制的类型。
- 对于DOM元素类型,React会保留节点对应的DOM元素,只对树形结构根节点上的属性和内容做一下对比,然后只更新修改的部分。
- 对于React组件类型,React会根据新节点的props去更新原来根节点的props实例,引发这个组件实例的更新过程,也就是按照顺序引发下列函数:
shouldComponentUpdate
componentWillReceiveProps
componentWillUpdate
render
componentDidUpdate
在处理完根节点的对比之后,React的算法会对根节点的每个子节点重复一样的动作,这时候每个子节点就会成为它所覆盖部分的根节点,处理方式和它的父节点完全一样。
(3)多个子组件的情况
当一个组件包含多个子组件,React的处理方式也非常的简单直接。
React发现多了一个TodoItem,会创建一个新的TodoItem组件实例,这个TodoItem组件实例需要经历装载过程,对于前两个TodoItem实例,React会引发它们的更新过程。
上面的例子是TodoItem序列后面增加了一个新的TodoItem实例,接下来在TodoItem序列前面增加一个TodoItem实例,代码如下:
像上面新的TodoItem实例插入在第一位的例子中,React会首先认为把text为First的TodoItem组件实例的text改成了Zero,text为Second的TodoItem组件实例的text改成了First,在后面多出了一个TodoItem组件实例,text内容为Second。这样操作的后果就是,现存的两个TodoItem实例的text属性被改变了,强迫它们完成了一个更新过程。React提供了方法来克服这种浪费,但需要开发人员在写代码的时候提供一点帮助,这就是key的作用。
- key的用法
默认情况下,在React的眼里,确定每一个组件在组件序列中的唯一标识就是它的位置,所以它也完全不懂哪些子组件实际上并没有改变,为了让React更加“聪明”,就需要开发者提供一点帮助。
如果在代码中明确的告诉React每个组件的唯一标识,就可以帮助React在处理这个问题时聪明很多,告诉React每个组件“×××号”的途径就是key属性。假如让待办事项列表用JSX标识的代码如下:
<UI>
<TodoItem key={1} text="First" completed={false} />
<TodoItem key={2} text="Second" completed={false} />
<UI />
前面代码的区别是每个TodoItem增加了名为key的prop,而且每个key是这个TodoItem实例的唯一id。现在第一位增加一个TodoItem实例,并给一个唯一的key值0,React的处理方式会不一样。
<UI>
<TodoItem key={0} text="Zero" completed={false} />
<TodoItem key={1} text="First" completed={false} />
<TodoItem key={2} text="Second" completed={false} />
<UI />
React根据key值,可以知道现在的第二个和第三个TodoItem实例其实就是之前的第一个和第二个实例,所以React就会把新创建的TodoItem实例插在第一位,对于原有的两个TodoItem实例只用原有的props来启动更新过程,这样shouldComponentUpdate就会发生作用,避免无谓的更新操作。
在一列子组件中,每个子组件的key值必须唯一,并且key值还需要是稳定不变的,因此数组的下标不能作为key使用。
注意:虽然key是一个prop,但是接受key的组件并不能读取到key的值,因为key和ref是React保留的两个特殊的prop,并没有预期让组件直接访问。
三、用reselect提高数据获取性能
-
两阶段选择过程
reselect库的工作原理:只要相关状态没有改变,那就直接使用上一次的缓存结果。
reselect库被用来创造“选择器”,即接受state作为参数的函数,这个选择器函数返回的数据就是我们某个mapStateToProps需要的结果。
reselect认为一个选择器的工作可以分为两个部分,把一个计算步骤分为两个步骤:
(1)从输入参数state抽取第一层结果,将这第一层结果和之前抽取的第一层结果做比较,如果发现完全相同,就没有必要进行第二部分运算了,选择器直接把之前第二部分的运算结果返回就好了。注意:这一部分做的比较,就是JavaScript的===操作符比较,如果第一层结果是对象的话,只有是同一对象才会被认为是相同。
(2)根据第一层结果计算出选择器需要返回的最终结果。
显然,每次选择器函数被调用时,步骤一都会被执行,但步骤一的结果被用来判断是否可以使用缓存的结果,所以并不是每次都会调用步骤二的运算。
剩下的事情就是确定选择器步骤一和步骤二分别进行什么运算。原则很简单,步骤一运算因为每次选择器都要使用,所以一定要快,运算要非常简单,最好就是一个映射运算,通常就只是从state参数中得到某个字段的引用就足够,把剩下来的重活累活都交给步骤二去做。
在TodoList的具体例子中,todos和filter的值直接决定了应该显示什么样的待办事项,所以,步骤一是获取todos和filter的值,步骤二是根据这两个值进行计算。
使用reselect需要安装对应的npm包:
npm install --save reselect
reselect提供了创造选择器的createSelector函数,这是一个高阶函数,也就是接受函数为参数来产生一个新函数的函数。
第一个参数是一个函数数组,每个元素代表了选择器步骤一需要做的映射计算,这里我们提供了两个函数getFilter和getTodos,代码如下:
const getFilter = (state)=>state.filter;
const getTodos = (state) = >state.todos;
createSelector函数的第二个参数代表步骤二的计算过程,参数为第一个参数的输出结果。
现在,可以在TodoList模块中改用新定义的选择器来获取待办事项数据了:
import {selectVisibleTodos} from '../selector.js'
const mapStateToProps = (state) =>{
return {
todos : selectVisibleTodos(state)
}
}
Redux要求每个reducer不能修改state状态,如果要返回一个新的状态,就必须返回一个新的对象。这样,如果state状态树上的某个节点没有变化,那我们可以认为这个节点下的数据没有改变,应用在reselect中,步骤一的运算就可以确定使用缓存的数据结果。
虽然reselect的createSelector创造的选择器并不是一个纯函数,但是createSelector接受的所有函数参数都是纯函数,虽然选择器有“记忆”这个副作用,但只要输入参数state没有变化,产生的结果也就没有变化,表现得却类似于一个纯函数。
- 范式化状态树
状态树的设计尽量范式化。所谓范式化,就是遵照关系型数据库的设计原则,减少冗余数据。
反范式化的设计:
{
id : 1, //待办事项id
text:“待办事项1”, //待办事项文字内容
completed:false, //是否已完成
type:{ //种类
name:“紧急” , //种类的名称
color:“red” //种类的显示颜色
}
}
范式化的设计:
{
id:1, //待办事项id
text:“待办事项1” //待办事项文字内容
completed:false, //是否已完成
typeId:1, 待办事项所属的种类id
}
用一个typeId代表类型,然后在Redux store上和Todos平级的根节点位置创建一个types字段,内容是一个数组,每个数组元素代表一个类型,一个种类的数据是类似下面的对象:
{
id:1, //种类id
name:“紧急”, //种类的名称
color:“red” //种类的显示颜色
}
当TodoItem要渲染内容时,从Redux Store状态树的todos字段下获取的数据是不够的,因为只有typeId。为了获得对应的种类名称和颜色,需要做一个类似关系型数据库的join操作,到状态树的type字段下去寻找对应的typeId的种类数据。
以上比较来看范式方式更合理。因为虽然join数据需要花费计算时间,但是用了reselect之后,大部分情况下都会命中缓存,实际上也就没有花费很多计算时间了。