React 渲染性能優化

性能優化

在React內部已經使用了許多巧妙的技術來最小化由於Dom變更導致UI渲染所耗費的時間。對於很多應用來說,使用React後無需太多工作就會讓客戶端執行性能有質的提升。然而,還是很其他更多的辦法來加速React程序。

使用生產模式來構建應用

如果在開發和使用的過程中感覺了React應用有明顯的性能問題,請先確認是否已經構建了壓縮後的生產包:

  • 在單頁面用中,打包之後的生產文件應該是.min.js版本。
  • 對於Brunch(html打包工具:http://brunch.io/),打包命令需要包含-p標記。
  • 對於Browserify(UMD規範打包工具:http://browserify.org/),打包時需要增加生產配置參數—— NODE_ENV=production
  • 對於在創建React App時,需要執行 npm run build 命令,並按照說明操作。
  • 對於Rollup(JavaScript代碼高效壓縮工具:https://rollupjs.org/),生產打包時需要在 commonjs 插件之前使用 replace 插件:
    plugins: [ require('rollup-plugin-replace')({ 'process.env.NODE_ENV': JSON.stringify('production') }), require('rollup-plugin-commonjs')(), // ... ]plugins: [ require('rollup-plugin-replace')({ 'process.env.NODE_ENV': JSON.stringify('production') }), require('rollup-plugin-commonjs')(), // ... ]

    可以在這裏看到 一個完整的例子:see this gist

  • 使用Webpack打包,需要在打生產包的配置腳本中增加以下配置和插件:

    new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('production') } }), new webpack.optimize.UglifyJsPlugin()new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('production') } }), new webpack.optimize.UglifyJsPlugin()

切記不要將開發模式的包發佈到生產環境,因爲開發包中額外包含了許多用於輔助的測試的信息,無論在加載還是執行時,它都比較慢。

使用chrome分析組件的渲染時間線

在開發模式中下你可以直接在chrome的性能工具中看到組件是如何裝載、更新和卸載的。例如下面的圖片展示的效果:

React 渲染性能優化

