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開發實例精解》,點此鏈接可在博文視點官網查看。

  圖片描述

 

發佈了187 篇原創文章 · 獲贊 0 · 訪問量 8389
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章