Edited at

お前らのReactは遅い

煽りタイトルですみません。

最近、Reactのプロジェクトのページを動かしていて、

もっさりしてる(レンダリングの負荷が高いな)と思ったので

どうやったら無駄なレンダリングを減らせるか思考錯誤したことをまとめました。

preactとか別ライブラリの話はしません。

よかったらこちらもどうぞ

ReactJSで作る今時のSPA入門(基本編)

2019年07月06日追記:

ブラウザのレンダリングの仕組みに関して良記事があったので先に一読しておくことをおすすめします。

良記事1:実際のところ「ブラウザを立ち上げてページが表示されるまで」には何が起きるのか

良記事2:ブラウザレンダリング入門〜知ることで見える世界〜

1ピクセルがブラウザに表示されるまで:Life of a Pixel 2018

この記事に関してはReactのDOMツリー(レイアウト)レンダリングに関する最適化戦略です。


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に渡すものは再生成しない、いいね?

極力renderで変数の定義や即時実行を避ける

参考:【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.memoに関してはprops内容が変更しない場合(レンダリング内容が初期化時から変更しない場合)に限り使うのが良さそうです。

また、React 16.8で出たReact Hooksを使うとuseStateでSFCにstateを持つことも可能になりました。(以下FC)

React 16.8: 正式版となったReact Hooksを今さら総ざらいする

さらにuseMemoやuseCallbackを使うことでFunctional Component内の変数や関数を再生成せずに再利用することが可能のため、そちらのほうがパフォーマンスが出そうです

雰囲気で使わない React hooks の useCallback/useMemo

より、可読性が下がりバグの原因になる確率があがるため適材適所で使ったほうが良さそうです。

そもそもページ単位でstateを持たせるような設計がアンチパターンのため、stateを持つのはできるだけ末端のコンポーネントに限定します。

データの参照が必要なコンポーネントに関してはconnectで直接参照するようにします。(ページ内で更新されうるデータを持つ)

個人的にはFC群をまとめた、React.memoの親コンポーネントを用意するとレンダリングの影響範囲を限定できて良い気がします。これはAtomic DesignのOrganisms(有機体)くらいの粒度のイメージだと思います。

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f35353037372f36613935613461372d336537332d336165302d633866342d3261356261376639653964362e706e67.png

原子: デザインの最小要素、カラー、フォント、見出し、ボタン、入力欄等

分子: 原子を組み合わせてグルーピングしたパターン(見出しと本文のセットなど)

有機体: 分子を組み合わせて作り出されたインタフェース(見出し+メニュー欄→ナビゲーションバー)

テンプレート: 有機体を組み合わせてできた、ページのワイヤーフレーム

ページ: ワイヤーフレーム外のページ独自デザイン要素も入ったページそのもの


結局全部FCにすればいいの?

React Hooks — Slower than HOC?

useEffectつきのFCとHOCをベンチマークした結果HOCのほうが早いという結果がでてしまったようです。

これに関してDan先生が反論してます。

This benchmark is indeed flawed.

componentDidMountはuseEffectより早くコールバックされるらしいのですが、コール時にはユーザーには実際には何も表示されません。

useLayoutEffectで比較した結果はHooksが優勢のようです。

ただ、一概にHooksが早いとは言えないようです。

(この辺は現状はコンポーネントの粒度でケースバイケースで使い分けるのが個人的には良い気がしてます。)


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

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

最近はwebpack-bundle-analyzerプラグインのほうが内訳が細かく出るのでもっぱらこっちを使ってます。

設定はプラグインに追加するだけです。


webpack.build.js

  plugins: [

new BundleAnalyzerPlugin()
]

93f72404-b338-11e6-92d4-9a365550a701.gif

また、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ページの場合はサーバ側で全部レンダリングさせるしかない)

またloadable-componentsに関してはリンクホバー時にprefetchする機能があるのでこちらも活用すると不要なJSファイルのローディングをさらに減らすことが出来ます。(直前までロードしない)

Reactのcode splitting用のライブラリとしてloadable-componentsが良かったので推していきたい


SSRとSSG(サーバサイドジェネレーション)について

これまで自分はSSR(サーバサイドレンダリング)のほうがCSR(クライアントサイドレンダリング)より早いと思っていましたが

サーバのスペックやブラウザのスペック、回線状況にも依存しますが

興味深いのはSSRしたほうがCSRよりトータルの読み込みの時間が遅くなる場合があるということです。

(もちろんファーストビューのインタラクションはCSRが真っ白に対して部分表示できてるSSRのほうがよいですが)

The Benefits of Server Side Rendering Over Client Side Rendering

これはDOMの量が多いページだとバックエンドでレンダリングに時間がかかり、TTFB(Time to First Byte)が遅くなることに起因するそうです。(RenderDOMServer.renderToStringの負荷が高い)

解決策としては

・CDNなどでページをキャッシュさせる

・SSG(サーバサイドジェネレーション)でプリレンダリングしてhtmlを吐き出しておく

などが考えられます。

SSGに関しては静的なページをクローリングして予め生成するため、

更新があまり少ないけど速度が重視されるLPやブログなどに有効です。

React公式でも紹介されていたreact-snapを使うと良さそうです。

上記のloadable-componentsとも併用できるとのことなので

CodeSplittingと組み合わせることで初回ランディング時の速度を向上させることができそうです。

Pre-render routes with react-snap


まとめ

レンダリング


  • そもそも親コンポーネントでの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リソースを先読みできる

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