在chrome中按照以下步驟執行:

  1. 使用?react_perf作爲url參數(例如:http://localhost:3000/?react_perf)
  2. 打開chrome的開發工具Timeline,然後點擊Record(左上角的紅色按鈕)。
  3. 執行你要監控的操作。請不要記錄超過20秒,這可能會導致chrome假死。
  4. 停止記錄。
  5. React事件將會批量記錄在User Timing標籤裏。

關於分析的數據,需要明確的是:渲染的時間只是一個相對的參考值,在構建成生產包之後,渲染的速度會更快。儘管如此,這些數據仍然能夠幫助我們分析是否有不相關的UI被錯誤的更新,以及UI更新的頻率和深度。

目前只有Chrome、Edge和IE支持這個特性,但是官方正在使用User Timing API 標準 讓更多瀏覽器支持這個特性。

手工避免重複渲染

React構建和維護了一個內部的虛擬Dom,這個Dom和真實的UI是相互映射的關係,他包含從用戶自定義組件中返回的各種React元素。這個虛擬的Dom使得React可以避免重複渲染相同的Dom節點並在訪問存在的節點時直接使用React的虛擬層數據,這樣設計的原因是重複渲染瀏覽器或web view的UI比操作一個JavaScript的對象要慢許多。在React Native也採用同樣的處理方式。

當組件的props和state變更時,React會將最新返回的元素與之前舊的元素進行對比來確定是否真的需要重新渲染真實的Dom。當他們不相等時,React會更新真實的Dom。

在某些情況下,可以在自定義組件中重載shouldComponentUpdate方法來加速觸發渲染的比對的過程。該方法的默認實現返回參數爲true,此時React將按照原來的方式進行比對和渲染:

shouldComponentUpdate(nextProps, nextState) {
  return true;
}

如果在某些情況下能夠清晰的明確組件不需要重新渲染,可以在 shouldComponentUpdate 方法中返回 false,這樣會讓讓組件跳過整個渲染過程,包括不再調用當前組件和子組件的render()方法。

shouldComponentUpdate 的執行過程

下面是一個組件結構樹。圖中,「SCU」表示 shouldComponentUpdate 方法返回的值(綠色true,紅色fasle),「vDOMEq」表示React的匹配是否一致(綠色true,紅色fasle),有顏色的紅圈表示是否執行了UI重繪(綠色表示沒重繪,紅色表示執行重繪)。

React 渲染性能優化

在C2組件中,shouldComponentUpdate 方法返回了false,所以React不會判斷是否需要重新渲染C2並且不執行render()方法, 因此在C4和C5中不再執行shouldComponentUpdate 方法。

對於C1和C3,shouldComponentUpdate 都返回了true,所以React必須對着2個組件進行比對。對於C6,shouldComponentUpdate 返回true,而且比對的結果是需要UI重繪,因此C6會更新他們的真實Dom。

還有一個值得關心的組件是C8,React在這個組件中執行了render()方法,但是由於虛擬Dom並沒有發生變更,前後比對一致,所以並沒有發生真實Dom渲染。

在整個過程中React僅僅變更了C6組件的UI樣式,C8由於前後虛擬Dom一致因此沒有真正的執行UI渲染。C2、C2的子組件以及C7沒有執行render()方法。

一個shouldComponentUpdate的例子

在例子中,當props.color和state.count發生變更時進行UI渲染,我們在 shouldComponentUpdate 方法中進行檢查:

class CounterButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  shouldComponentUpdate(nextProps, nextState) {
    //只判斷props.color和nextState.count是否變更,其他情況均不渲染
    if (this.props.color !== nextProps.color) {
      return true;
    }
    if (this.state.count !== nextState.count) {
      return true;
    }
    return false;
  }

  render() {
    return (
      <button color={this.props.color} onClick={() => this.setState(state => ({count: state.count + 1}))}> Count: {this.state.count} </button>
    );
  }
}

在這段代碼中,shouldComponentUpdate 僅僅檢查 props.color和 state.count是否發生變更,如果他們的值沒有修改,組件將不會發生任何更新。在實際使用中,組件往往比這個複雜,我們可以使用類似於「淺比較」(關於淺比較可以參看: Shallow Compare)的模式來比對所有的屬性或狀態是否發生變更。React提供了這個模式的一個實現組件,只要讓組件繼承自 React.PureComponent即可。我們可以將代碼進行下面的修改:

//繼承自React.PureComponent
class CounterButton extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  render() {
    return (
      <button color={this.props.color} onClick={() => this.setState(state => ({count: state.count + 1}))}> Count: {this.state.count} </button>
    );
  }
}

在大部分情況下,只要使用 React.PureComponent 就可以代替我們自己重載 shouldComponentUpdate方法,但是它僅僅適用於「淺比較」,所以這個組件不適用於props和state數據發生突變的情況。

附:數據突變(mutated)是指變量的引用沒有改變(指針地址未改變),但是引用指向的數據發生了變化(指針指向的數據發生變更)。例如const x = {foo:'foo'}。x.foo='none' 就是一個突變。

在更復雜的數據結構中還會存在一些問題。例如下面的代碼,我們希望ListOfWords 組件將words參數渲染成一個逗號分隔的字符串,而父組件監控點擊事件,每次點擊都會增加一個單詞到列表中,但是下面的代碼並不會正確工作:

class ListOfWords extends React.PureComponent {
  render() {
    return <div>{this.props.words.join(',')}</div>;
  }
}

class WordAdder extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      words: ['marklar']
    };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    // 這段內容會導致代碼不按照預期工作。
    const words = this.state.words;
    words.push('marklar');
    this.setState({words: words});
  }

  render() {
    return (
      <div>
        <button onClick={this.handleClick} />
        <ListOfWords words={this.state.words} />
      </div>
    );
  }
}

