JavaScript
webpack
React
React16
webpack4

煽りタイトルですみません。
最近、Reactのプロジェクトのページを動かしていて、
もっさりしてる(レンダリングの負荷が高いな)と思ったので
どうやったら無駄なレンダリングを減らせるか思考錯誤したことをまとめました。
preactとか別ライブラリの話はしません。

よかったらこちらもどうぞ
ReactJSで作る今時のSPA入門(基本編)

ちなみに同時公開の裏記事もあるよ
Headless CMSとWordPressの囚人

RAILモデル

まず、Googleのパフォーマンスガイドなのですが、RAIL モデルでパフォーマンスを計測するというのがあります。

  • ユーザーを第一に考えます。最終目標は、特定の端末でのサイトの処理速度を上げることではありません。ユーザーが満足感を得ることが最終目標です。
  • ユーザーに対して即座に応答します。ユーザー入力は、100 ミリ秒以内に認識します。
  • アニメーションやスクロールでは、10 ミリ秒以内にフレームを生成します。
  • メインスレッドのアイドル時間を最大限に活用します。
  • ユーザーの作業を妨げません。インタラクティブ コンテンツは 1000 ミリ秒以内に提供します。

rail.png

遅延に対するユーザーの反応

  • 0~16 ミリ秒: とても良い、ゲームとかでも60FPS(1フレームあたりの描画間隔が16ミリ秒)とかになっていますね
  • 0~100 ミリ秒: ページ内動作としては許容範囲
  • 100~300 ミリ秒: ページ内動作のレスポンスとしてはやや遅い
  • 300~1000 ミリ秒: ページの読み込み単位であればスムーズな体験を提供している
  • 1000 ミリ秒以上: 1秒を超えるとユーザは実行したタスクへの関心を失う
  • 10,000 ミリ秒以上: ユーザーは不満を感じてタスクを中断し、そのまま戻ってこない恐れがあります

レスポンスが早いことはユーザ体験的にも正義です。

どのコンポーネントがレンダリングされているか可視化する

ChromeのReact DevtoolアドオンのHighlight Updateでどこがレンダリングされているか可視化できます。レンダリングされている箇所がボーダーでハイライトされます。

画面収録-2018-12-01-15.31.422.gif

レンダリングパフォーマンスの計測方法(React 16)

Debugging React performance with React 16 and Chrome Devtools.のやり方まんまです。

React Devtoolアドオンなどがレンダリングパフォーマンスに悪影響を及ぼす可能性があるので、実際に計測する際はdisableにすることが推奨されています。
画面をフルにつかうため、Chrome Dev toolのドックを分離します。

2.gif

実際の実行環境が高スペックなPCとは限りません。ロースペックなモバイル端末かもしれません。
PerformanceタブのCPUの設定でダウングレードエミュレートする設定があります。
その上のNetworkの設定でも、3G環境など通信環境が悪い想定をエミュレートできる設定があります。

3.gif

ページをリロードしてローディングのパフォーマンス計測するにはPerformanceタブの更新ボタンを押します。
ページ内での操作を行い、計測をするには●ボタンで行い、Stopを押します。

4.gif

Scriptが実行されている箇所は黄色い箇所です。SummaryにScriptが実行されたトータル時間が表示されます。
①の青い線がDOMContentLoaded(最初のDOMの解析が完了した時間)イベントが発生した時間、赤い線がload(ページが完全に読み込まれた時間)イベントが発生した時間です。

5.png

User Timingを開き、Reactの処理がされている箇所に移動します。

6.gif
1.gif

ソースマップファイルが存在する場合、さらに処理内部のどこで時間がかかったかわかります。

  1. フレームグラフのバーをクリックすると各処理がかかった時間がわかります。
  2. Bottom-Upをタブを開きます。
  3. 処理の時間をソートします。
  4. ソースマップファイルがある場合表示されます。
  5. 実際のソースコード内のどこで時間がかかっているか調査できます。

7.png

8.png

ソースマップファイルはwebpackでビルドしている場合は、webpack.config.jsのdevtool設定で出力できます。
出力するフォーマットに関してはwebpack公式ドキュメントを参考にしてください。

参考: Devtool

webpack.config.js
module.exports = {
  mode: 'development', // 開発モード
  devtool: 'source-map', // ソースマップファイル出力
}

Reactのレンダリングパフォーマンス最適化戦略

とあるページで表示される次のようなDOMツリーがあるとします。
各ノードはReactのコンポーネントを想定しています。

無題の図形描画.png

末端のノードDでsetStateをしてコンポーネントの状態を変更した場合、Dのレンダリングが走ります。
この場合、影響範囲はレンダリングのDのみです。

無題の図形描画 (1).png

