NHN Cloud NHN Cloud Meetup!

Reactレンダリング性能をご覧ください

Reactはシンプルでありながら十分に速い。しかし、大まかに作っても速いことはなく、うまく作ってこそ速くなります。Reactの動作原理を理解し、アプリケーションが遅くなる状況を探して修正することが重要です。幸いなことにReactはシンプルで理解しやすいライブラリです。

Reactのパフォーマンス最適化には、ReactのElement、Component、Instanceの意味とレンダリングの正確な理解があらかじめ必要です。これについては、Dan AbramovのReact Components, Elements and Instancesでよく説明されているので、ぜひ読んでみてください。

Render

(この記事で述べるエレメントはHTMLElementではなく、ReactElementを意味します。)

Reactは、UI構造を内部コンポーネントが返却するエレメントをツリー形式で管理して表現します。そして、この表現に一般オブジェクト(Plain object)を使用します。まず内部的に管理し、変更が必要な部分のみ検索するように実装し、不要なDOMノードの生成や制御を最小化しています。通常、この構造をVirtual-DOMと呼びますが、IOSやAndroidのUIを処理するReact Nativeも同じように動作します。(したがって、厳密にいうと、Virtual-DOMは少し誤用されています。)

コンポーネントのPropsやStateの変更があったとき、Reactはコンポーネントの以前の状態のエレメントと、新たに作成されたエレメントを比較して、実際にDOMを更新すべきか決めます。エレメントを比較して検知した変更点のみ更新するということです。

エレメントは基本的にImmutableであるため、属性(Props)を変更できません。各レンダリングに常に新しいエレメント(DOMノードではなく、一般オブジェクトであることを忘れないように)を作成します。エレメントは映像のフレームと同じであると考えてよいでしょう。

ReactDOM.render(
  element,
  document.getElementById('root')
);

上のコードは、ReactDOM.render()APIでレンダリングを実行します。しかし、毎回すべての変化に対してReactDOM.render()を呼び出す必要はありません。コンポーネントのsetState()メソッドが実行されると、当該コンポーネントを変更対象コンポーネント(Dirty component)に登録して、次のイベントループでバッチ処理として対象コンポーネントのエレメントをレンダリングします。

このようなReactのレンダリングを区分すると、変更点を検索する過程(ReconciliationのDiffingアルゴリズム)と、変更点を実際のUIに適用する作業に分けることができます。ブラウザ基準では、React-coreのReconciliation作業とDOM操作(ReactDOMComponent .updateComponent)として考えられます。

Reconciliation:The diffing algorithm

ReactのReconciliationは、任意の変更に対する前/後のエレメントツリーを比較(Diff)して、更新が必要な部分のみ検知し、更新することを意味します。ReactはレンダリングでReconciliation作業を先行するので、プラットフォームUIの制御を最小限に抑えます(通常、UIコントロールのコストは高いため)。つまり、ブラウザでDOMへの制御を最小化します。

もう一度整理すると、Reactコンポーネントは

  1. render()で、新しいエレメントツリーを生成する
  2. 既存のエレメントツリーと比較して変更点を探して更新する

ところが、従来のDiffアルゴリズムはO(n^3)の時間複雑度を持っています。そこで、Reactは次の2つの仮定を持つヒューリスティックアルゴリズムでO(n)に近似できるように実装しました。

  1. 異なるタイプの2つのエレメントは、他のツリーを作成するものである
  2. 各レンダリングで維持されるエレメントにkeyプロパティから同じエレメントであることを知らせる。(同レベルでのみ有効)

Diff方式をもう少し詳しく調べてみよう。

Level By Level

ツリーを比較する際は、基本的にサブツリーの位置(level-by-level)を基準に比較します。

Elements Of Different Types

