返回 登录
0

React应用优化:避免不必要的render

引言:在优化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}>foo</div>;
    }
});

  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}>foo</div>;
    }
}

  手动将 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}>foo</div>;
    }
}

  上面两种方式本质上是一致的。
  另外也有以高阶组件形式提供这种能力的工具,如库recompose提供的pure方法,用法更简单,很适合ES6写法的React组件。

import {pure} from 'recompose';

class FooComponent extends React.Component {
    render() {
        return <div className={this.props.className}>foo</div>;
    }
}

const OptimizedComponent = pure(FooComponent);

  与前两种方式不同的是,这种做法也支持函数式组件。

const FunctionalComponent = ({ className }) => (
<div className={className}>foo</div>;
);
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 key={item.id} onClick={() => onItemClick(item.id)}>{item.name}</Item>
        )
    );
    return (
<p>{items}</p>
    );
}

  如上,希望监听列表每一项的点击事件,获取当前被点击的项的ID,很自然地,在render 中为每个item创建了箭头函数作为其点击回调。这会导致每次组件BtnList的render都会重新生成一遍这些回调函数,而这些回调函数是子节点Item的props的组成,从而子节点不得不重新渲染。
函数绑定

  • 函数声明
    与函数声明类似,函数绑定(Function.prototype.bind)也会在每次执行时产生一个新的函数,从而影响使用方对props的比对。
    函数绑定的使用场景有两种,一是为函数绑定上下文(this),如下。
class WrappedInput extends React.Component {
    // ……
    onChange(e) {
        //在此添加回调代码
    }
    render() {
        return (
<Input onChange={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 => (
<Item key={item.id} onRemove={this.onRemove.bind(this, item.id)}>
                    {item.name}
</Item>
            )
        );
        return (
<section>{items}</section>
        );
    }
}

  对于这个场景最简单的做法是,将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 => (
<Item key={item.id} onRemove={this.onRemove} id={id}>
                    {item.name}
</Item>
            )
        );
        return (
<section>{items}</section>
        );
    }
}

  但不得不承认的是,对于子组件Item来说,拿到一个通用的onRemove方法是不太合理的。所以会有一些解决方案采取这样的思路:提供一个具有记忆能力的绑定方法,对于相同的参数,返回相同的绑定结果。或者借助React组件记忆先前render结果的特点,将绑定行为实现为一个组件,Saif Hakim在文章《Performance EngineeringWith React》中介绍了一种这样的实现,感兴趣的读者可以了解一下。
  笔者的观点是,绝大部分情况下,都不至于需要为了性能做这么多的妥协。除非极端情况,否则代码的简洁、可读要比性能更重要。对于这种情况,已知的解决方法或者会影响应用逻辑分布的合理性,或者会引入过多的复杂度,这里提出仅供参考,实际的必要性需要结合具体项目分析。

  • object/array字面量
    代码中的对象与数组字面量是另一处“新数据”的源头,它们经常表现为如下样式。
function Foo() {
    return (
<Bar options={['a', 'b', 'c']} />
    );
}

   处理这种情况,只需将字面量保存在常量中即可,如下。

const OPTIONS = ['a', 'b', 'c'];
function Foo() {
    return (
<Bar options={OPTIONS} />
    );
}

  本文选自《React与Redux开发实例精解》

  图片描述
 

评论