では、一番親のAでsetStateした場合はどうなるのかというと、Aのrenderメソッドが呼ばれて、
子以下の全てのComponentがレンダリング対象になります。(子のコンポーネントがReact.Component、Stateless Functional Componentのみの場合)
この場合、再レンダリングをする必要がない子コンポーネントも無駄にrenderメソッドが呼ばれてしまいます。
(子コンポーネントのpropsにstateのパラメータを渡すとかは関係なしに子コンポーネント以下のrenderが呼ばれます!)
厄介なのは親のコンポーネントにぶらさがっている子コンポーネントが多ければ多いほど、何も対処しない場合にレンダリング負荷が上がっていきます。

無題の図形描画.png

reduxとreact-reduxを使う場合、renderの影響コンポーネントを限定的にすることができます。
(connectの第1引数のmapStateToPropsで参照している箇所のみ)
この構造のメリットはconnectの第2引数であるmapDispatchToPropsでreduxのアクションをどこからでも呼べます。
注意したいのは親コンポーネント(この図だとA)ではmapStateToPropsを参照しないようにすることです。
(参照してAの方のpropsを更新してしまうと木全体が再レンダリングの対象となるため)
ただし、この場合でもB以下のD、Eはレンダリングが走ります。

無題の図形描画.png

根本的に余計なレンダリングを減らすためには子コンポーネント側でshouldComponentUpdateをオーバライドして条件によってfalseを返す必要があります。(デフォルトは常にtrue)

1_cEWErpe-oY-_S1dOaT1NtA.jpeg

shouldComponentUpdateを自前で書く場合は、shouldComponentUpdateに渡ってくるprops、stateと
コンポーネントが持っているpropsとstateを比較して、違いがあるのみレンダリングするという判定をします。

参考:Reactの再レンダリングをなるべく減らす

class SampleComponent extends React.Component {
  static get defaultProps() {
    return {
      sampleProp: '',
    }
  }

  constructor(props) {
    super(props);
    this.state = {
      sampleState: '',
    }
  }

  shouldComponentUpdate(nextProps, nextState) {
    return !(this.state.sampleState === nextState.sampleState &&
             this.props.sampleProp === this.props.SampleProp)

  }

  render() {
    // 略
  }
}

上記のshouldComponentUpdateの判定処理を自動的にやってくれるPureComponentがReact 15.3以降から使えます。
簡易的にやるには、React.ComponentはReact.PureComponentに置き換え、
Stateless Functional Component(SFC)の場合はReact 16.6から使えるReact.memoでwrapすると良いでしょう。
ちなみに、connectでwrapされたコンポーネントはデフォルトでPureComponent扱いになります。
参考:connectの第4引数のoptions

基本的にはPureComponent、React.memoとreact-reduxのconnectを組み合わせる方針で良いと思いますが次のアンチパターンには気をつけなければいけません。

PureComponentの再レンダリングを引き起こすアンチパターン

特にpropsで子PureComponentにパラメータを渡す箇所に関して気をつけないと子コンポーネントの再レンダリングを誘発します。
propsに渡すものは再生成しない、いいね?

参考:【React】 PureComponent にアロー関数を渡してはいけない

ちなみにrefに関してはpropsじゃないのでインラインのアロー関数を渡してもOK
参考:PureComponent にインラインのアロー関数を渡してもいい場合

アロー関数をpropsに即時関数で渡す

即時関数で直接propsに渡してしまうと、親のrenderが呼ばれるたびに別のオブジェクトとして即時関数が再生成される
そのため、PureComponentのshouldComponentUpdateでは違うpropsが渡ってきたものとみなされるので再レンダリングされてしまう。
次の例はChildコンポーネントにchange propsで親のハンドリング関数を渡す場合

・NG

 render () {
   return <Child change={() => console.log('hoge')} />
 }

・OK

bindを使う

 constructor(props) {
   super(props)
   this.hoge = this.hoge.bind(this)
 }

 hoge() {
   console.log('hoge')
 }

 render () {
   return <Child change={this.hoge} />
 }

もしくはplugin-proposal-class-propertiesプラグインを使っている場合はクラス内アロー関数で定義しておいたものを指定できます

 hoge = () => {
   console.log('hoge')
 }

 render () {
   return <Child change={this.hoge} />
 }

デフォルトパラメータ付きでpropsに渡す

途中で値が変わるような場合はpropsが変更されたとみなされて、子コンポーネントのrenderが呼ばれる

・NG

 render () {
   return <Child options={this.abc || []} />
 }  

・OK
constructorでやりましょう

 constructor(props) {
   super(props)
   this.abc = []
 }

 render () {
   return <Child options={this.abc} />
 }  

bindをrenderでやってしまう

・NG

 hoge() {
   console.log('hoge')
 }

 render () {
   return <Child change={this.hoge.bind(this)} />
 }

・OK
constructorでやりましょう