同じ場所でエレメントのタイプが異なる場合

  1. 既存のツリーを除去した後、新しいツリーを作成する
  2. 既存のツリーを削除するとき、ツリー内部のエレメント/コンポーネントはすべて削除する
  3. 新しいツリーを作成するとき、内部のエレメント/コンポーネントも新しく作成する
{/* Before */}
<div>
  <Counter /> {/* Will unmount */}
</div>

{/* After */}
<span>
  <Counter /> {/* Will mount, Did mount */}
</span>

DOM Elements Of The Same Type

同じ位置でエレメントがDOMを表現し、そのタイプが同じ場合

  1. エレメントのattributesを比較する
  2. 変更されたattributesだけを更新する
  3. 子要素にdiffアルゴリズムを再帰的に適用する
{/* Before */}
<div className="before" title="stuff" />

{/* After */}
<div className="after" title="stuff" /> {/* Update className */}

Component Elements Of The Same type

同じ位置でエレメントがコンポーネントを表現し、そのタイプが同じ場合

  1. コンポーネントインスタンス自体は変わらない(したがってコンポーネントのstateが維持される)
  2. コンポーネントインスタンスの更新前のライフサイクルメソッドが呼び出され、propsが更新される
  3. render()を呼び出して、コンポーネントの以前のエレメントツリーと次のエレメントツリーに対して、diffアルゴリズムを再帰的に適用する
{/* Before */}
<Counter value="3" />

{/* After */}
{/* Will recevie props, Will update, Render --> diff algorithm recurses */}
<Counter value="4" />

Recursing On Children

子要素に対して繰り返し比較するとき、Reactは前/後の状態の子要素のリストを一緒に繰り返して、その差異を確認します。したがってエレメントの配置のような状況に弱いです。

{/* Before */}
<ul>
  <li>first</li> {/* prev-first */}
  <li>second</li>  {/* prev-second */}
</ul>

{/* After (with reordering) */}
<ul>
  <li>second</li> {/* Compares prev-first --> Update dom */}
  <li>first</li> {/* Compares prev-second --> Update dom */}
  <li>third</li> {/* Compares prev --> Insert dom */}
</ul>

Keys

エレメントに、Keyの属性を明示的に付与して上記のような状況で発生する不要なアップデートを最小化できます。ただし、(現在実装されたReactは)兄弟ノード間の移動は表現できるが、兄弟ノード以外、他に移動された場合は表現できません。keyは、単一のサブツリーのみでユニークな値を持つことができ、各レンダリングで変更されません。そして他のサブツリーとは関係ありません。

{/* Before */}
<ul>
  <li key="first">first</li> {/* prev-first */}
  <li key="second">second</li>  {/* prev-second */}
</ul>

{/* After (with reordering) */}
<ul>
  <li key="second">second</li> {/* Compares prev-second --> Update X, Reorder dom */}
  <li key="first">first</li> {/* Compares prev-first, --> Update X, Reorder dom */}
  <li key="thrid">third</li> {/* Compares prev --> Insert dom */}
</ul>

Avoid Reconciliation

前述のとおり、ReactはReconciliationで、O(n)の時間複雑度を持っており、必要以上のDOMアクセスや更新を避けるため、一般的にパフォーマンスを心配することはありません。

しかし、コンポーネントがレンダリングするエレメントが数千、数万個になれば、O(n)も遅くなります。

開発者は一部の場合において、実際にレンダリングが必要ない状況を理解しています。そのため、コンポーネントがレンダリングの前に呼び出すライフサイクルメソッドshouldComponentUpdate()をオーバーライドして、パフォーマンスを向上させることができます。ReactのshouldComponentUpdateの基本実装は、return trueであるため、オーバーライドしていない場合は、常にReconciliationを含むレンダリング処理を実行します。開発者はコンポーネントのレンダリングが必要な場合にのみ、return falseを使ってReactの不要なレンダリング処理を防止できます。

ShouldComponentUpdate In Action

以下のようなエレメントツリーのレンダリングプロセスをみてみよう。

