Reactのこともっとよくしろう! ~パフォーマンス編~

公開:2021/02/04
更新:2021/02/05
19 min読了の目安(約17200字TECH技術記事

前回の勉強会(https://zenn.dev/articles/a9a4732ace9614/edit) では、
・FLUXとは Reduxのデータフローとは(redux toolkitは何をやっているのか
・なぜイミュータブルである必要があるのか
について扱いました。

今回は
・パフォーマンス改善について
を扱っていこうと思います。

公式のドキュメントにも専用ページが用意されています。
React パフォーマンス最適化
こちらをベースに、補足やコード上での実践などを加えて説明していきます。

今記事では、Reactのverが16.8以上であること(hooks/functionコンポーネントを扱うこと)を前提に進めています。

前回の記事で触れた 「イミュータブルについて」の復習をしておくと良いかもしれません。

今回も、リポジトリを用意しました。

https://github.com/takanokana/react-tag
branchをlesson/3に切り替えると今期時の作業が可能です。

差分検出方法

パフォーマンスを向上する為にはまず、Reactがどのようにして表示を切り替えているかの仕組みをおさえておく必要があります。
Reactはいわゆる仮想DOMの仕組みで動いているので、Reactで構築している仮想DOMの変更を検出し、HTMLに反映するのが主な流れです。

仮想DOMについてより詳しく知りたい方は、下記の記事がおすすめです。
仮想DOMは本当に“速い”のか? DOM操作の新しい考え方を、フレームワークを実装して理解しよう
実際に仮想DOMの小さなフレームワークを実装しながら、仮想DOMの仕組みについて学ぶことができます。

つまりはReact仮想DOMと実DOMの差分を検出する方法をおさえておくことで、パフォーマンス向上のための取り組みが理解できます。

差分検出のパターンはいくつかありますが、今回は同じ型のコンポーネント要素の差分検出に焦点を絞ります。
参照: React 差分検出方法

- <MyComponent props="bow" />
+ <MyComponent props="meow" />

といった場合の変化です。

コンポーネントが更新される場合、インスタンスは同じままとなり、レンダー間で state は保持されます。React は対応するコンポーネントのインスタンスの props を新しい要素に合うように更新し、UNSAFE_componentWillReceiveProps()、UNSAFE_componentWillUpdate() および componentDidUpdate() を対応するインスタンスに対して呼び出します。

次に、render() メソッドが呼ばれ、差分アルゴリズムが再帰的に前の結果と新しい結果を処理します。

ここでキーとなるのは、変更があった場合

  • インスタンスは同じまま
  • propsを更新
  • 色々なライフサイクルメソッドが呼ばれた後、render()が走る
    ということです。

また、差分のあったコンポーネントの子コンポーネントは、上記の処理を再帰的に行なっていきます。

公式ドキュメントではclassコンポーネント中心で話がされていますが、関数コンポーネントであれば再レンダリングは関数全体が走ります。

実際にコードで確認してみます。
lesson/3ブランチでは、src/tsx/views/pages/top/DescBox.tsx内で
説明文を表示するコンポーネントを準備しています。

src/tsx/views/pages/top/DescBox.tsx
// 説明分を表示するblock
import React, { useState, FC } from 'react'
import { css } from '@emotion/core'

export const DescBox: FC = () => {
  const [isVisible, setIsVisible] = useState(true)
  return (
    <div css={DescBoxCss}>
      <DescHead />
      <DescBoxTxt isVisible={isVisible} />
      {isVisible && (
      <HideBtn setIsVisible={setIsVisible} />
      )}
    </div>
  )
}

const DescHead = () => (
  <h2 css={DescHeadCss}>このサイトについて</h2>
)

const DescHeadCss = css`
  font-weight: bold;
`
interface DescBoxTxtProps {
  isVisible: boolean
}

const DescBoxTxt: FC<DescBoxTxtProps> = ({ isVisible }) => (
  <>
    {isVisible
      ? (
        <p>
          このページは付箋をpostするページです。
          <br />
          各色のボタンを押すことで付箋の色を変更することができます。
          <br />
          それぞれの一覧から各ページの付箋リストを見ることができます。
        </p>
      )
      : <p>説明文は非表示になっています。</p>}
  </>
)

interface DescBoxBtnProps {
  setIsVisible: React.Dispatch<React.SetStateAction<boolean>>
}

const HideBtn:FC<DescBoxBtnProps> = ({ setIsVisible }) => (
  <button
    onClick={() => setIsVisible(false)}
    type="button"
  >
    説明文を非表示にする
  </button>
)

const DescBoxCss = css`
  padding: 30px 20px;
  line-height:1.5;
`

実装内容は、説明文を表示しており、ボタンを押すと説明文が非表示になるというとてもシンプルなものです。
先ほどの説明の通りであれば、親コンポーネントDescBoxでpropsが切り替わると、子コンポーネントも再renderingされるということでした。それぞれconsole.countを仕込んで試してみます。

DescBox
export const DescBox: FC = () => {
  const [isVisible, setIsVisible] = useState(true)
+ console.count('DescBox')
  return (
    <div css={DescBoxCss}>
      <DescBoxTxt isVisible={isVisible} />
      {isVisible && (
      <HideBtn setIsVisible={setIsVisible} />
      )}
    </div>
  )
}

子コンポーネントにconsole.logを仕込みます。

DescHead
- const DescHead = () => (
+ const DescHead = () => {
+ console.count('DescHead')
+  return(
    <h2 css={DescHeadCss}>このサイトについて</h2>
   )
+ }

DescBoxTxt,HideBtnも同様の手順で仕込んでください。

初回レンダリングが走るので、当然全て1度は走ります。
また、HideBtnに関しては、stateの更新によってreturnされなくなっているので仮想DOMから外れています。

ここで注目していただきたいのは、DescHeadです。
こちらは親のpropsを流し込んでいないので再レンダリングの必要はありませんが、
再帰的に自動で再レンダリングされてしまっています。

現在は一つのコンポーネントですが、もしDescHeadのようなコンポーネンの下のツリーに大量のDOMがあったらどうでしょうか?コンポーネント内で時間のかかる処理があったら...?
当然、処理は重くなり、ユーザビリティを損なう恐れがあります。

パフォーマンス向上の心得その1: 再レンダリングをコントロールする

Reactには、この再帰的な再レンダリングをコントロールする方法が提供されています。

どうするかというと、React.memoを使います。

memoは渡されたpropsの浅い比較を行って,trueであれば再レンダリングをスキップします。
なので、渡すpropsをimmutableにしておかないと、挙動がうまくいかない恐れがあります。

また、memoは第二引数を渡すことができ、第二引数の処理がtrueであれば再レンダリングをスキップする、といったことが可能です。
ただし、第二引数で複雑な処理をしてしまって memoの処理 > 再レンダリングのコスト になってしまっては本末転倒なので、ここは実際の挙動をみつつ調整する必要があります。

DescBox
const DescHead = memo(() => {
  console.count('DescHead')
  return (
    <h2 css={DescHeadCss}>このサイトについて</h2>
  )
})

これでDescBoxの再レンダリングを抑止することができました。

パフォーマンス向上の心得その2: devtoolを使いこなす

前節でmemoの第二引数の話をしましたが、それでは実際に実装すべきかどうかをどのように測ったら良いのでしょうか?

Chromeのdevtoolを使いましょう。
以前の記事で、拡張を使って再レンダリング箇所を目視する方法はすでに紹介しました。

https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=ja

今回は、Profilerという別の機能を紹介します。

この機能はreact-dom v16.5以上でのみサポートされています。

参照:https://ja.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html

手順は下記の通りです。
1.devtoolのタブをProfilerに切り替えます。
2. 左上の丸ボタンをクリックします。
3. 再レンダリングの起こる動作(説明文の非表示、色の着替えなど)をします。
4.タブをProfilerに切り替え、 左上の丸ボタンを押し、再レンダリングの起こりそうな動作(説明文の非表示、色の着替えなど)を行います。
5. devツールの左上の丸ボタンを再度クリックします。

このように、レンダリングにかかった時間がコンポーネントごとにチェックできます。
Perfomanceタブと違い、Reactのコンポーネントのみに集中し、細かく計測できるのでReactパフォーマンス改善にはうってつけです。

それぞれのバーは黄色が強くなるほど再レンダリングに時間を要しており、青色が強くなるほど時間がかかっていないという表示になっています。灰色はレンダリングが発生しなかったということです。

ReactのDOMへの変更が確定した時(以降これをコミットと言います)毎にデータを取得できます。
右上の縦に並んでいるバーがそうです。

それぞれのDOMのバーをクリックすると、再レンダリングにかかった時間を確認できます。
これでどこがボトルネックになっているかの調査が可能です。
ただしコミットの時間は、DOMに対する処理の計測時間のみだということに注意してください。
つまり、memoの実行時間やrender内の時間は計測できません。
私はボトルネックになっているDOMを発見する、という目的で使います。詳しい処理の時間を見るにはPerfomanceタブを開きます。

こちらも同様、左上の●を押すことで記録を開始し、再度押すことでデータが出力されます。

黄色い部分がスクリプトによる実行時間です。

Timingのタブを開くと、どのコンポーネントが時間を多く使っているかがわかります。

また、Mainのタブを開き、下部分のBottom-Upを参照すると、どの関数が時間を食っているかがわかります。

試しにコードのmemo内にあえて重い処理を起き、その前後で数値がどう変わるかを計測してみてください。

DescBox
const test = (prev, next) => {
  for (let i = 0; i < 1000; i++) {
    console.log(i)
  }
  return true
}
const DescHead = memo(() => {
  console.count('DescHead')
  return (
    <h2 css={DescHeadCss}>このサイトについて</h2>
  )
}, test)

パフォーマンス向上の心得その3: 必要のないpropsを渡さない

そもそも論ですが、親コンポーネントで再レンダリングを起こさなければ子コンポーネントでレンダリングが起きることもありません。そのためには、必要最低限のpropsを渡すようにすることが重要です。

ブランチを lesson/3-2に切り替えてください。

src/tsx/views/components/block/Header.tsx
export default function Header() {
  const [userData, setUserData] = useState({
    name: 'irico',
    message: '魚大好き',
  })

  return (
    <div css={HeaderContainer}>
      <h1>
        <Link to="/" css={TitleCss}>Tag React</Link>
      </h1>
      <p css={UserTxtCss}>
        ようこそ
        {userData.name}
        さん
      </p>
      <div css={BtnsCss}>
        <HeaderColorBtn clrType="white" />
        <HeaderColorBtn clrType="blue" />
        <HeaderColorBtn clrType="green" />
        <HeaderColorBtn clrType="red" />
        <HeaderColorBtn clrType="yellow" />
      </div>
      <p>{userData.message}</p>
      <button
        onClick={() => setUserData({
          ...userData,
          message: 'react大好き',
        })}
        type="button"
      >
        メッセージを変える
      </button>
    </div>
  )
}

ヘッダーが上記のようにuserDataを持つように変更しました。
このような情報は本来はReduxなどで管理することが多いかと思いますが、あくまでサンプルですのでご了承ください💦


「メッセージを変える」ボタンを押すと、messageの情報が必要ない部分でも再レンダリングが起きてしまいます。
これをリファクタしてみましょう。
コツは、
・必要な情報は必要なところにだけ
・コンポーネントを細かく切り出す
です。

新たに

$ touch src/tsx/views/components/atoms/HeaderMsg.tsx \
src/tsx/views/components/atoms/HeaderUser.tsx

とコマンドを打ち二つのファイルを作成します。

src/tsx/views/components/atoms/HeaderUser.tsx
import React, { useState, FC } from 'react'
import { css } from '@emotion/core'

export const HeaderUser = () => {
  const [username, setUsername] = useState('irico')

  return (
    <p css={UserTxtCss}>
      ようこそ
      {username}
      さん
    </p>
  )
}

const UserTxtCss = css`
  margin-left: 20px;
`
src/tsx/views/components/atoms/HeaderMsg.tsx
import React, { useState, FC } from 'react'

export const HeaderMsg: FC = () => {
  const [message, setMessage] = useState('魚大好き')
  return (
    <>
      <p>{message}</p>
      <button
        onClick={() => setMessage('react大好き')}
        type="button"
      >
        メッセージを変える
      </button>
    </>
  )
}

それぞれのコンポーネントで必要なstateだけをもたせました。
親コンポーネントからはuseStateを削除します。

src/tsx/views/components/block/Header.tsx
+ import { HeaderUser } from '../atoms/HeaderUser'
+ import { HeaderMsg } from '../atoms/HeaderMsg'

export default function Header() {
-  const [userData, setUserData] = useState({
-    name: 'irico',
-    message: '魚大好き',
-  })

  return (
    <div css={HeaderContainer}>
      <h1>
        <Link to="/" css={TitleCss}>Tag React</Link>
      </h1>
-      <p css={UserTxtCss}>
-        ようこそ
-        {userData.name}
-        さん
-      </p>
+     <HeaderUser />
      <div css={BtnsCss}>
        <HeaderColorBtn clrType="white" />
        <HeaderColorBtn clrType="blue" />
        <HeaderColorBtn clrType="green" />
        <HeaderColorBtn clrType="red" />
        <HeaderColorBtn clrType="yellow" />
      </div>
-     <p>{userData.message}</p>
-     <button
-       onClick={() => setUserData({
-         ...userData,
-         message: 'react大好き',
-       })}
-       type="button"
-     >
-       メッセージを変える
-     </button>
+     <HeaderMsg />
    </div>
  )
}

これで「メッセージを変える」ボタンを押しても、無駄なレンダリングが走らなくなります。

パフォーマンス向上の心得その4: 再レンダリング時のコストを下げる

再レンダリングが必要な部分においても、その処理を軽くすることが大切です。
再レンダリングが頻繁に起きる部分においては特に重要です。

functionコンポーネントにおいては、
関数内処理を重くしないこと
が重要になります。

そのためには、
useMemo, useCallbackなどでmemo化する
useRefを活用する
という方法があります。

useMemo, useCallbackは何をする?

どちらも一度処理した内容をキャッシュしてくれる機能を持ちます。
例えば、 (2 * 12345) / 2 * 5 + 300 - 150 + 1500 ...といった長い計算処理を、 再レンダリングのたびに走らせる必要がないということです。

useMemoは値をメモ化し、usecallbackはコールバックをメモ化します。

src/tsx/views/pages/TagList.tsxをリファクタリングしてみましょう。
現時点では問題になっていませんが、このコンポーネントに新たなpropsがいくつか追加されたと仮定します。
その場合、その新しいpropsが変更されて再レンダリングが起きるたびに、tagの一覧をループで生成しなくてはなりません。
タグのdataが更新される時だけ計算されるようにしたいですね。

useMemoは第一引数にメモ化する処理を、第二引数に依存するものを配列で渡します。
この場合は、dataが変更される時に再計算して欲しいので、dataを配列で渡します。

src/tsx/views/pages/TagList.tsx
import React, { useMemo, FC } from 'react'

export const TagList: FC<ItagList> = ({ clrType }) => {
  const data = useSelector((state) => colorSelect(clrType, state))

  const tagListDom = useMemo(
    () => data.map((tagData) => (
      <div
        css={TagCss(clrType)}
        key={tagData.id}
      >
        {tagData.text}

      </div>
    )), [data],
  )

  return (
    <main>
      {tagListDom}
    </main>
  )
}

上記のようにuseMemoで、DOMの生成部分をMemo化しました。
これで、レンダリング時の処理負担を軽減できますね。

useCallbackについては以前の記事のlesson2での実装を参照していだけると良いかと思います。

もちろんuseMemo, useCallback自体にもコストが発生するので、
全部につける!などはもちろんNGです。処理に時間のかかるものを選択して使用するべきです。

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

useRefとは?

参照:React公式 useRef
useRefの最大の特徴は、値が更新されても差分検知がなされないことです!

早速useRefを使ってsrc/tsx/views/pages/top/PostBox.tsxをリファクタリングしてみましょう。
このコンポーネントの最大の問題は、inputが走るたびに再レンダリングされてしまうという点です。
postTxtのstateを持っているので、仕方がない...そう思われていますか?
では、postTxtを無くします。

src/tsx/views/pages/top/PostBox.tsx
export const PostBox: FC = () => {
  const [color, setColor] = useState('white')
-  const [postTxt, setPostTxt] = useState('')
  const dispatch = useDispatch()

+  const ref = useRef<HTMLTextAreaElement>(null)

  /// //////////////////
  // state変更用メソッド
  const setClrHandler = (thisColor: string) => {
    setColor(thisColor)
  }
-  const setTxtHandler = (
-     e: React.ChangeEvent<HTMLTextAreaElement>,
-   ) => {
-    setPostTxt(e.target.value)
-   }
  /// //////////////////
  // post用メソッド
  const postHandler = () => {
+    if (ref.current === null) return
    dispatch(addTag({
      color,
+      text: ref.current.value,
-      text: postTxt,
+    }))
    // データをクリア
-    setPostTxt('')
+    ref.current.value = ''

  }

  return (
    <section css={PostBoxCss}>
      <textarea
+        ref={ref}
        css={TextBoxMain(color)}
-        onChange={setTxtHandler}
-        value={postTxt}
      />

このように、post時にDOMの値を参照することで、input入力時のレンダリングを防ぐことができます。
ちなみにこれを利用して、レンダリングを抑えたフォームライブラリがredux-hook-formです。
別の記事にて検証しましたので、興味のある方はご覧ください🙇‍♂️

また、useRefと言われると、上記のように
💂‍♀️「DOMの参照を持たせるやつですよね」
といった印象が強いかもしれませんが、DOMに限らずなんでも好きなものを入れることができます。
つまり、再レンダリングを起こしたくないけれど値は変更したい、そういった場合のパフォーマンスチューニングに役立ちます。
例えばtimerであったり、API送信するための値であったりです。
ここで全てのユースケースを紹介すると、記述量がモリモリになってしまいますので割愛しますが、
useRefは表示に関係のない値を保持するのに便利! と覚えておくと良いかもしれません。

パフォーマンス向上の心得その5: マウントするDOM数を減らす

マウントするDOMの数を減らしてしまえば、当然パフォーマンスは向上します。

非常に長いリスト(数百〜数千行)などの表示を 見かけ上は全て表示しているように見せて、DOMは一部のみを表示するというウィンドウィングというテクニックがあります。
(Twitterの呟きの表示方法もその一つですね。)

Reactでウィンドウィングを表現するためには、react-windowreact-virtualizedとったライブラリがあります。
react-windowreact-virtualizedの簡略化したものです。
詳しい違いについて知りたい場合は下記の記事がおすすめです。
Windowing wars: React-virtualized vs. react-window

今回はreact-windowを使ってリストをリファクタしてみます。
参照: react-window - npm

まずはパッケージをインストールします。

$ npm i react-window -S

そして、storeの初期値を大量に増やしましょう。

src/tsx/stores/slices/tagListSlice.ts
+ let data: tag[] = [];
+ for(let i = 0; i < 100; i++){
+    data.push({
+        color: 'white',
+        text: '夜ネギを買う',
+        id: i
+    })
+}

const tagListSlice = createSlice({
  //   slice名
  name: 'tagList',
  //   初期値
  initialState: {
    nextId: 1,
-    data: [{
-      color: 'white',
-      text: '夜ネギを買う',
-      id: 0,
-    }],
+    data,
  },

これで大量のリストを生成できました。
では早速react-windowを実装します。

また、本来なら字数制限はないのでリストの高さは可変ですが、
高さ可変の場合実装が複雑になるので今回は固定幅で実装します。

src/tsx/views/pages/TagList.tsx
import React, { FC } from 'react'
import { css } from '@emotion/core'
import { FixedSizeList as List } from 'react-window';

// store
import { useSelector } from '../../stores/index'
import { colorSelect } from '../../stores/slices/tagListSlice'
// style
import { colors } from '../../style/components/atoms/Button'

interface ItagList {
  clrType: string
}
/**
 * 投稿された付箋をカラー毎に表示するページ
 */
export const TagList: FC<ItagList> = ({ clrType }) => {
  const data = useSelector((state) => colorSelect(clrType, state))
  const tagListItem = ({ index, style }) => (
    <div
      style={style}
      css={TagCss(clrType)}
      key={data[index].id}
    >
      {data[index].text}
    </div>
  )

  return (
    <main>
      <List
        height={500}
        itemCount={data.length - 1}
        itemSize={56}
      >
        {tagListItem}
      </List>
    </main>
  )
}

const TagCss = (clrType: string) => css`
  border: 1px solid #ccc;
  display: flex;
  align-items: center;
  padding: 0 20px;
  background: ${colors[clrType].background};
  white-space: pre-wrap;
`

見た目上は沢山のDOMが並んでいますが、実際には14個ほどのDOMしか並んでいないことがわかります。

ウィンドウィングは初期読み込みを早くする代わりにスクロール時のリスト表示を遅くするので、適材適所で使う必要があります。ウィンドウィングではなくページネーションを実装する場合がいい場合もあるでしょう。

まとめ

ずいぶん長くなりましたが、Reactパフォーマンス向上のためのおすすめ施策は以下になります。
devtoolを駆使しつつ、
・再レンダリングをコントロールする - React.memoを使う -
・必要のないpropsを渡さない - コンポーネントを小分けに/処理を下に落とす -
・再レンダリング時のコストを下げる - 関数内処理を重くしないこと-
・マウントするDOM数を減らす - ウィンドウィングやページネーションを駆使する-

パフォーマンスチューニングが上手く行えれば、サイトがサクサクに生まれ変わるかも...!?
また、繰り返しになりますが、パフォーマンスチューニングはそれ自体がパフォーマンスを損なうこともあります。
キャッシュする処理などはバグも発生させやすく、実装も複雑になりやすいです。
見極めつつ実装していきたいですね😎

reduxについても取り上げたかったけど長くなりすぎて断念しました...

次回予定
・reduxのパフォーマンスチューニングについて(正規化/useSelector/reselectなど)
・reduxのミドルウェアについて

この記事に贈られたバッジ