constructor(props) {
   super(props)
   this.hoge = this.hoge.bind(this)
 }

 hoge() {
   console.log('hoge')
 }

 render () {
   return <Child change={this.hoge} />
 }

SFC vs React.memo vs PureComponent

ステートレスなComponentに関してはSFCにしたほうがReactの無駄なライフサイクルの処理がないため、レンダリング速度があがります。
参考:45% Faster React Functional Components, Now

React.memoやPureComponentはどの粒度で使えば良いのかというとHOCや特にライフサイクルメソッドのコストが軽くはないので少なくとも小規模なDOMしか持たないコンポーネントにとってはReact.memoやPureComponentは機能過多になり、パフォーマンスが逆に落ちます。
おそらくレンダリングするDOM数とトレードオフなので使いどころは考えましょう。
参考:本当は怖いReact.memo
この辺はどれくらいがトレードオフなのか実際に計測してみないとわかりません。

また、React 16.7で出る予定のReact Hookを使うとFunctional Component(FC)にstateを持つことも可能になるのでよりPureComponentの必要性がなくなるかもしれません。(Componentの関数化が進み、React.memoが主流になる)
【React】新機能hooks

そもそもページ単位でstateを持たせるような設計がアンチパターンのため、stateを持つのはできるだけ末端のコンポーネントに限定します。
データの参照が必要なコンポーネントに関してはconnectで直接参照するようにします。(ページ内で更新されうるデータを持つ)
個人的にはFC群をまとめた、React.memoの親コンポーネントを用意するとレンダリングの影響範囲を限定できて良い気がします。これはAtomic DesignのOrganisms(有機体)くらいの粒度のイメージだと思います。

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f35353037372f36613935613461372d336537332d336165302d633866342d3261356261376639653964362e706e67.png

原子: デザインの最小要素、カラー、フォント、見出し、ボタン、入力欄等
分子: 原子を組み合わせてグルーピングしたパターン(見出しと本文のセットなど)
有機体: 分子を組み合わせて作り出されたインタフェース(見出し+メニュー欄→ナビゲーションバー)
テンプレート: 有機体を組み合わせてできた、ページのワイヤーフレーム
ページ: ワイヤーフレーム外のページ独自デザイン要素も入ったページそのもの

無駄なレンダリングを検知する

why-did-you-updateパッケージを使うと
stateやpropsが子コンポーネントに伝わったタイミングで無駄なレンダリングを検知することができます。(デバッグ用)

import React from 'react'

// 無駄なレンダリングを検知する
if (process.env.NODE_ENV !== 'production') {
  const {whyDidYouUpdate} = require('why-did-you-update')
  whyDidYouUpdate(React)
}

スクリーンショット 2018-12-06 19.03.35.png

サンプル

StackBlitzにレンダリングが呼ばれるかの比較を書いてみたので確認してみると挙動がわかります。
(console開いてみてね)

スクリーンショット 2018-12-01 22.57.52.png

Code SplitingとDynamic importによるローディング高速化

Code Splitingを使えば、ビルド時にbundle.jsから指定のcomponentを別のjsファイルに分離することができます。

Webpackの場合はwebpackのoptimization項目で主要なライブラリを別ファイルに分離することができます。
webpack CodeSplitting

以下の例は1つだったbundle.jsから
react.jsファイルとcore.jsファイルとbundle.jsファイルの3つのファイルに分離する例です。
(巨大なbundle.jsを1つ読み込むよりも複数の同サイズのjsファイルを並列で読み込んだほうがローディングが高速化します)
react関連のパッケージはreact.jsにそれ以外のサイズが大きいライブラリはcore.jsに分離します。

webpack.build.js
const Visualizer = require('webpack-visualizer-plugin')

module.exports = {
  mode: 'production', // 本番環境
  optimization: {
    splitChunks: {
      cacheGroups: {
        react: {
          test: /react/,
          name: 'react',
          chunks: 'all',
        },
        core: {
          test: /redux|core-js|jss|history|matarial-ui|lodash|moment|rollbar|\.io|platform|axios/,
          name: 'core',
          chunks: 'all',
        },
      },
    },
  },
  plugins: [
    new Visualizer({filename: './stats.html'})
  ],
}

webpack-visualizer-pluginプラグインを使えば、生成されたjsファイルの容量の内訳を可視化してくれます。(この例だと、stats.htmlを出力)
いわずもがなですが、使っていないライブラリはパッケージから除外しましょう。もしくは不必要に大きいパッケージは気をつけたほうが良いです。bundle.jsのサイズが膨れ上がります。

5b284d60-71da-11e5-8d35-7d1d4c58843a.png