C2からSCU(shouldComponentUpdate)がfalseを返すことでレンダリングは実行されません。したがってC4、C5のSCUは発生しません。

C1、C3はSCUからtrueを返すため、Reactは子コンポーネントを巡回してレンダリング有無を確認します。C6はSCUでtrueを返し、以前の状態のエレメントと新たに作成されたエレメントの違いを検出してDOMの更新を実行します。

C8の場合はどうでしょうか。SCUからtrueを返したので、エレメントをレンダリングします。ところが、以前の状態のエレメントと次の状態のエレメントで差異がないため、DOMを更新しません。このような場合、Reactのレンダリングプロセスは不要で、当然パフォーマンスの低下を誘発します。

全体的にみると、ReactはC6のみDOMの更新を行います。C8の場合は、新規作成されたエレメントを比較して差がないのでDOMの更新は実行せず、C2とC7はSCUからfalseが返されたことで、エレメントの比較は行わず更新もされていません。(render()も呼び出されていません。)

PureComponent

React.PureComponentshouldComponentUpdateAPIを除いてReact.Componentと同じです。PureComponentはrendererでshouldComponentUpdateライフサイクルロジックを実行するとき、基本的にshallow-compareを実行します。つまり、純粋関数のように同じ入力に対して同じ出力がなされるという意味で、Reconciliation動作を実行しないということです。ただし、n-depthの複雑なデータ構造に対してdeep-compareを実行するとshallow-compareに制限しています。Reactを開発するとしたら、stateやpropsを最大限に軽くして、あるいはImmutableオブジェクトを使って開発することをお勧めします。

ここで注意することは、通常PureComponentがshouldComponentUpdateでshallow-compareすると説明していますが、PureComponent classがshouldComponentUpdateを定義しているという意味ではなく、実際に定義されていない(React v15.4.2基準)ということです。

