5.1.3 React-Redux的shouldComponentUpdate实现
您可以在百度里搜索“深入浅出React和Redux 艾草文学(www.321553.xyz)”查找最新章节!
5.1.3 React-Redux的shouldComponentUpdate实现
在前面介绍过,shouldCompnoentUpdate可能是React组件生命周期函数中除了render函数之外最重要的一个函数了,render函数决定了“组件渲染出什么”,而shouldComponent-Update函数则决定“什么时候不需要重新渲染”。
React组件类的父类Component提供了shouldComponentUpdate的默认实现方式,但这个默认实现就是简单地返回一个true。也就是说,默认每次更新的时候都要调用所有的生命周期函数,包括调用render函数,根据render函数的返回结果计算Virtual DOM。
当然,默认方法是一个兜底的保险方法。毕竟React并不知道各个组件的细节,把组件重新渲染一遍就算浪费,至少绝对不会出错。
但是要达到更好的性能,有必要定义好我们的组件的shouldComponentUpdate函数,让它在必要的时候返回false,告诉React不用继续更新,就会节省大量的计算资源。每个React组件的内在逻辑都有自己的特点,需要根据组件逻辑来定制shouldComponent-Update函数的行为。
之所以出现图5-1所示的浪费时间,就是因为TodoItem是一个无状态函数,所以使用的是React默认的shouldComponentUpdate函数实现,也就是永远返回true的实现。
TodoList在重复渲染时,会引发所有TodoItem子组件的更新过程,即使传递给这些子组件的prop没有任何变化。既然TodoItem的shouldComponentUpdate行为是返回true,那React也就会调用更新过程中所有的生命周期函数,产生Virtual DOM,但是最后通过Virtual DOM的比对发现Virtual DOM没有变化,其实根本不需要修改DOM。
看起来,要做的就是给TodoItem组件增加定制的shouldComponentUpdate函数,这样就会让TodoItem代码从一个独立的函数变成一个ES6class,增加的shouldComponent-Update函数代码如下:
shouldComponentUpdate(nextProps, nextState) {
return (nextProps.completed !== this.props.completed) ||
(nextProps.text !== this.props.text);
}
因为对于一个TodoItem组件而言,影响渲染内容的prop只有completed和text,只要确保这两个prop没有变化,shouldComponentUpdate函数就可以返回false。
如果每个React组件都需要定制自己的shouldComponentUpdate函数,从写代码的角度来看也是一件很麻烦的事情。但是如果使用了react-redux库,一切都很简单。
回顾一下前面ControlPanel和Todo应用的例子,使用react-redux库,我们把完成一个功能的React组件分成两部分:
·第一部分是一个傻瓜组件,只管负责视图部分,处理的是“组件看起来怎样”的事情。这个傻瓜组件往往用一个函数的无状态组件就足够表示,甚至都不需要是一个类的形态,只需要定义一个函数就足够。
·第二部分是一个容器组件,负责逻辑部分,处理的是“组件如何工作”的事情,这个容器组件有状态,而且保持和Redux Store上状态的同步,但是react-redux的connect函数把这部分同步的逻辑封装起来了,我们甚至在代码中根本看不见这个类的样子,往往直接导出connect返回函数的执行结果就行。
使用react-redux,一个典型的React组件代码文件最后一个语句代码是这样:
export default connect(mapStateToProps, mapDispatchToProps)(Foo);
可以看到,往往都没有必要把产生的容器组件赋值给一个变量标示符,直接把connect的结果export导出就可以了。
虽然代码上不可见,但是connect的过程中实际上产生了一个无名的React组件类,这个类定制了shouldComponentUpdate函数的实现,实现逻辑是比对这次传递给内层傻瓜组件的props和上一次的props,因为负责“组件看起来怎样”的傻瓜视图是一个无状态组件,它的渲染结果完全由传入的props决定,如果props没有变化,那就可以认为渲染结果肯定一样。
例如,我们有一个Foo组件,代码如下:
import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
const Foo = ({text}) => (
{text}
)
const mapStateToProps = (state) => (
text: state.text
);
export default connect(mapStateToProps)(Foo);
这个组件简单得实在不能更简单,就是把Redux Store的状态树上的text字段显示出来。内部的傻瓜组件Foo只有一个props属性text,通过react-redux的connect方法,这个文件导出的是封装过的容器组件。
在这个例子中,导出的容器组件的shouldComponentUpdate所做的事情,就是判断这一次渲染的text值和上一次的text值是否相同,如果相同,那就没有必要重新渲染了,可以返回false;否则就要返回true。
这样,我们编写的Foo依然是一个无状态组件,但是当要渲染Foo组件实例时,只要Redux Store上的对应state没有改变,Foo就不会经历无意义的Virutal DOM产生和比对过程,也就避免了浪费。
同样的方法也可以应用在TodoItem组件上。不过,因为TodoItem没有直接从Redux Store上读取状态,但我们依然可以使用react-redux方法,只是connect函数的调用不需要任何参数,要做的只是将定义TodoItem组件的代码最后一行改成如下代码,代码如下:
export default connect()(TodoItem);
在上面的例子中,在connect函数的调用没有参数,没有mapStateToProps和map-DispatchToProps函数,使用connect来包裹TodoItem的唯一目的就是利用那个聪明的shouldComponentUpdate函数。
使用React Perf重复检查性能,可以发现并没有真正解决问题,依然存在渲染浪费的问题,如图5-2所示。
图5-2 使用connect之后依然存在渲染时间浪费
和图5-1的不同之处只是产生浪费的组件发生了变化,TodoList渲染Connect(TodoItem)是浪费,根源是Connect(TodoItem)渲染TodoItem浪费,其中Connect(TodoItem)就是利用react-redux的connect函数产生的容器组件。
要理解为什么会出现这个现象,就要理解react-redux提供的是shouldCompnent-Update是怎样的实现方式。
相对于React组件的默认shouldComponentUpdate函数实现,react-redux的实现方式当然是前进了一大步。但是在对比prop和上一次渲染所用prop方面,依然用的是尽量简单的方法,做的是所谓“浅层比较”(shallow compare)。简单说来就是用JavaScript的===操作符来进行比较,如果prop的类型是字符串或者数字,只要值相同,那么“浅层比较”也会认为二者相同,但是,如果prop的类型是复杂对象,那么“浅层比较”的方式只看这两个prop是不是同一对象的引用,如果不是,哪怕这两个对象中的内容完全一样,也会被认为是两个不同的prop。
比如,JSX中使用组件Foo的时候给名为style的prop赋值,代码如下:
像上面这样使用方法,Foo组件利用react-redux提供的shouldComponentUpdate函数实现,每一次渲染都会认为style这个prop发生了变化,因为每次都会产生一个新的对象给style,而在“浅层比较”中,只比较第一层,不会去比较对象里面是不是相等。
看起来,react-redux采用“浅层比较”似乎做得不够好,为什么不用“深层比较”呢?
但其实这是一个正确的决定,因为一个对象到底有多少层无法预料,如果递归对每个字段都进行“深层比较”,不光让代码更加复杂,也可能会造成性能问题。
在通用的shouldCompnentUpdate函数中做“浅层比较”,是一个被普遍接受的做法;如果需要做“深层比较”,那就是某个特定组件的行为,需要开发者自己根据组件情况去编写。不过也要谨记,不要简单地递归比较所有层次的字段,因为传递进来的prop对象什么结构是无法预料的。
总之,要想让react-redux认为前后的对象类型prop是相同的,就必须要保证prop是指向同一个JavaScript对象。
上面使用Foo组件的例子应该改进成下面这样。
const fooStyle = {color: "red"}; //确保这个初始化只执行一次,不要放在render中
同样的情况也存在于函数类型的prop,react-redux无从知道两个不同的函数是不是做着一样的事情,要想让它认为两个prop是相同的,就必须让这这两个prop指向同样一个函数,如果每次传给prop的都是一个新创建的函数,那肯定就没法让prop指向同一个函数了。
看TodoList传递给TodoItem的onToggle和onRemove这个prop是如何写的,在JSX中的代码如下:
onToggle={() => onToggleTodo(item.id)}
onRemove={() => onRemoveTodo(item.id)}
这里赋值给onClick的是一个匿名的函数,而且是在赋值的时候产生的。也就是说,每次渲染一个TodoItem的时候,都会产生一个新的函数,这就是问题所在。
虽然每次产生的匿名函数做的都是同样的事情,但是react-redux只认函数类型prop是不是指向同一个函数对象,每次都新产生的函数怎么可能通过这个检验呢,所以,即使TodoItem组件被react-redux库装备上了巧妙的shouldCompoentUpdate实现,依然躲不过每次更新过程都要重新渲染的命运。
怎么办呢?办法就是不要让TodoList每次都传递新的函数给TodoItem。
在Todo应用这个例子中,TodoItem组件实例的数量是不确定的,而每个TodoItem的点击事件函数又依赖于TodoItem的id,所以处理起来有点麻烦这里有两种方式,下面以onToggle这个prop为例展示一下,onRemove使用类似的方法。
第一种方式,TodoList保证传递给TodoItem的onToggle永远只指向同一个函数对象,这样是为了应对TodoItem的shouldComponentUpdate检查,但是因为TodoItem可能有多个实例,所以这个函数要用某种方法区分什么TodoItem回调这个函数,区分的方法只能通过函数参数。
在TodoList组件中,mapDispatchToProps产生的prop中的onToggleTodo接受TodoItem的id作为参数,恰好胜任这个工作,所以,可以在JSX中代码改成下面这样:
key={item.id} id={item.id} text={item.text} completed={item.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) }); 以前我们只使用过mapDispatchToProp这个函数的第一个参数dispatch,其实这个函数还有第二个参数ownProps,也就是父组件渲染当前组件时传递过来的props,通过访问ownProps.id就能够得到父组件传递过来的名为id的prop值。 上面的mapDispatchToProps函数给TodoItem组件增加了名为onToggleItem的prop,调用onToggle,传递当前实例的id作为参数,在TodoItem的JSX中就应该使用onToggleItem,而不是直接使用TodoList提供的onToggle。 第二种方式,干脆让TodoList不要给TodoItem传递任何函数类型prop,点击事件完全由TodoItem组件自己搞定。 在TodoList组件的JSX中,渲染TodoItem组件的代码如下: key={item.id} id={item.id} text={item.text} completed={item.completed} /> 可以看到不需要onToggle和onRemove这些函数类型prop,但依然有名为id的prop。 在TodoItem组件中,需要自己通过react-redux派发action,需要改变的代码如下: const mapDispatchToProps = (dispatch, ownProps) => { const {id} = ownProps; return { onToggle: () => dispatch(toggleTodo(id)), onRemove: () => dispatch(removeTodo(id)) } }; 对比两种方式,可以看到无论如何TodoItem都需要使用react-redux,都需要定义产生定制prop的mapDispatchToProps,都要求TodoList传入一个id,区别只在于actions是由父组件导入还是由组件自己导入。 相比而言,没有多大必要让action在TodoList导入然后传递一个函数给TodoItem,第二种方式让TodoItem处理自己的一切事务,更符合高内聚的要求。 在https://github.com/mocheng/react-and-redux的chapter-05/todo_perf目录下,可以看到用第二种方式实现的完整代码,在代码中有意把傻瓜组件TodoItem变成了一个ES6class,这么做完全是为了在这个组件的构造函数中写上console.log,在render函数中也使用了console.log语句。这样,在演示改进的Todo应用时,根据浏览器Console上的输出,就能知道TodoItem组件是否经历了装载过程和更新过程。 重新启动改进后的Todo应用,添加三个待办事项,分别是First、Second和Third,让First和Third反转为完成状态,清空浏览器的Console。 这时候,我们点击“已完成”过滤器,待办事项就只显示First和Third,在Console上看不到输出,说明这个过程中没有任何TodoItem组件被创建和更新。 然后我们再点击“全部”过滤器,这时在浏览器Console中可以有如下输出: enter TodoItem constructor: Second enter TodoItem render: Second 这个过程涉及TodoList组件的更新,但是First和Third两个TodoItem实例的render函数并没有被调用,说明react-redux的shouldComponentUpdate函数起了作用,避免了没有必要的重新渲染,使用React Perf工具,也不再出现浪费的渲染时间。 读者可能好奇,在“全部”和“未完成”之间切换的时候,似乎只有Second这个TodoItem经历了装载过程,React如何知道First和Third这两个TodoItem不需要渲染的呢?这就是下一节多个React组件性能优化要讨论的问题。 深入浅出React和Redux