導致代碼無法正常工作的原因是 PureComponent 僅僅對 this.props.words的新舊值進行「淺比較」。在words值在handleClick中被修改之後,即使有新的單詞被添加到數組中,但是this.props.words的新舊值在進行比較時是一樣的(引用對象比較),因此 ListOfWords 一直不會發生渲染。

非突變數據的價值

有一個簡單的方法預防上面提到的問題,就是在使用prop和state時防止數據發生突變。例如下面的例如,我們用數組的concat方法來代替等號「=」,這樣在concat後會產生一個新的數組賦值給this.state.words:

handleClick() {
  this.setState(prevState => ({
    words: prevState.words.concat(['marklar'])
  }));
}

ES6支持列表擴展語法,因此我們更容易在es6中實現非突變的數據賦值,例如:

handleClick() {
  this.setState(prevState => ({
    words: [...prevState.words, 'marklar'],
  }));
};

可以重寫傳統的賦值語句防止對象中的數據發生數據突變。下面的例子有一個名爲 colormap 的對象,我們想在修改 colormap.right 的值時渲染組件,我們可以這樣重寫組件:

function updateColorMap(colormap) {
  colormap.right = 'blue'; //淺拷貝,指針地址未變,數據發生變化。
}

可以使用 Object.assign 方法來防止數據突變:

function updateColorMap(colormap) { // 深拷貝,修改返回對象的地址 return Object.assign({}, colormap, {right: 'blue'}); }function updateColorMap(colormap) { // 深拷貝,修改返回對象的地址 return Object.assign({}, colormap, {right: 'blue'}); }

修改後 updateColorMap 方法返回一個新的實例。需要注意的是某些瀏覽器不支持 Object.assign方法,我們需要使用polyfill(差異化抹平,比如我們引入了babel-polyfill)來解決這個問題。

有一個新的JavaScript方案是使用 擴展傳播特性(見 object spread properties )來解決數據突變問題,實現如下:

function updateColorMap(colormap) {
  return {...colormap, right: 'blue'};
}

如果是構建React的App應用,那麼以上方法都能夠很好的支持,如果是在瀏覽器環境使用,需要引入polyfill機制。

使用不可變的數據結構

Immutable.js 是解決數據突變問題的另外一種解決方案。它提供不可變、持久化的集合。集合包含下列結構:

  • Immutable:一旦數據被創建,改集合不能在任何其他地方修改。
  • Persistent:可以從已有的的數據集合(例如set)來創建新的數據集合。在創建新的數據集合後,已有的數據集合依然有效。
  • 結構分享(Structural Sharing):使用和原始數據儘可能相似的結構創建新的數據集合,並將複製降至最低,儘可能的提高效率。

數據結構不可變的特性使跟蹤數據變化變得很簡單。任何變更將始終導致創建一個新的對象,所以我們只需要檢查引用(指針地址)是否已經被修改即可確定數據是否已經修改。例如在常規的JavaScript代碼中:

const x = { foo: "bar" };
const y = x;
y.foo = "baz";
x === y; // true

儘管y的值已經被修改,但是它和x都是同一個引用(指向相同的地址),因此最後的比較語句會返回true。我們可以使用 immutable.js來修改代碼:

const SomeRecord = Immutable.Record({ foo: null});
const x = new SomeRecord({ foo: 'bar'});
const y = x.set('foo', 'baz');
x === y; // false

在這個例子中,由於x突變時使用了新的引用,我們可以安全的假設x已經發生改變。

還有兩個庫可以幫我們構建不可變數據: seamless-immutable and immutability-helper

不可變的數據結構爲我們跟蹤數據對象變更提供了更加簡便的方式,這是我們快速實現shouldComponentUpdate方法的基礎。使用不可變數據後,可以爲React提供不錯的性能提升。