rendererの動作を部分的にもう少し詳しく説明すると、以下のとおりです。(参考:PR#7195

//...
if (instance.shouldComponentUpdate) {
  shouldUpdate = instance.shouldComponentUpdate(nextProps, nextState, nextContext);
} else if (instance.isPureComponent) {
  //PureComponentは、shouldComponentUpdateの実装体がなく、rendererにおいてshallow-compareを実行する。
  shouldUpdate = (
    !shallowEqual(prevProps, nextProps) || 
    !shallowEqual(instance.state, nextState)
  );
}
//...

return shouldUpdate;

そのため、次のようなコードはエラーが発生します。

class Foo extends PureComponent {
  //....

  shouldComponentUpdate(nextProps, nextState) {
    // PureComponentにshouldComponentUpdateの実装体がないため、下記のコードはエラーが発生する。
    const result = super.shouldComponentUpdate(nextProps, nextState);
    log('Foo: shouldComponentUpdate', result);

    return result;
  }

  //...
}

結論として、PureComponentでshouldComponentUpdateを作成する場合、PureComponent実装を無視するため、作成してはいけません。(現時点で、PureComponentを継承した後、shouldComponentUpdateを別々に作成する場合、開発モードで警告メッセージを表示しようという意見があります – Issue#9239

React-Addons-Perf

Reactは、一般的にreconciliation、avoid-reconciliation、production buildなどの技法を用いて迅速に動作します。しかし、一般的な方法だけでは、複雑なアプリケーションの開発は困難で、開発者がミスしたり、大量データの処理など、さまざまな状況に応じて必要な性能を得られないことがあります。

前述したShouldComponent In ActionのC8は、不要なパフォーマンスの低下が原因ですが、開発者はこのような問題を見つけて解決しなければなりません。

しかし、コードだけを見てパフォーマンスの問題があるコンポーネントを探すのは難しいでしょう。そこで、性能測定ツールであるreact-addons-perfを用いてレンダリング性能を正確に測定し、パフォーマンス低下の原因を見つけることができます。

測定する

Perf.start();
// ....
Perf.stop();

const measurements = Perf.getLastMeasurements();
  1. start():測定開始
  2. stop():測定終了
  3. getLastMeasurements():最後の測定結果を取得する

結果出力

Perf.printInclusive(); // Last measurements
Perf.printInclusive(measurements);

Perf.printExclusive(); // Last measurements
Perf.printExclusive(measurements);

Perf.printWasted(); // Last measurements
Perf.printWasted(measurements);

Perf.printOperations(); // Last measurements
Perf.printOperations(measurements);
    • printInclusive():全体の所要時間を出力
    • printExclusive():コンポーネントがマウントされている時間を除いて出力(props処理、componentWillMount()componentDidMount()など)
    • printWasted()実際にレンダリングがないコンポーネントで消費された時間(ex – Diff結果差がなくDOM変化がない)
    • printOperations():DOM操作に関するログ

使用性

通常、パフォーマンスの問題は、printWasted()APIで測定して解決できます。そして大半は、PureComponent分離を通じて解決できます。
printInclusive()printExclusive()からコンポーネントのマウント/更新などのコストを確認できます。特にライフサイクルのロジックが複雑な場合、簡単に確認することができます。printOperations()は、Reactが実際にDOMを生成または更新するログを表示し、予期しないDOMアクセス/修正などを確認できます。

Bad Cases

以下は、開発において失敗しやすいケースです。実際にレンダリングや開発の際は注意点を十分理解していますが、大規模なアプリケーションを開発していると簡単にミスしてしまいそうな部分です。表面的な問題が発生したとき、すぐミスに気づけるように次の2つは記憶しておきましょう。

  1. 分離していないコンポーネントがないか
  2. 誤ったProps伝達がされていないか

いずれも同じ原因を持っています。また、このようなケースでなくても、Reactレンダリングでパフォーマンスの低下が発生する可能性は高く、その場合もほとんどは不要なReconciliationで処理時間がかかっていることが問題です。

分離していないコンポーネント

コンポーネントが適切に分離されていなければ、可読性、保守の問題だけでなく、性能的にも非常に大きな損害を被ります。次の例をみてみよう。

テスト用アプリケーションのAppコンポーネントはRootコンポーネントで自分のtitlelistItemsを管理します。したがって、次のようなstateを持っています。

state = {
  listItmes: [],
  title: 'Test app'
};

以下はListを1つのコンポーネントに分離していないコードです。(テストではItemコンポーネントはPureComponentを継承し、テストに大きな影響がないようにしています。)

// 下記は簡単に表したAppコンポーネントのrenderメソッドである。
render() {
  <div className="app">
    ...
    <div className="app-intro">
      {this.state.title}
    </div>
    <div className="list-container">
      <ul>
      {
        this.state.items.map((item) => {
          return <Item key={item.id} {...item} />
        })
      }
      </ul>
    </div>
  </div>
}

以下はListを1つのコンポーネントに分離したコードです。このとき、ListコンポーネントはPureComponentを継承するようにしました。

// 下記は簡単に表したAppコンポーネントのrenderメソッドである。
render() {
  <div className="app">
    ...
    <div className="app-intro">
      {this.state.title}
    </div>
    <List items={this.state.items} />
  </div>
}

このようにレンダリングする2つのアプリケーションでtitleを変更すると、何がさらに速いか考えてみよう。

2つのアプリケーションのAppコンポーネントで、componentWillUpdate()からcomponentDidUpdate()までの時間をUserTimingAPIで測定しました。

  • Listコンポーネントに分離されていない場合(App-bad)
  • Listコンポーネントに分離された場合(App)
  • Appのツリー

テスト用アプリは非常にシンプルであるにも関わらず、約30〜40倍の違いがありました。もう少し複雑なアプリケーションであれば、その差はさらに広がるでしょう。

また重要な点は、titleを変更するときにPerf.start(),Perf.stop(),Perf.printWasted()APIでパフォーマンスを測定しましたが、いずれの場合もWastedTimeが発生していない(ReactPerf.jsで何のログも残さなかった)ことです。つまり、コンポーネントを適切に分離しなければ、パフォーマンスに損害があっても簡単に気付くことができないということです。

もちろん、printInclusive(),printExclusive(),printOperations()APIは、ブラウザの開発者ツールなどから上記のような問題現象を測定できますが、コンポーネント分離で簡単に解決できる問題を、敢えて難しく測定して解決する必要はありません。

誤ったProps伝達

コンポーネントを適切に分離してPureComponentを使用しても、まだ意図しないパフォーマンス低下を引き起こす恐れがあります。次のコードをみてみよう。

// 下記は簡単に表したAppコンポーネントのrenderメソッドである。
render() {
  return (
    <div className="app">
      ...
      <div className="app-intro">
        {this.state.title}
      </div>
      <List items={this.state.items} deleteItem={id => this.deleteItem(id)}/>
    </div>
  );
}

// ListはPureComponentを継承しており
// ListのItemは一般的なSFCである。

// 下記は簡単に表したListコンポーネントである。
class List extends PureComponent {
  static propTypes = {
    items: PropTypes.array,
    deleteItem: PropTypes.func
  };

  render() {
    const items = this.props.items.map((item) => {
      return <Item key={item.id} {...item} onClickDeleteButton={this.props.deleteItem} />
    });

    return (
      <ul>
        {items}
      </ul>
    );
  }
}

ListからItemを除去するためdeleteItemという関数をpropsを用いて伝達しました。一見すると特に問題ないようですが、実際には、大きなパフォーマンス低下を引き起こしています。

Appコンポーネントでtitleを変更する場合について、react-addons-perf測定したパフォーマンスをみてみよう。

このような無駄な時間が発生する理由は、deleteItem={id => this.deleteItem(id)}構文が原因です。Appのrender()でListに移行するdeleteItemが、常に新しい関数として生成されるため、ListがPureComponentでもReconciliation作業に含まれます。このため、通常propsへと移る関数はコンストラクタであらかじめバインドしてdeleteItem={this.deleteItem}構文のように、新しい関数を作成せずに伝達することをお勧めします。

react-reduxのconnect HOCも、このような場合がよくあります。以下のmapStateToPropsをみてみよう。

// StateのitemsはImmutableオブジェクトである。
const mapStateToProps = (state) => {
  items: state.items.toArray()
}

export default connet(mapStateToProps)(List);

上記のコードは、先ほどよりもはるかに大きなパフォーマンスの低下を引き起こします。Storeのすべてのアップデートにおいて、ReactはListを常にレンダリングに含めます。そのため、このような場合は、Immutableオブジェクトをarrayのような形に変換せずにそのままコンポーネントに伝達することをお勧めします。

const mapStateToProps = (state) => {
  items: state.items
}

// この場合、Listはitemsをarrayではなく、Immutableのオブジェクトで処理する必要がある。
export default connet(mapStateToProps)(List);

おわりに

Reactは直接的なUIコントロールを最小化して動作するので、基本的に速いと考えられますが、制御を最小限にするために先行される作業コストを無視してはいけません。

Reconciliationは、基本的な概念でありながら、意図しない大きなパフォーマンスの低下を引き起こす可能性があります。実際に開発者がすべてのパフォーマンス低下のケースを覚えて、コードを作成するときにその場ですぐに把握するのは難しいでしょう。最初からパフォーマンスが最適化されたアプリケーションを考慮すること自体が非効率的です。しかし、コンポーネントを適切に分離することは重要です。いくら簡単なエレメントとロジックであっても機能、責任、再利用性などで、コンポーネントを適切に分離して、案件を迅速に把握して対応できるように開発することを推奨します。

Reference

React Docs

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop