React製のSPAのパフォーマンスチューニング実例 | リクルートテクノロジーズ メンバーズブログ

React製のSPAのパフォーマンスチューニング実例 | <a class="keyword" href="http://d.hatena.ne.jp/keyword/%A5%EA%A5%AF%A5%EB%A1%BC%A5%C8">リクルート</a>テク<a class="keyword" href="http://d.hatena.ne.jp/keyword/%A5%CE%A5%ED">ノロ</a><a class="keyword" href="http://d.hatena.ne.jp/keyword/%A5%B8%A1%BC">ジー</a>ズ メンバーズブログ


React製のSPAのパフォーマンスチューニング実例





こんにちは.エンジニアリングマネージャーの五味です.

今回から 11 月末まで,18入社新人のうち9名によるブログリレーを開催します.

配属前研修 ) を終えた彼らは、それぞれのスペシャリティが最も活かせるであろうグループに配属されました.

当社の若手エンジニアがどのような仕事に取り組み,何を感じ,何をしているのか.本連載では,その一端を紹介していきたいと思います.

SPA 開発,セキュリティ診断,プロダクト開発におけるプロセス運用・機能改善事例などを予定していますので,どうぞ楽しみにしていてください.

初回はフロントエンドエンジニア 辻 健人 からのエントリーです.

はじめに

はじめまして!リクルートテクノロジーズに4月に新卒入社した 辻 健人です!GitHubではmaxmellonで活動しています.

今回は,私が担当しているAirシフトというシフト管理サービスで実施した内容を紹介します.

本記事では,Reactにおける再レンダリングのメカニズムを基本的な部分から扱い, SPAにおける再レンダリングの最適化での着眼点や改善方法を紹介します.

Airシフトとは

Airシフトは,シフト表の作成はもちろん,スタッフとのやりとりや細かな調整業務もラクになるシフト管理サービスです.

直感的に操作できるシンプルな画面で,簡単にシフト作成が行えます.シフト表と一体となったチャットを使ってスタッフとやりとりができるので,シフトの作成はもちろん,急な調整や連絡ができます.

技術スタックとしては,React/Redux,チャット機能にWebSocket, SSRやユーザ認証,ファイルダウンロードにBFFアーキテクチャを採用しています.

背景

Airシフトのユーザを訪問する機会があり,ヒアリングを行った結果,Airシフトの動作が重いという声があがりました. シフト表の表示期間を切り替えると,次の画面が表示されるまでに数秒かかっているようでした. ユーザの利用環境は必ずしもハイスペックなPCというわけではないため,より多くの環境で快適に利用してもらえるよう,パフォーマンス改善を実施することにしました.

それにあたって調査した技術や,実際にプロダクトに導入した技術を紹介します.

課題

今回,Reactのパフォーマンスについて触れるのは,Airシフトにおいてパフォーマンスの課題があったからです. どのような課題かというと,たくさん使ってくれているユーザーほど重くなり,結果として遷移に数十秒かかっているという課題がありました. 特に,今回ヒアリングに行った店舗では,マシンリソースが潤沢ではない環境で操作後に待つ時間非常にが長くなってしまっていました.

実際にどれぐらい遅いのかを,低スペックなPCをエミュレートして計測してみました.

  • 計測環境
MacBook Pro (13-inch, 2017)
項目 詳細
OS macOS High Sierra
CPU 3.1GHz Intel Core i5
メモリ 16GB 2133 MHz LPDDR3
グラフィクス Intel Iris Plus Graphics 650 1536MB
ブラウザ Google Chrome 68.0.3440 (64bit)
追加条件 CPU x4 Slow Down (スペックの低いPCで遅い問題を再現するため)
データ数 15人 2グループ 150シフト/月 (現実的なデータ数)

今回,ヒアリングでスペックの低いPCで著しくこの問題が顕著に現れたので,その環境を再現するために, Google Developer Tools の機能で,CPUの性能を4倍低速にします.

厳密な再現にはなりませんが,低スペック時にどう動いているかを気軽に再現できるので,今回はこの機能を使いました.

計測結果
  • 計測対象の操作

週から月へ変更するという操作


APIも900msecとそれなりに時間がかかっていますが,それ以上にScripting (JavaScript を実行している時間) に時間がかかってしまっていることがわかります.その時間合計するとなんと 15secにもなります.

これを解決するにあたって使った手法や解決策を紹介します.

仕組みの理解

マウントと再レンダリング

まず,調査や改善を行う前に,React とはどういう仕組みで動いているかを理解することが重要です. なので,Reactのライフサイクルやレンダリング周辺の処理について着目します.

ReactにおけるComponent が レンダリング する場面は,2つあります. それぞれ,マウントと再レンダリングです.

違いとしては,マウントでは,フルにDOMを生成し,親要素にマウントするのに対して, 再レンダリングでは差分を計測し,再レンダリングの必要があるものに対して,最小限の更新を行います.

Tips:

マウントは,公式ドキュメントでは Mounting や Mount と表記されています

レンダリングのことは Updating や Update と表記されています

マウント時のライフサイクル

マウント時には,Component に実装された次の関数が順に実行されます.

レンダリング時のライフサイクル

レンダリング時には,Component に実装された次の関数が順に実行されます.

ここで,重要になるのが, shouldComponentUpdate です.

shouldComponentUpdate 関数は,prevProps, prevState を受け取り,現在の props, state を比較して更新する必要があるかどうかを判定し,その結果をbooleanで返す関数です. 更新の必要がありとした場合は, true を返します. デフォルトでは,常に true  です. つまり,propsのインスタンス,stateのインスタンスが変化したとき,その中身の値が全く同じでも再レンダリングされてしまいます.

レンダリングをチューニングする際には,shouldComponentUpdate に着目する必要があります.

React における List と key

リストにおいては,気をつけておくことがライフサイクルに加えてもう一つあります. それはkey です. keyは,繰り返しの要素において同じ要素かどうかを判定し,要素を増減させるための識別子です.

適切なkeyとそうでないkeyで何が変わるかを具体的に見ていきましょう.

次のようなアイテムコンポーネントをリストで表示するとします








1

2

3

4

5

6

7

8

9

10

type ItemType = {

  id: string,

  content: string,

}

function Item({ item }: { item: ItemType }) {

  return (

    <li>{item.content}</li>

  )

}


次のように2つのkeyの付け方で実装したとします

  • 悪いkeyの付け方








1

2

3

4

5

6

7

8

9

10

function List({ items }: { items: Array<ItemType> }) {

  return (

    <div>

      <h1>不適切なkey</h1>

      <ul>

        {items.map((item) => <Item key={Math.random() + ''} item={item} />)}

      </ul>

    </div>

  )

}


  • 良いkeyの付け方








1

2

3

4

5

6

7

8

9

10

function List({ items }: { items: Array<ItemType> }) {

  return (

    <div style={{ display: 'inline-block', width: '25vw' }}>

      <h1>適切なkey</h1>

      <ul>

        {items.map((item) => <Item key={item.id} item={item} />)}

      </ul>

    </div>

  )

}


これらをレンダリングすると次のようになります.(propsの変化がわかりやすいように1秒ごとにitemsを追加します)

緑色に光っている箇所が新しいDOMが生成されている箇所になります.データとkeyが対応していない場合,常にマウントが発生しています. 加えて,再レンダリングではなく,マウントになってしまうので,shouldComponentUpdateによる制御もできません. 非常にコストが高くなってしまいます.

一般的に,APIがDBの内容をjsonで返したものを描画するとき,そのデータの主キーや代用キーをReact の key にすると良いでしょう.

keyを指定しなかったときどうなるか

keyが存在するかどうか,あるいはuniqueであるかどうかのvalidationは開発環境でのみReact側がしてくれます.

そして,keyが存在しなかったときは,配列のindexがkeyとして自動的に使われます. しかし,indexをkeyとする場合でも,map((item, key) => <Component key={key} />) のように,きちんと明記しましょう.

Listにおける再レンダリング

データによって一意に定まるkeyを設定することで,マウントを制御できることをここまでで見てきました. ただ,Listでは更に罠があります.特にデータ数が多いものや更新の頻度が高いものは注意が必要です.

少し前に触れましたが,shouldComponentUpdateは常にtrueを返す ということを意識しなければなりません. 特にshouldComponentUpdateを気をつけずにリストを描画すると次のようになります.

keyによって,マウント/再レンダリングを制御できることがわかりました.不要なマウントを再レンダリングにし,ある程度改善ができたと思います. ただ,ここで気をつけなければいけないのが,shouldComponentUpdateを実装していない場合,defaultで常にtrueを返す という点です. デモでみていきましょう.

水色の四角は,再レンダリングが発生している箇所です. shouldComponentUpdateがtrueを返しているので,無駄に再レンダリングしてしまっていることがわかると思います.

実際のコードを見てどう対策するのかを見てみましょう. リストの実装は先程と基本的に同じです.








1

2

3

4

5

6

7

function List({ items }: { items: Array<ItemType> }) {

  return (

    <ul>

      {items.map((item) => <Item key={Math.random() + ''} item={item} />)}

    </ul>

  )

}









1

2

3

4

5

6

7

8

9

10

11

import { Component } from 'react'

class Item extends Component<ItemType> {

  render() {

    const { item } = this.props

    return (

      <li>{item.content}</li>

    )

  }

}


このような,ListとItemの関係において,差分レンダリングを制御するにあたり,Itemに着目します. 汎用的な,shouldComponentUpdate の実装として,PureComponent というものがあります.

PureComponentが具体的にしていることは,propsに対してshallowCompareして差分を見ています.このとき注意なのがshallow なので, 任意の props[key] が object だった場合そのObjectの中身の値が同じかどうかではなく, 同じインスタンスであるかどうか (通常のstrict equalと同じ挙動)を見ます.

なので,たとえObjectが全て同じ値でも,Rest parameters や Object.assign などで コピーしていると異なるインスタンス扱いになってしまうということに気をつける必要があります.

ちなみに,shallowCompare の具体的な実装は,fbjs/shallowEqual です.








1

2

3

4

5

6

7

8

9

10

11

import { PureComponent } from 'react'

class PureItem extends PureComponent<ItemType> {

  render() {

    const { item } = this.props

    return (

      <li>{item.content}</li>

    )

  }

}


これを実際にレンダリングするとどうなるか見てみましょう.

実際に差分レンダリングがされている箇所を見てみると,stateが書き換わっているListのみ再レンダリングされ, 各Itemは再レンダリングされていないことがわかります.

ここでは PureComponent を用いた例を紹介しましたが, Stateles Functional Component を用いている場合はrecompose/pure というものがあります. もし,Functional Component を軸に開発を進めるのであれば,PureComponent を継承する代わりに recompose/pure を利用することで同じ効果が得られます.








1

2

3

4

5

6

7

8

9

import { pure } from 'recompose'

function Item({ item }: { item: ItemType }) {

  return (

    <li>{item}</li>

  )

}

const PureItem = pure(Item)


ボトルネックを調査・計測

chrome developer tools

Reactのversion 16から,react-addons-perf のサポートがなくなりました. その代わりに,Chrome の devtools を使って計測する方法を紹介していきます.

パフォーマンストレースを閲覧する

Chrome の機能を用いて React のアプリケーションのパフォーマンストレースを見られます. React内部でUserTiming API を利用して,それぞれの処理時間を見ることができます. (Reactの内部処理時間は開発環境のみ閲覧可能)

赤枠のボタン押下することで,任意の操作のパフォーマンスツリーを見ることができます. 青枠のボタンは,フルリロードからDOMContentLoaded をハンドルして実行されたscriptingが終わるまでのパフォーマンスツリーを見ることができます.