ステートレスなコンポーネントによるReactのパフォーマンス最適化

2017/06/30

Peter Bengtsson

74
再描画による「遅い」Reactコンポーネントを速くするための方法を検討しました。追体験しながらどうぞ。

処理結果が変わるようなデータを内部に保持していないステートレスなコンポーネントを解説します。つまりthis.state ={...} を含まないコーポネントです。与えられたプロパティと、下位のコンポーネントを持ちます。

まずは超基本

import React, { Component } from 'react'

class User extends Component {
  render() {
    const { name, highlighted, userSelected } = this.props
    console.log('Hey User is being rendered for', [name, highlighted])
    return <div>
      <h3
        style={{fontStyle: highlighted ? 'italic' : 'normal'}}
        onClick={event => {
          userSelected()
        }}
        >{name}</h3>
    </div>
  }
}


Edit Basic component

できました。基本中の基本ですが、例としては良いでしょう。

覚えておきたいことは、

  • ステートレスなので、this.state ={...}がない
  • console.logで使用されている様子がわかる。特にパフォーマンス改善に取り組むなら、プロパティが変わっていないのに無駄に再描画されるのは避けたい
  • イベントハンドラにインライン関数がある。これは便利な構文で、コードは取り扱う要素のすぐ近くにあり、.bind(this)の手間も不要
  • インライン関数は毎回呼び出されるので、パフォーマンス面で若干影響する。詳しくは後述

「プレゼンテーショナル」なコンポーネント

上記のコンポーネントはステートレスだけでなく、Dan Abramovの言葉を借りると「プレゼンテーショナル(表象的)な」コンポーネントです。要するに軽量で、実際にHTML/DOMを生み出し、ステート(状態)を一切保持しないコンポーネントということです。

これを関数コンポーネントにします。理解しやすいので怖がらないでください。環境にかかわらず、入力値が同じなら常に同じ出力を返します。ただし、プロパティの1つは関数なのでコールバックを返します。

以下のように書き換えます。

const User = ({ name, highlighted, userSelected }) => {
  console.log('Hey User is being rendered for', [name, highlighted])
  return <div>
    <h3
      style={{fontStyle: highlighted ? 'italic' : 'normal'}}
      onClick={event => {
        userSelected()
      }}>{name}</h3>
  </div>
}


Edit Functional component

微妙ですが、JavaScriptのように、使用するフレームワークを考えずに書けます。

再描画でサイトが重くなる仕組み

Userコンポーネントが、一定間隔で更新されるステート(状態)を持つコンポーネントの中で使用されたと仮定します。ただし、Userコンポーネント自体にはなんら影響しません。たとえば、

import React, { Component } from 'react'

class Users extends Component {
  constructor(props) {
    super(props)
    this.state = {
      otherData: null,
      users: [{name: 'John Doe', highlighted: false}]
    }
  }

  async componentDidMount() {
    try {
      let response = await fetch('https://api.github.com')
      let data = await response.json()
      this.setState({otherData: data})
    } catch(err) {
      throw err
    }
  }

  toggleUserHighlight(user) {
    this.setState(prevState => {
      users: prevState.users.map(u => {
        if (u.name === user.name) {
          u.highlighted = !u.highlighted
        }
        return u
      })
    })
  }

  render() {
    return <div>
      <h1>Users</h1>
      {
        this.state.users.map(user => {
          return <User
            name={user.name}
            highlighted={user.highlighted}
            userSelected={() => {
              this.toggleUserHighlight(user)
            }}/>
         })
      }
    </div>
  }
}


Edit Wrapped functional component

これを実行すると、Userコンポーネントのプロパティは変わっていないのに再描画されてしまいます。現時点ではたいした影響はありませんが、実際のアプリケーションではコンポーネントがだんだん肥大化して複雑になるため、無用な再描画でサイト全体が重くなってしまいます。

react-addons-perfでこのアプリのデバッグをしたら、Users->Userのレンダリングで時間を無駄にしていることが分かるはずです。困りました……。

