React.memoを利用したパフォーマンスチューニング

React.memoはコンポーネントをメモ化し、
ウォッチしている変数に変更があったときにメモ化していたコンポーネントを破棄し、
再レンダ、再びメモ化します。

第1引数にコンポーネントを返す関数を渡し、第2引数にPropsが同一であるかを判断する関数を渡します。
shouldComponentUpdateでは再レンダを行う際にtrueを返しますが、
memoでは再レンダを行わない際にtrueを返すので注意が必要です。

第2引数に何も渡さなかった場合はPureComponent(shallowEqual)と同等の比較によりメモの更新判断がなされます。

PureComponentとshouldComponentUpdateがFunctional Componentになる上で合体したという理解で大体OKです。

// below two components are same
class TodoListClassStyle extends React.PureComponent {
 constructor(props) {
   super(props)
 }
 render() {
   return (
     <ul>
       { 
         this.props.list.map((item, index) => (
           <li key={index}>
             { item }
           </li>
         ))
       }
     </ul>
   )
 }
}

const TodoListFCStyle = React.memo(({ list }) => (
 <ul>
   { 
     list.map((item, index) => (
       <li key={index}>
         { item }
       </li>
     ))
   }
 </ul>
))

上が従来の記法、下がmemoを使用した記法です。
慣れてしまえばだいぶすっきりした記法ですね。

ここからが本題で、memoを利用しどうやってチューニングをしていくかの話になります。
私はすべてのコンポーネントに同一に適応できるチューニングTipsというのはないと考えていて、
各コンポーネントのpropに応じて最善手を選択する事になると考えています。
銀の弾丸なんてなかった。

そうは言っても、大体以下のパターンのどれかに収まるので身構えなくても良かったりします。

1. propsがない場合
2. propsがshallow equalで対応しきれる場合
3. propsがdeep equalで対応する必要がある場合
4. 変化しうるプロパティが特定できている場合

1. propsがない場合

この場合は常時trueを返す関数をセットしたmemoか、
普通のSFCが早いかの比較になります。計測しましょう。

※ 以下のパフォーマンス計測は、create-react-appで作成したプロジェクトをビルドしたもので計測しています。

import React from 'react'
import ReactDOM from 'react-dom'

const ConflictSFC = () => (
  <pre>
    conflict歌います。
    ズォールヒ~~↑wwww
    ヴィヤーンタースwwwww
    ワース フェスツwwwwwww
    ルオルwwwww
    プローイユクwwwwwww
    ダルフェ スォーイヴォーwwwww
    スウェンネwwww
    ヤットゥ ヴ ヒェンヴガrジョjゴアjガオガオッガwwwじゃgjj
  </pre>
)
 
const ConflictMemo = React.memo(() => (
  <pre>
    conflict歌います。
    ズォールヒ~~↑wwww
    ヴィヤーンタースwwwww
    ワース フェスツwwwwwww
    ルオルwwwww
    プローイユクwwwwwww
    ダルフェ スォーイヴォーwwwww
    スウェンネwwww
    ヤットゥ ヴ ヒェンヴガrジョjゴアjガオガオッガwwwじゃgjj
  </pre>
)
, () => true)
 
const app = document.querySelector('#app')

const time0 = performance.now()

for(let i = 0; i < 10000; i++) {
   ReactDOM.render(
       <ConflictSFC />,
       app
   )
   ReactDOM.unmountComponentAtNode(app)
}

const time1 = performance.now()

console.log(`With Stateless Functional Component -> ${time1 - time0} milliseconds.`)

const time2 = performance.now()

for(let j = 0; j < 10000; j++) {
   ReactDOM.render(
       <ConflictMemo />,
       app
   )
   ReactDOM.unmountComponentAtNode(app)
}

const time3 = performance.now()

console.log(`With React.memo -> ${time3 - time2} milliseconds.`)

SFC Component : 323.2399999978952 milliseconds.
React.memo : 206.68999999179505 milliseconds.
(Chrome 72)

SFC Component : 786.6 milliseconds.
React.memo : 849.4 milliseconds.
(Edge)

ここに関してはブラウザ間の差異が激しすぎる印象です。
(というかEdgeがアレな気が・・・)   
EdgeもChakra捨ててChromiumに迎合するのでmemoするのも悪くないかもしれないです。

結論:Chromium系ならmemoが早そう

