Reactでメモ化によるパフォーマンス改善を狙う

react

はじめに

はじめまして。株式会社スタメンでエンジニアをしております永井です。 今回の記事ではReactでメモ化によるパフォーマンスを意識した実装方法について書きたいと思います。

なぜパフォーマンスを意識した実装が大切なのでしょうか。

なぜなら、ユーザーのある操作に対するレスポンスの速度を高めることは、UXの文脈において非常に重要な要素だからです。例えば、100ms未満のレスポンスに関してはユーザーは瞬時に感じられますが、100ms ~ 300msではすでに遅いと感じてしまいます。遅いことにストレスを感じたユーザーは、別のサービスにリプレイスしてしまうかもしれません。

Reactでパフォーマンスを出すには、バンドルサイズを減らすなど、いくつか方法はありますが、基本的な戦略としては不要なレンダリングを抑えることだと思います。

この不要なレンダリングを抑制するためには、Reactがどのように機能するかを理解する必要があります。理解しないまま改善を行うと、却ってパフォーマンスに悪影響が出る可能性もあります。

そのため、パフォーマンス改善に繋がるメモ化等のメソッドを説明する前に、このReactがレンダリングにおいてどのように機能しているかを説明したいと思います。

仮想DOMによるReactの更新処理

ReactではDOMの更新処理を、仮想DOMによる差分更新処理に任せることで、パフォーマンスを高めています。

具体的に言うと、実際のDOMをJavascriptオブジェクトの形式に変換したツリーデータをメモリ上に作成し、コンポーネントの状態に変更がある度に、実際のDOMを更新するのではなく仮想DOMを更新します。

更新された仮想DOMと古い仮想DOMを比較し差分を検出することで、実際のDOMにレンダリングを行います。 こうすることで実際のDOMでは必要最低限の箇所のみレンダリングを行うことが可能になります。

例えば以下のようなコードがあるとします。

const Page = () => {
  return (
    <div>
      <p>Counter App</p>
      <Counter />
    </div>
  )
}

const Counter = () => {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  )
}

仮想DOMとしては、初回レンダリング時にPageとCounterコンポーネントで記述されたJSXを元に、Javascriptで構築した仮想DOMツリーを生成します。そして、+を押すと、countのstateが更新されてレンダリングが走り、countの箇所が2となった新しい仮想DOMツリーを生成します。そして新旧2つの仮想DOMツリーを比較して差分を検出し、差分があった箇所である<span>{count}</span>だけを実際のDOMに反映します。

このように、仮想DOMの概念によって必要最低限の箇所のみDOMを変更することができます。

Reactのメモ化

仮想DOMによる差分更新によって、変更箇所のみをレンダリングすることができます。しかし、配下のコンポーネントが再描画されるため、不必要な箇所まで再レンダリングされてしまいます。 そこで、Reactによるメモ化によってコンポーネントに変更がない場合はレンダリングされないようにしましょう。

React.memo

Reactの高階関数であるReact.memoはコンポーネントをメモ化する上でよく使われる手法です。

例えば以下のようなコードがあるとします。

const Parent = () => {
  const [parentName, setParentName] = useState('')
  return (
    <div>
      <span>parent is {parentName}</span>
      <input type="text" onChange={e => setParentName(e.target.value)} />
      <Child />
    </div>
  )
}

const Child = () => {
  return (
    <div>
      <span>I am Child</span>
    </div>
  )
}

この時、<input //... />に文字を入力するとsetParentNameが発火してnameが更新されます。stateが更新されるのでParentコンポーネントはレンダリングされますが、Childコンポーネントはどうでしょうか?

試しにconsole.log('child')を仕込んでみましょう。

const Child = () => {
  console.log('child')
  return (
    <div>
      <span>I am Child</span>
    </div>
  )
}

再び<input //... />に文字を入力してみましょう。すると...

=> child

「child」と表示されてしまいました。つまり、文字を入力する度に発生するParentコンポーネントのレンダリングに付随して、Childコンポーネントも毎回レンダリングされてしまっているのです。

本来であればこのレンダリングは不要なので、パフォーマンスを考慮するのであれば防ぎたいレンダリングです。

このレンダリングを抑えるために、React.memoを使います。

const Child = React.memo(() => {
  console.log('child')
  const
  return (
    <div>
      <span>I am Child</span>
    </div>
  )
})

このコードではReact.memoでコンポーネントをラップすることで、Childコンポーネントに渡すpropsに変更がない場合に、レンダリングをスキップしています。Parentコンポーネントにある<input //... />で再び文字を入力してみると、コンソールには何も出力されていないことが確認できると思います。