プロパティは変わっていないのにReactが変わったと判断するのを防ぐため、shouldComponentUpdateでコントロールします。Reactライフサイクルフックを追加するには、コンポーネント(関数コンポーネントでなく)をクラスベースのコンポーネントにします。
それではクラスベースの実装に戻して、ライフサイクルフックメソッドを新規作成します。

クラスコンポーネントに戻す

import React, { Component } from 'react'

class User extends Component {

  shouldComponentUpdate(nextProps) {
    // Because we KNOW that only these props would change the output
    // of this component.
    return nextProps.name !== this.props.name || nextProps.highlighted !== this.props.highlighted
  }

  render() {
    const { name, highlighted, userSelected } = this.props
    console.log('Hey User is being rendered for', [name, highlighted])
    return <div>
      <h3
        style={{fontStyle: highlighted ? 'italic' : 'normal'}}
        onClick={event => {
          userSelected()
        }}
        >{name}</h3>
    </div>
  }
}


Edit Wrapped class based PURE component

追加したshouldComponentUpdateメソッドを見ると、なんだか汚いですね。使わない関数、変更できるプロパティを列挙します。これはuserSelected関数のプロパティは変わらない前提であることに注意してください。

これで、親のAppコンポーネントが再描画されても、1度しかレンダリングされずパフォーマンスが向上します。ただ、もっとうまく実装できないでしょうか。

React.PureComponentを試す

React15.3以降ではコンポーネントに新しいベースクラスPureComponentが追加されました。最初からshouldComponentUpdateメソッドを持ち、各プロパティを「shallow equal」で比較します。これなら、プロパティの列挙が必要なカスタム版shouldComponentUpdateメソッドは不要なはずです。

import React, { PureComponent } from 'react'

class User extends PureComponent {

  render() {
    const { name, highlighted, userSelected } = this.props
    console.log('Hey User is being rendered for', [name, highlighted])
    return <div>
      <h3
        style={{fontStyle: highlighted ? 'italic' : 'normal'}}
        onClick={event => {
          userSelected()
        }}
        >{name}</h3>
    </div>
  }
}


Edit Functional component with recompose's pure

試すと、すぐに失望します。毎回再レンダリングされます。理由は、Apprenderメソッドを実行することでuserSelected関数が生成されるためです。つまりPureComponentベースのコンポーネントがshouldComponentUpdateメソッドを実行すると、userSelected関数は毎回新規作成されるため別物と判定され、常にtrueが返るのです。

対策は、関数をコンポーネントのコンストラクタ内でバインドします。ただし、通常は1回なのに、メソッド名を5回も書くことになります。

  • this.userSelected =this.userSelected.bind(this)(コンストラクタ内)
  • userSelected(){(メソッド定義部分)
  • <User userSelected={this.userSelected}...Userコンポーネントの描画位置を定義するとき)

もう1つの問題は、userSelectedメソッドを走らせる際にクロージャに頼っています。具体的にはthis.state.users.map()イテレータのuser変数に依存しているのです。

解決策は、userSelectedメソッドをthisにバインドして、子コンポーネント内でメソッドを呼ぶ際に、userないしその名前を返すことです。


Edit Wrapped class based PURE component methods once defined

recomposeで解決する

繰り返しで求められるのは、以下の項目です。

  1. 関数コンポーネントはきれいに書く。コードを読む人が、ステートを一切持たないと分かるようにする。単体テストの際にも理解しやすく、冗長にならず、より純粋なJavaScriptに近づく(もちろんJSXで書く)
  2. 子コンポーネントに渡すメソッドすべてをバインドしない。メソッドが複雑になるとまとめて処理するよりもリファクタリング(再構成)するほうが良いかもしれないが、一括処理のメソッドなら、コードはメソッドを使用する場所の近くに書き、名前を付けることで5回も書く必要はない
  3. 子コンポーネントはプロパティが変更されない限り再描画しない。ちょっとした軽量なパーツだけなら良いが、実際のアプリケーションで山ほどパーツがあるときに、本来避けられるのに過剰なレンダリング処理を強いることはCPUに負担がかかる

できるならコンポーネントの描写は1回にしたいです。1回で終わるのなら「Reactを高速化する方法」というテーマのブログ記事は9割減るでしょう。

