引言:在优化React应用时,绝大部分的优化空间在于避免不必要的render——即Virtual DOM节点的生成,这不仅可以节省执行render的时间,还可以节省对DOM节点做Diff的时间。
本文选自《React与Redux开发实例精解》,将会从五点向您介绍如何避免不必要的render。
1.shouldComponentUpdate
React在组件的生命周期方法中提供了一个钩子shouldComponentUpdate,这个方法默认返回true,表示需要重新执行render方法并使用其返回的结果作为新的Virtual DOM节点。通过实现这个方法,并在合适的时候返回false,告诉React可以不用重新执行render,而是使用原有的Virtual DOM 节点,这是最常用的避免render的手段,这一方式也常被很形象地称为“短路”(short circuit)。
shouldComponentUpdate方法会获得两个参数:nextProps及nextState。常见的实现是,将新旧props及state分别进行比较,确认没有改动或改动对组件没有影响的情况下返回false,否则返回true。
如果shouldComponentUpdate使用不当,实现中的判断并不正确,会导致产生数据更新而界面没有更新、二者不一致的bug,“在合适的时候返回false”是使用这个方法最需要注意的点。要在不对组件做任何限制的情况下保证shouldComponentUpdate完全的正确性,需要手工依据每个组件的逻辑精细地对props、state中的每个字段逐一比对,这种做法不具备复用性,也会影响组件本身的可维护性。
所以一般情况下,会对组件及其输入进行一定的限制,然后提出一个通用的shouldComponentUpdate实现。
首先要求组件的render是“pure”的,即对于相同的输入,render总是给出相同的输出。在这样的基础上,可以对输入采用通用的比较行为,然后依据输入是否一致,直接判断输出是否会是一致的。若是,则可以返回false以避免重复渲染。
其次是对组件输入的限制,要求props与state都是不可修改的(immutable)。如果props与state会被修改,那么判断两次render的输入是否相同便无从说起。
最后值得一说的是,“通用的比较行为”的实现。从理论上说,要判断JavaScript中的两个值是否相等,对于基本类型可以通过===直接比较,而对于复杂类型,如Object、Array,===意味着引用比较,即使引用比较结果为false,其内容也可能是一致的,遍历整个数据结构进行深层比较(deep compare)才能得到准确的答案。但是,shouldComponentUpdate是一个会被频繁调用的方法,而深比较是代价很大的行为,如果数据结构较为复杂,进行深比较甚至会不如直接执行一遍render,通过shouldComponentUpdate实现“短路”也就失去了意义。因此一般来说,会采取一个相对可以接受的方案:浅比较(shallow compare)。相比深比较会遍历整个树状结构而言,浅比较最多只遍历一层子节点。即对于下例的两个对象:
const props = { foo, bar };
const nextProps = { foo, bar };
浅比较会对props.foo与nextProps.foo、props.bar与nextProps.bar进行比较(要求严格相等),而不会深入比较props.foo与nextProps.foo的内容。如此,比较的复杂度会大大降低。
2.Mixin与HoC
前面提到,一个普遍的性能优化做法是,在shouldComponentUpdate中进行浅比较,并在判断为相等时避免重新render。PureRenderMixin是React官方提供的实现,采用Mixin的形式,用法如下。
var PureRenderMixin = require('react-addons-pure-render-mixin');
React.createClass({
mixins: [PureRenderMixin],
render: function() {
return <div className={this.props.className}>foodiv>;
}
});
Mixin是ES5写法实现的React组件所推荐的能力复用形式,ES6写法的React组件并不支持,虽然你也可以这么做。
import PureRenderMixin from 'react-addons-pure-render-mixin';
class FooComponent extends React.Component {
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
}
render() {
return <div className={this.props.className}>foodiv>;
}
}
手动将 PureRenderMixin提供的shouldComponentUpdate方法挂载到组件实例上。但与其这样,不如直接使用另一个React提供的辅助工具shallow-compare。
import shallowCompare from 'react-addons-shallow-compare';
export class FooComponent extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
}
render() {
return <div className={this.props.className}>foodiv>;
}
}
上面两种方式本质上是一致的。
另外也有以高阶组件形式提供这种能力的工具,如库recompose提供的pure方法,用法更简单,很适合ES6写法的React组件。
import {pure} from 'recompose';
class FooComponent extends React.Component {
render() {
return <div className={this.props.className}>foodiv>;
}
}
const OptimizedComponent = pure(FooComponent);
与前两种方式不同的是,这种做法也支持函数式组件。
const FunctionalComponent = ({ className }) => (
foo;
);
const OptimizedComponent = pure(FunctionalComponent);
3.不可变数据
前面提到,为了让这种“短路”的做法产生预期的效果,要求数据(props与state)是不可变的。然而在JavaScript中,数据天生是可变的,修改复杂的数据结构也是很自然的做法。
const a = { foo: { bar: 1} };
a.foo.bar = 2;
但以这种方式修改数据会导致使用了a作为props的组件失去实现shouldComponentUpdate的意义。为此,Facebook的工程师开发了immutable-js用于创建并操作不可变数据结构。典型的使用是如下这样的。
import Immutable from 'immutable';
const map1 = Immutable.Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 50);
map1.get('b'); // 2
map2.get('b'); // 50
使用immutable-js的代价主要有两部分,一方面库本身的体积并不算小(55.7KB,Gzip压缩后16.3KB),另一方面在开发中需要引入一套新的数据操作方式。除了immutable-js外,mori、Cortex等也是可选的方案,但也都有着类似的问题。幸而大部分情况下都可以选择另外一个相对代价较小的做法:使用 JavaScript原生语法或方法中对不可变数据更友好的那些部分。
对于基本数据类型(boolean、number、string 等),它们本身就是不可变的,它们的操作与计算会产生新的值。而对于复杂数据类型,主要是object与array,在修改时需要稍加注意。
对于object,像如下这样的操作方式是会修改原数据本身的。
obj.a = 1;
obj['b'] = 2;
Object.assign(obj, { a: 1 });
而下面这样的操作是不会的。
const newObj = Object.assign({}, obj, { a: 1 });
如果借助Object Rest/Spread Properties的语法(目前处于Stage 2的提案,在未来可能成为标准),还可以如下这么写。
const newObj = { ...obj, { a: 1 } };
对于array,如下这样的操作会修改原数据本身。
arr[0] = 1;
arr.push(2);
arr.pop();
arr.unshift(3);
arr.shift();
arr.splice(0, 1, [2]);
而Array.prototype也提供了很多不会修改原数组的变换方法,它们会返回一个新的数组作为结果。
arr.concat(1);
arr.slice(-1);
arr.map(item => item.name);
arr.filter(item => item.name !== '');
也可以通过增加一步复制数组的行为,然后在新的数组上进行操作。
const newArr = Array.from(arr);
newArr.push(1);
const newArr2 = Array.from(arr);
newArr2[0] = 1;
如果借助ES6的Array Rest/Spread语法,还可以如下这么做。
[...arr, 1];
[...arr.slice(0, -1), 1];
React官方也有提供一个便于修改较复杂数据结构深层次内容的工具——react-addons-update,它的用法借鉴了MongoDB的query语法(示例来自React官方文档)。
var update = require('react-addons-update');
var newData = update(myData, {
x: {y: {z: {$set: 7}}},
a: {b: {$push: [9]}}
});
如上的行为会在myData的基础上创造一个新的对象newData,且newData.x.y.z会被赋值为7,newData.a.b的内容(一个数组)会被push进值9。对比不使用update的写法(示例来自React官方文档)如下。
var newData = extend(myData, { x: extend(myData.x, { y: extend(myData.x.y, {z: 7}), }), a: extend(myData.a, {b: myData.a.b.concat(9)}) });
上例中extend(myData, …) 的行为类似于Object.assign({},myData, …)。可见,在很多场景下,update都是一个非常有用的工具,可以提高代码的简洁性与可读性。
4.计算结果记忆
使用immutable data可以低成本地判断状态是否发生变化,而在修改数据时尽可能复用原有节点(节点内容未更改的情况下)的特点,使得在整体状态的局部发生变化时,那些依赖未变更部分数据的组件所接触到的数据保持不变,这在一定程度上减少了重复渲染。
然而很多时候,组件依赖的数据往往不是简单地读取全局state上的一个或几个节点,而是基于全局state中的数据计算组合出的结果。以一个Todo List应用为例,在全局的state中通过list存放所有项,而组件VisibleList需要展示未完成项。
const stateToProps = state => {
const list = state.list;
const visibleFilter = state.visibleFilter;
const visibleList = list.filter(
item => (item.status === visibleFilter)
);
return {
list: visibleList
};
};
function List({list}) {/* ... */}
const VisibleList = connect(stateToProps)(List);
如上,在方法stateToProps中基于state计算出当前要展示的项列表visibleList,并将其传递给组件List进行展示。有一个潜在的性能问题是,当state的内容变更时,即使state.list与state.filter均未变更,每次执行stateToProps都会计算生成一个新的visibleList数组。这时即便组件List在shouldComponentUpdate方法中对props进行比较,得到的结果也是不相等的,从而触发重新render。
当应用变得复杂时,绝大部分组件所使用的数据都是基于全局state的不同部分,通过各种方式计算处理得到的,这一情况会随处可见,很多基于shouldComponentUpdate的“短路”式优化都会失去效果。
对此,有一个简单的解决方法是记忆计算结果。一般把从state计算得到一份可用数据的行为称为selector。
const visibleListSelector = state => state.list.filter(
item => (item.status === state.visibleFilter)
);
如果这样的selector具备记忆能力,即在其结果所依赖的部分数据未变更的情况下,直接返回先前的计算结果,那么前面提到的问题将迎刃而解。
reselect就是实现了这样一个能力的JavaScript库。它的使用很简单,下面来改写一下上边的几个selector。
import { createSelector } from 'reselect';
const listSelector = state => state.list;
const visibleFilterSelector = state => state.visibleFilter;
const visibleListSelector = createSelector(
listSelector,
visibleFilterSelector,
(list, visibleFilter) => list.filter(
item => (item.status === visibleFilter)
)
);
可以看到,实现了3个selector:listSelector、visibleFilterSelector及visibleListSelector,其中visibleListSelector由listSelector与visibleFilterSelector通过createSelector组合而成。即,一个selector可以由一个或多个已有的selector结合一个计算函数组合得到,其中组合函数的参数就是传入的几个selector的结果。reselect的价值不仅在于提供了这种组合selector的能力,而且通过createSelector组合产生的selector具有记忆能力,即除非计算函数有参数变更,否则它不会被重新执行。也就是说,除非state.list或state.visibleFilter发生变化,visibleListSelector才会返回新的结果,否则visibleListSelector会一直返回同一份被记忆的数据。
可见,类似reselect这样的方案帮助解决了基于原始state的计算结果比较的问题,有助于实现shouldComponentUpdate来提升应用性能。同时,将基于state的计算行为以统一的形式实现并组装,也有助于复用逻辑,提高应用的可维护性。
5.容易忽视的细节
最后,在组件的实现中,一些很容易被忽视的细节,会趋于让相关组件的shouldComponentUpdate失效,给性能带来潜在的风险。它们的特点是,对于相同的内容,每次都创造并使用一个新的对象/函数,这一行为存在于前面提到的selector之外,典型的位置包括父组件的render方法、生成容器组件的stateToProps方法等。下面是一些常见的例子。
- 函数声明
经常在render中声明函数,尤其是匿名函数及ES6的箭头函数,用来作为回调传递给子节点,一个典型的例子如下。
const onItemClick = id => console.log(id);
function List({list}) {
const items = list.map(
item => (
- item.id} onClick={() => onItemClick(item.id)}>{item.name}
)
);
return (
{items}
);
}
如上,希望监听列表每一项的点击事件,获取当前被点击的项的ID,很自然地,在render 中为每个item创建了箭头函数作为其点击回调。这会导致每次组件BtnList的render都会重新生成一遍这些回调函数,而这些回调函数是子节点Item的props的组成,从而子节点不得不重新渲染。
函数绑定
- 函数声明
与函数声明类似,函数绑定(Function.prototype.bind)也会在每次执行时产生一个新的函数,从而影响使用方对props的比对。
函数绑定的使用场景有两种,一是为函数绑定上下文(this),如下。
class WrappedInput extends React.Component {
// ……
onChange(e) {
//在此添加回调代码
}
render() {
return (
this.onChange.bind(this)} />
);
}
//……
}
这种情况一般出现在ES6写法的React组件中,因为通过ES5的写法React.createClass创建的组件,在被实例化时,其原型上的方法会被统一绑定到实例本身。因此对于这种情况,通常建议参考ES5写法的组件的做法,将bind行为提前,即在实例化时将需要绑定的方法进行手动绑定。
class WrappedInput extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this); }
//……
onChange(e) {
// do some stuff……}
render() {
return ( ); } //……}
这样bind只需执行一次,每次render传入给子组件Input的都是同一个方法。
二是为函数绑定参数,在父组件的同一个方法需要给多个子节点使用时尤为常见,如下。
class List extends React.Component {
onRemove(id) {
//在此添加回调代码
}
render() {
const items = this.props.items.map(
item => (
- this.onRemove.bind(this, item.id)}>
{item.name}
)
);
return (
{items}
);
}
}
对于这个场景最简单的做法是,将bind了上下文的父组件方法onRemove连同item.id传递给子组件,由子组件在调用onRemove时传入item.id,像如下这样。
class Item extends React.Component {
onRemove() {
this.props.onRemove(this.props.id);
}
render() {
//在此this.onRemove方法
}
}
class List extends React.Component {
constructor(props) {
super(props);
this.onRemove = this.onRemove.bind(this);
}
onRemove(id) {}
render() {
const items = this.props.items.map(
item => (
- this.onRemove} id={id}>
{item.name}
)
);
return (
{items}
);
}
}
但不得不承认的是,对于子组件Item来说,拿到一个通用的onRemove方法是不太合理的。所以会有一些解决方案采取这样的思路:提供一个具有记忆能力的绑定方法,对于相同的参数,返回相同的绑定结果。或者借助React组件记忆先前render结果的特点,将绑定行为实现为一个组件,Saif Hakim在文章《Performance EngineeringWith React》中介绍了一种这样的实现,感兴趣的读者可以了解一下。
笔者的观点是,绝大部分情况下,都不至于需要为了性能做这么多的妥协。除非极端情况,否则代码的简洁、可读要比性能更重要。对于这种情况,已知的解决方法或者会影响应用逻辑分布的合理性,或者会引入过多的复杂度,这里提出仅供参考,实际的必要性需要结合具体项目分析。
- object/array字面量
代码中的对象与数组字面量是另一处“新数据”的源头,它们经常表现为如下样式。
function Foo() {
return (
{['a', 'b', 'c']} />
);
}
处理这种情况,只需将字面量保存在常量中即可,如下。
const OPTIONS = ['a', 'b', 'c'];
function Foo() {
return (
<Bar options={OPTIONS} />
);
}
本文选自《React与Redux开发实例精解》,点此链接可在博文视点官网查看。