このReact.memoでpropsの前後の値を比較してレンダリングするかを決定している訳ですが、この比較は浅い比較で行われます。所謂、オブジェクトのインスタンスにおける参照が異なるかどうかを見ています。

React.memoは第2引数に何も指定しないと、デフォルトでは浅い比較で行われます。第2引数に比較関数を渡すことでレンダリングをカスタムで制御することができますが、基本的には等価性のチェックにはコストが掛かるので避けたいです。

このように、React.memoを使用することで、本来変更されていないコンポーネントのレンダリングを抑えることができますが、一つ落とし穴があります。

次のコードを見てみてください。

const Parent = () => {
  const [parentName, setParentName] = useState('')
  const [childName, setChildName] = useState('')
  
  const childNameHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setChildName(e.target.value)
  }
  
  return (
    <div>
      <span>parent is {parentName}</span>
      <input type="text" onChange={e => setParentName(e.target.value)} />
      <Child name={childName} onChange={childNameHandler} />
    </div>
  )
}

const Child = React.memo(({ name, onChange }) => {
  console.log('child')
  return (
    <div>
      <span>child is {name}</span>
      <input type="text" onChange={onChange} />
    </div>
  )
})

変更した箇所としては、Childコンポーネントにstateのnameと関数のchildNameHandlerpropsで渡しています。

このコードで再びParentコンポーネントにある<input //... />に文字を入力してみましょう。すると...

=> child

React.memoでメモ化しているのにも関わらず、再びレンダリングされてしまいました。この原因は、childNameHandler関数にあります。 アロー関数はレンダリングの度に新しい関数オブジェクトを生成しますが、この関数オブジェクトの再生成によって、propsとして渡しているchildNameHandlerの参照が変更されてしまい、Childコンポーネントがレンダリングされてしまうのです。((() => {}) !== (() => {})であるため)

これを防ぐ方法としてuseCallbackがあります。

useCallback

useCallbackを用いて改善したコードは以下のようになります。

const Parent = () => {
  const [parentName, setParentName] = useState('')
  const [childName, setChildName] = useState('')
  
  const childNameHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setChildName(e.target.value)
  }, [])
  
  return (
    <div>
      <span>parent is {parentName}</span>
      <input type="text" onChange={e => setParentName(e.target.value)} />
      <Child name={childName} onChange={childNameHandler} />
    </div>
  )
}

useCallbackは、メモ化された関数オブジェクトを返すhooks APIです。第2引数にしている配列は依存配列で、配列内のいずれかの値が変更されると、新しく関数オブジェクトを生成します。

useCallbackを使用する場合は、基本的にReact.memoによって最適化されたコンポーネントにpropsとして渡す場合に限定するべきです。 React.memoを使用していないコンポーネントにuseCallbackによってメモ化した関数を渡したとしても、親コンポーネントがレンダリングされると子コンポーネントはレンダリングされてしまうからです。

また、React.memoによってメモ化されたコンポーネントに渡さない場合にもuseCallbackでメモ化するのは避けた方が良いと言われています。これはuseCallbackの実行コストは関数オブジェクトの再生成のコストよりも高いと言われているからです。

useMemo

useMemoは関数の返り値をメモ化する際に使用します(useCallbackは関数自体をメモ化します)

例えば、以下のような関数があるとします。(こんなコードは現実には存在しないと思いますがあくまでサンプルということで)

const someCalculate = () => {
  let number = 0;
  while(number <= 1000) {
    console.log(number)
    number++
  }
  return count * number
}

非常にシンプルなコードですが、変数numberが1000になるまで1ずつ足していき、最終的に変数countと乗算するというものです。

useMemoではこの計算結果をメモ化することができ、計算自体をスキップすることができます。(以下のコード)

const memorized = useMemo(() => {
  let number = 0;
  while(number < 1000) {
    console.log(number)
    number++
  }
  return count * number
}, [count])

useMemoの依存配列にはcountを入れています。countが変化すると再計算する必要がありますが、countが不変の場合は計算結果をメモ化して利用することができます。

最後に

Reactでのメモ化について今回書きました。普通にReactで実装していても画面自体は出来てしまうのですが、Reactのレンダリングやメモ化について知らないと、実はかなりパフォーマンスが悪い実装になってしまうことは往々にしてあると思います。

弊社のプロダクトであるTUNAGでも、フロントエンド領域においてパフォーマンスを最適化しきれていない部分がまだまだあるので、徐々に最適化できればと思います。

スタメンでは一緒に働くエンジニアを募集しています。 興味がある方は、ぜひ採用サイトからご連絡ください!