Hooks時代のReactパフォーマンスチューニング

はじめまして!HRBrainでフロントエンドエンジニアをしている鈴木(@suzuesa)です

さて、早いものでHooksがリリースされて2ヶ月が経とうとしています
みなさんHooksを使いこなせてますか?私はまだまだ使いこなせません…
今回はその新しいHooksの機能をパフォーマンスチューニングの話と絡めてご紹介したいと思います

前提

パフォーマンスチューニングと言っても、どうしてReactが遅くなるのか、何処を改善すれば速くなるのかを知っておく必要があります

qiita.com

上の記事にすごい詳しく書いてあるので、そちらを見てからこの記事を読むともっと理解度が深まるかもしれません
簡潔に言えば 無駄な計算を抑え再レンダリングをできるだけ抑える この2つがポイントです
特に再レンダリングの負荷が高く、抑制する効果が高いためこれを重点的に考えていく必要があります

ReactはPropsもしくはStateが変更されたコンポーネント以下の すべてのコンポーネント を再レンダリングします
ですので子のコンポーネントで「このコンポーネントはいつ再レンダリングしないか」を定義し、不要な再レンダリングを回避していくホワイトリスト形式で各コンポーネントを調整していきます

これまでのチューニング方法

Hooksを使ったパフォーマンスチューニングのお話をする前に、これまでの方法をおさらいさせてください
既にパフォーマンスチューニングについて知見のある方はHooks以降のチューニングまで飛んでOKです

ClassComponent

ClassComponent では基本的に shouldComponentUpdate メソッドをオーバーライドし boolean を返すことでそのコンポーネントをいつ再レンダリングするかカスタマイズすることができました

class Component extends React.Component<{ text: string }> {
  shouldComponentUpdate(prevProps, nextProps) {
    const isTextDiff = prevProps.text !== nextProps.text
    return isTextDiff
  }
}

しかし、これでは Props を追加する度に比較式を書かなければならず、かなり面倒でした
これを解決するためReactの機能としてPureComponent が発表され、自動 ShallowEqual 機能により格段にコード記述量が減り簡単かつ簡潔にパフォーマンスチューニングが可能になりました

class Component extends React.PureComponent<{ text: string }> {
  // shouldComponentUpdate を書かなくて良い
}

FunctionComponent

ClassComponentでは shouldComponentUpdatePureComponent を使って再レンダリングをコントロールすることができましたが、FunctionComponentはそういった機能が無く、必ず再レンダリングされてしまっていました
しばらく経ってReact v16.6で発表された memo によりFunctionComponentでも shouldComponentUpdate と同じことができるようになりました

// PureComponent相当
const Component = React.memo<{ text: string }>(({ text }) => {
  ...
})

// shouldComponentUpdate相当 但し第二引数関数の返り値は再レンダリングする場合にtrueにする
const Component = React.memo<{ text: string }>(({ text }) => {
  ...
}, (prevProps, nextProps) => {
  const isTextEqual = prevProps.text === nextProps.text
  return isTextEqual
})

Hooks以降のチューニング

みなさんはこうしてパフォーマンスチューニングをしてきと思いますが、Hooksの登場により更にパフォーマンスを極めることができるようになりました
キーとなるのは useMemouseCallback の2つです

useMemo

一旦再レンダリングを抑える話は置いておいて、
useMemo は計算結果を記憶し、必要な時だけ再計算することができる機能で、例えば以下のような場合に有効です

const Component: React.FC = ({ products }) => {
  const soldoutProducts = products.filter(x => x.isSoldout === true)
}

Propsから渡された products から「売り切れの商品」を計算していますが、商品が10,000個程度あったとすると(こんなこと無いとは思いますが…)再レンダリングの度に計算するのは負荷がかかります
そんな時に useMemo を使用するとPropsの products変更された時のみ 計算させることが可能です

const Component: React.FC = ({ products }) => {
-  const soldoutProducts = products.filter(x => x.isSoldout === true)
+  const soldoutProducts = React.useMemo(() => products.filter(x => x.isSoldout === true), [products])
}

useMemo は第一引数関数の結果を保持し第二引数で渡された値に変更があった時のみ再軽鎖されます
上の例だと products が変更された時のみとなるので、いくら Component が再レンダリングされようとPropsの products が変更されない限り .filter() 関数は実行されません

個人的には useMemo 全部の変数に付けるくらいの勢いで書いてしまうのがおすすめです

memo() + useCallback()

useCallback は子コンポーネントに渡すコールバック関数を記憶しておく事ができる機能です
単体ではあまり意味がない(らしい)ですが、前述の memo() と併用することで一番の効果を発揮します

const App = () => {
  const [text, setText] = React.useState('')

  return (
    <>
      <input type='text' value={text} onChange={e => setText(e.target.value)} />
      <Wrap />
    </>
  )
}

const Wrap = () => {
  const [isChecked, setIsChecked] = React.useState(false)
  const toggleChecked = () => setIsChecked(!isChecked)

  return <Checkbox value={isChecked} onClick={toggleChecked} />
}

const Checkbox = React.memo<{ value: boolean; onClick: () => void }>(
  ({ value, onClick }) => {
    console.log('Checkbox is renderd!')
    return (
      <div style={{ cursor: 'pointer' }} onClick={onClick}>
        {value ? '☑' : '□'}
      </div>
    )
  }
)

ちょっと長くて説明し辛いので是非みなさんも自分の環境で動かしてみてください
注目して欲しいのは Checkbox で、この状態だと memo 化されているにも関わらず App -> Wrap の順に再レンダリングがかかると Checkbox も再レンダリングされてしまっています

f:id:hrb-suzuki-souma:20190327194614g:plain

これを useCallback を使って以下のように修正します

...
const Wrap = () => {
  const [isChecked, setIsChecked] = React.useState(false)
- const toggleChecked = () => setIsChecked(!isChecked)
+ const toggleChecked = React.useCallback(() => setIsChecked(!isChecked), [isChecked])

  return <Checkbox value={isChecked} onClick={toggleChecked} />
}
...

実際に実行して確認してみましょう

f:id:hrb-suzuki-souma:20190327194652g:plain

input に変更があっても Checkbox が再レンダリングされていないことがわかります
修正前再レンダリングされてしまっていた原因はコールバック関数にあり、Wrap が再レンダリングされると toggleChecked新しく 作り直され Checkbox に渡されるので、 CheckboxShallowEqual はこれを「前回とは違うPropsだ!」と判断し再レンダリングしてしまうのです
useCallback を使用して isChecked が変更された時のみ作り直すとしたことで、 Wrap が再レンダリングされた際には作り直されず記憶されるため ShallowEqual で同じPropsだと判断され再レンダリングされないようにすることができます

useCallback に関しても全部に付けるくらいの勢いで書くのがオススメです
(どこのコンポーネントがmemoしててどれをuseCallbackにしないといけなくて…と管理する方が大変)

実践

ここまで新しい useMemouseCallback について解説してきましたが、習うより慣れろで練習問題を用意しました

codesandbox.io

お寿司のメニューを管理する簡単なアプリを作ってみました
今のままではかなりイケてないので、是非これをForkしてパフォーマンスを最強にしてみてください!