また、dynamic importと呼ばれる技術で非同期にjsファイルをローディングすることができます。
これにより同期読み込みでブロッキングされる時間が短縮されます。
(※ただし、SSRする際はランディングするページのコンポーネントに関しては同期読み込みしないとDOM一致しないという問題があったりします。)
一応、ランディングしたページに関してのみ同期読み込みするサンプルを作ってみました。
SSR(サーバサイドレンダリング)について

// 同期
import Sample from './Sample'
// 非同期
import('./Sample').then(module => module.default).then(Sample => {/*処理*/})

また、webpackでビルドする際はimportするファイルにwebpackChunkNameというマジックコメントをつけることで、
webpackビルド時にimport対象のファイルをbundle.jsから分離することができます。
次の例は、Sampleコンポーネントをsample.jsに分離する例です。(webpackChunkNameはファイル名を指定するため、一意である必要があります。)

import(/* webpackChunkName: "sample" */ './Sample')

さらに、webpack 4.6.0以降では、prefetchの機能もつけることが可能になりました。
webpackPrefetchのマジックコメントを付与することで、アイドル時間を利用して先読みでjsリソースファイルを取得することが可能です。
読み込む優先順位もz-indexライクな数値で指定することができます。(その場合、trueは0扱い)
参考:<link rel=”prefetch/preload”> in webpack
webpackPrefetchに関しては指定しまうと非同期で対象のjsリソースを取りに行くため、
次のページで使用されると予想されるComponentにのみに対象を絞ったほうが良いでしょう。(無駄な通信を避ける)
未検証ですが、GAのトラッキングデータなどからどのページに遷移しそうか推測してくれるGuess.jsなんてものもあったりします。

import(/* webpackPrefetch: true, webpackChunkName: "sample" */ './Sample')

React.lazyという機能も実装されたのですが、SSR未対応なため、まだloadable-componentsが推奨されています。(使うタイミングで非同期読込する)

使い方は次のような感じです。

import loadable from '@loadable/component'

const Sample = loadable(() => import(/* webpackChunkName: 'sample' */ './Sample'))

簡易的にloadableと等価なComponentを自作すると次のような感じです。

AsyncComponent.js
import React from 'react'

// 遅延レンダリングを行うコンポーネント
export default (loader) => (
  class AsyncComponent extends React.Component {
    constructor(props) {
      super(props)
      this.Component = null
      this.state = { Component: AsyncComponent.Component }
    }

    componentDidMount() {
      // 遅延して読み込み完了させる
      setTimeout(() => this.setState({startProgress: true}), 500)
      if (!this.state.Component) {
        loader()
          .then(module => module.default) // export defaultされたコンポーネント
          .then(Component => {
            // コンポーネントを遅延読み込みしたものに差し替え
            AsyncComponent.Component = Component
            this.setState({ Component })
          })
      }
    }

    render() {
      if (this.state.Component) {
        // Wrapしたコンポーネントをレンダリングする
        return <this.state.Component { ...this.props } />
      }

      if (!this.state.startProgress) {
        return null
      }

      // Loading中コンポーネント
      return <div>Now Loading...</div>
    }
  }
)

縦に長いページに関してはファーストビュー以外は非同期importにしたほうが、初回のレンダリングが早くなります。
(ただし、SSR onlyなページやAMPページの場合はサーバ側で全部レンダリングさせるしかない)

まとめ

レンダリング

  • そもそも親コンポーネントでのstate変更が必要な設計は極力さける、react-reduxのconnectでレンダリングするコンポーネントを限定する。末端のノードのみstateを持つ設計にすることでレンダリングの影響範囲を少なくする
    • 親でstateが必要だと感じた場合はデータの影響部分をReduxに切り出し、connect経由で子コンポーネントに伝える。条件ハンドリングしたい場合はthisメンバ変数を使うことも視野に入れる
  • 親コンポーネントではRedux(connectのmapStateToProps)を参照しない
  • propsにインライン関数は使わない、render内での処理を極力避ける(constructorで前処理する、メンバ関数を指定する)
  • stateの変更の影響を受けないpropsは渡しても可(初回のレンダリングのみ渡す系のprops)
  • レンダリングの負荷: Component > PureComponent > FC(wrapped React.memo) > FC
  • レンダリングするDOMの粒度が小さいコンポーネントはFCにする
  • FC群をまとめたReact.memoを作る

結局はどの粒度でコンポーネントをまとめるかという問題とどのデータをconnectで参照するかに尽きる気がしました。

ローディング

  • そもそも使っていないライブラリは削除してbundle.jsを軽くする
  • WebpackのCodeSplitting機能を使ってjsファイルをbundle.jsから分離する
  • dynamic importを活用することでjsリソース読み込み時のブロッキングを避ける
  • prefetch付きdynamic importをすることで次表示されるページのjsリソースを先読みできる

このライブラリは本当に必要なのかも導入する前に考慮しましょう。