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