2. propsがshallow equalで対応しきれる場合

shallow equalはその名の通り浅い比較を指します。
具体的には、オブジェクトの1段階目のプロパティの比較を行うものです。


type Props = {
 name: string
 age: number
}

// below two components works same
const MyLovelyCat = React.memo(({ name, age } : Props) => (
 <>
   <h1>My Lovely Cat {'<3'}</h1>
   <p>Name: {name}</p>
   <p>Age: {age}</p>
 </>
), (prevProps, nextProps) => {
 // shallow equalの実態
 // オブジェクトの一段階目のプロパティのみ比較を行う
 const keys = Object.keys(prevProps)
 
 for (const key of keys) {
   if(prevProps[key] !== nextProps[key]) {
     return false
   }
 }
 
 return true
})

const MyLovelyCaaaaaaaaat = React.memo(({ name, age } : Props) => (
 <>
   <h1>My Lovely Cat {'<3'}</h1>
   <p>Name: {name}</p>
   <p>Age: {age}</p>
 </>
))

JavaScriptに詳しい方ならご存知だと思うのですが、
比較演算子でObjectを比較する際はオブジェクトが同一のインスタンスであるかを比較します。
つまりKeyValueが完全に一致していても別インスタンスでは比較演算子は同一でないと判断するということです。

class Hoge {
 constructor(a, b) { this.a = a; this.b = b; }
}
console.log(new Hoge(1, 2) === new Hoge(1, 2)) // This will be false!!!

この時点で、shallow equalにネストしたプロパティを渡しても意図した結果にならない事が分かりました。
ネストしたプロパティまで対応したい場合、deep equalの出番です。
但し、shallow equalはdeep equalより高速であることを忘れないでください。

3. propsがdeep equalで対応する必要がある場合

ネストしたオブジェクトをpropsに持つ場合にはdeep equalの出番です。
再帰的にオブジェクトのプロパティを比較します。 
  
但し、ここで紹介する方法の中で最も遅くなることを留意してください。
もし変化しうるプロパティを分かりきっている場合は、4. 変化しうるプロパティが特定できている場合の実装を優先してください。

deep-equalライブラリはdeep-equal系ライブラリの中で最速を謳うfast-deep-equalを使用します。

import equal from 'fast-deep-equal'

const ComponentWithDeepEqual = React.memo(props => (
// snip
), (prev, next) => equal(prev, next))

4. 変化しうるプロパティが特定できている場合

もし特定のプロパティが変化した時だけメモを更新するのであれば
自分で更新を判定する関数をカスタマイズして対応することがベストプラクティスになります。

type TheBrave = {
 name: string
 gender: string
 equips: Equips
 maxHealth: number
 currentHealth: number
 maxMagicPoint: number
 currentMagicPoint: number
}

例えばこんなプロパティを持つ勇者が居たとしましょう。
そしてあなたは戦闘中に何度も更新されるコンポーネントを実装することになったとします。

Equipsがオブジェクトであろうので、
deep equalが適切かと思いますが、
冷静になって考えると戦闘中に変化するであろうプロパティはcurrentHealthcurrentMagicPointのみです。


const BattleComponent = React.memo(
 ({ theBrave }: { theBrave : TheBrave }) => {
   // snip
 },
 // p -> prevProps, n -> nextProps
 (p, n) => p.theBrave.currentHealth === n.theBrave.currentHealth &&
           p.theBrave.currentMagicPoint === n.theBrave.currentMagicPoint
)


必要なプロパティのみ比較しているので、
shallow equalやdeep equalより高速なコンポーネントの完成です!

まとめ

A. propsがあるか? Yes -> B. No -> 1.
B. 特定のプロパティだけ気にすればいいか? Yes -> 4. No -> C.
C. ネストしたプロパティがあるか? Yes -> 3. No -> 2.

チューニングに関する記事は正直あまりこれ!
という決定版が見つからないのでとりあえず自分の持っている知見を全放出しました。
あくまで一人が調べた結果でしかないのでミスリードや議論の余地があるかと思います。 
この記事を叩き台にしてほしい気持ち  
反応待ってます・・・!

※この記事の原本はこっちにあるのでPR等でツッコミ待ってます。

(noteのシンタックスハイライト改善して)

#プログラミング #フロントエンド #JavaScript #React

この記事が気に入ったらサポートをしてみませんか?