公式ドキュメントには、recomposeとは「Reactの関数コンポーネントと高階コンポーネントのためのユーティリティベルトです。React用のlodashのように考えてください」とあります。このライブラリーには見どころがたくさんあるのですが、ここで求められのは関数コンポーネントのレンダリングについて、プロパティが変わらないのに再描画しないようにすることです。

this:recompose.pureを使いつつ、コードを書き直して関数コンポーネントに戻と、こうなります。

import React from 'react'
import { pure } from 'recompose'

const User = pure(({ name, highlighted, userSelected }) => {
  console.log('Hey User is being rendered for', [name, highlighted])
  return <div>
    <h3
      style={{fontStyle: highlighted ? 'italic' : 'normal'}}
      onClick={event => {
        userSelected()
      }}>{name}</h3>
  </div>
})

export default User


Edit Functional component with recompose's pure

実行するとプロパティ(nameキーとhighlightedキー)は変わっていないのにUserコンポーネントが再描画されます。

そこで1歩進んで、recompose.pureの代わりに、recompose.pureの別バージョンであるrecompose.onlyUpdateForKeysを使います。ただし対象とするプロパティのキーは以下のように記述します。

import React from 'react'
import { onlyUpdateForKeys } from 'recompose'

const User = onlyUpdateForKeys(['name', 'highlighted'])(({ name, highlighted, userSelected }) => {
  console.log('Hey User is being rendered for', [name, highlighted])
  return <div>
    <h3
      style={{fontStyle: highlighted ? 'italic' : 'normal'}}
      onClick={event => {
        userSelected()
      }}>{name}</h3>
  </div>
})

export default User


Edit Functional component with recompose's onlyUpdateForKeys

実行すると、nameあるいはhighlightedプロパティを変更したときだけ更新されます。親コンポーネントが再描画されても、Userコンポーネントはそのままのはずです。

やっと、解決方法を見つけました。

ディスカッション

コンポーネントのパフォーマンスを最適化する価値があるか考えます。たぶん、得られる価値以上に手間がかかります。コンポーネントが大きくないなら、負荷の大きな計算はメモ化(あとで再計算せずにすむように実行結果を保存する)してコンポーネントの外に出したり、まだデータが準備できていないタイミングで無駄に描画されないように書き直したりできるでしょう。たとえば、fetchが終わるまでUserコンポーネントは描画しないようにできます。

やりやすいようにコードを書き、まずは立ち上げて、繰り返し改善を加えていくのは悪いことではありません。その場合はパフォーマンス向上のため、関数コンポーネントの定義を書き換えてください。以下の状態から、

const MyComp = (arg1, arg2) => {
...
}

以下のように書き換えます。

const MyComp = pure((arg1, arg2) => {
...
})

理想は、あれこれワザを紹介するまでもなくすべてを解決して欲しいです。将来もし理想がかなうなら、shallowEqualを劇的に改善するReactの新しいパッチで、「渡されたものが関数で、比較結果がイコールでないから異なったモノとみなしなさい」と魔法のような自動判別機能でしょう。

妥協案はあります。コンストラクタにメソッドをバインドしたり、毎回再構築されるインライン関数を使わずに済むPublic Class Fieldsです。Babelのstage-2に含まれている機能で、大概は現在のシステム構成でも対応しているでしょう。たとえばこのフォークです。より短いだけでなく、関数以外のプロパティを列挙する必要はありません。この方法ではクロージャの使用はあきらめてください。それでも、必要なときのためにrecompose.onlyUpdateForKeysを理解しておくことは大切です。

本記事はJack Franklinが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:Optimizing React Performance with Stateless Components

[翻訳:西尾 健史/編集:Livit

Peter Bengtsson

Peter Bengtsson

フルスタックのWeb開発者で、MozillaのWebエンジニアリングチームの一員です。爆速で楽しいWebアプリの製作に情熱を注ぎ、15年以上にわたりオープンソースのソフトウェアを公開し続けています。新たな試みはwww.peterbe.comを見てください。

Loading...