🕵️

なぜReactは標準でComponentをmemo化しないのか?

taro2022/05/22に公開2件のコメント

はじめに

普段はスタートアップでBtoB SaaSの開発をしているtaroと申します。
今回は、Reactのmemo化について考えている中で抱いた
「なんでReactは標準でComponentをmemo化していないんだろう?」
という疑問を解消するために、色々と調べたり考えたりした内容をまとめました!
途中でrenderのタイミングや、memo化で再renderが抑えられる理由などの前提知識の復習も含めていて、memo化について詳しくない方もmemo化の勉強にもなると思うので、ぜひぜひ読んでみてくださいー!

なぜこんな疑問を抱いたのか?

まずはそもそも僕がタイトルにあるような疑問を抱いた背景です。
疑問を抱くまでの思考プロセスはこんな感じです。

「再renderが余分に走ってて画面が重いから最適化したいなー」
→「React.memo()を使ってComponentをmemo化しよう!」
→「Componentごとにmemo化するかどうか考えるの面倒。。。」
→「いちいち考えるの面倒だし、全部memo化しちゃえばいんじゃね?」
→「それにmemo化したら困るComponentってない気がするし。。。」
→「じゃあなんでReactは標準でmemo化しないんだろう?」

まぁ一言でいえば
React.memo()を使うだけでパフォーマンスが向上するし、memo化したら困るComponentってなさそうだし、なんで標準でやってないんだろう?」
です。

ということで、React.memo()について色々調べたわけなんですが、本題に入る前に

  • ReactのComponentはいつ再renderされるのか?
  • React.memo()によってなぜ再renderを抑えられるのか?

を少しだけ復習して、前提知識を揃えていこうと思います。
(「復習はいらないよー!」って方は、改めて最初の疑問に立ち返るまで飛んでください!)

前提を揃えるために少し復習

まずはReactのComponentが再renderされるタイミングの復習です。

ReactのComponentはいつ再renderされるのか?

通常、ReactのComponentが再renderされるタイミングは

  1. 親Componentが再renderされた時
  2. stateが更新された時
    • useStateのsetterの実行
    • useReducerdispatch()の実行
    • Class Componentのthis.setState()の実行

です。
[React render いつ]と調べると、propsが更新された時も含めて紹介している記事もありますが、これは後述するmemo化されたComponentのみです。
Reactは1.親Componentが再renderされた時に、無条件ですべての子Componentを再renderするため、propsが更新されたかどうかは確認していません。

余談: 同じ値でsetState()したら再renderされるのか?

少し本題とずれますが、変更前のstateと同じ値でsetState()したら再renderされるのでしょうか?

答えは、

  • stateがプリミティブ → 再renderされない
  • stateがオブジェクト → 同一オブジェクトなら再renderされない。

です。

実はstateを変更する時に、内部で変更前後のstateをshallow比較していて、同じであれば再renderされません。
例えばuseStateのsetterを実行すると、useReducerdispatch()と同様なdispatchSetState()という関数が実行され、その中で

react/packages/react-reconciler/src/ReactFiberHooks.new.js
if (objectIs(eagerState, currentState)) {
  // Fast path. We can bail out without scheduling React to re-render.
  // It's still possible that we'll need to rebase this update later,
  // if the Componentre-renders for a different reason and by that
  // time the reducer has changed.
  return;
}
react/packages/shared/objectIs.js
/**
 * inlined Object.is polyfill to avoid requiring consumers ship their own
 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
 */
function is(x, y) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

var objectIs = typeof Object.is === "function" ? Object.is : is;

のようにshallow比較をしていて、同じだった場合は return され処理が止まり、再renderが起こりません。(コード中のコメントにもwithout schedulingReactto re-renderとありますね!)

そのためstateがプリミティブであれば同じ値を、オブジェクトなら同一オブジェクトを変更後のstateとしてsetterに渡して実行しても、再renderがされないことが確認できます。
逆に言えば、オブジェクトは中身の値が全く同じでも別のオブジェクトだとstateが変更されたと認識され、再renderされるので注意しましょう。

オブジェクトのshallow比較の例
const hoge = {bar: 'bar', foo: 'foo'}
const fuga = {bar: 'bar', foo: 'foo'}

hoge === hoge // -> true
hoge === fuga // -> false
hoge === {bar: 'bar', foo: 'foo'} // -> false
hoge === {...hoge} // -> false

ReactのComponentが再renderされるタイミング

改めてReactのComponentが再renderされるタイミングは以下の2つです。

  1. 親Componentが再renderされた時
    • propsの変更有無は確認せず無条件に再renderされる
  2. stateが更新された時
    • 変更前と変更後でstateをshallow比較して差分があった時のみ

では再renderされるタイミングを踏まえた上で、React.memo()によって再renderが抑えられる理由を復習していきます。

React.memo() によってなぜ再renderを抑えられるのか?

そもそもReact.memo()は、Componentを引数にとってComponentを返す(Componentをラップする)関数です。(高階Componentとも呼びます。)

// React.memoでラップするだけで、Componentはmemo化される
const MemoComponent = React.memo((props) => {
  // propsを使った処理
  return <Hoge />
})

React.memo()でラップされたComponentは、その親Componentが再renderされた際にpropsの変更を確認して変更がなければ、再renderされません。

またpropsの比較の方法はデフォルトではshallow比較をしていますが、独自の比較関数をReact.memo()の第2引数に渡すことで、比較方法をカスタマイズできます。

const equalFunction = (prevProps, nextProps) => {
  // 独自の比較処理
}

const MemoComponent = React.memo((props) => {
  // propsを使った処理
  return <Hoge />
}, equalFunction)

ただし僕が情報収集している限りだと、比較関数のカスタマイズはあまり使わない印象です。
そのため以降でComponentをmemo化する文脈があった場合は、比較関数のカスタマイズはせずデフォルトのshallow比較によるmemo化を意味するとします。

ちなみにpropsを持たないComponentもReact.memo()でmemo化できます。
そもそもpropsがないため、memo化すると親Componentが再renderされてもまったく再renderされなくなります。

React.memo()によって再renderが抑えられる理由

ということで、React.memo()で再renderが抑えられるのは、
React.memo()でラップされたComponentは、親Componentが再renderされてもpropsに変更がない場合に再renderされないから」
です。

復習まとめ

ここまで復習お疲れ様でした!
最後に一度復習した内容を簡単にまとめます。

ReactのComponentが再renderされるタイミング

  1. 親Componentが再renderされた時
    • propsの変更有無は確認せず無条件に再renderされる
  2. stateが更新された時
    • 変更前と変更後でstateをshallow比較して差分があった時のみ

React.memo()によって再renderが抑えられる理由
React.memo()でラップされたComponentは、親Componentが再renderされても変更前後のpropsをshallow比較して変更がない場合に再renderされないため

では改めてタイトルの疑問に立ち返っていこうと思います。

改めて最初の疑問に立ち返る

改めて今回僕が抱いた疑問は
「なんでReactは標準でComponentをmemo化しないんだろう?」
です。

ただ当然、標準でmemo化されると困る要件のComponentがあるのかもしれません。
そのためまずはComponentをmemo化するかの判断軸を考え、その後にmemo化してはいけないComponentがあるのかを考えます。

Componentをmemo化するかの判断軸

memo化されたComponentは、親Componentが再renderされてもpropsに変更がある時しか再renderされません。
つまりComponentをmemo化するかの判断軸は

  • 親Componentの再renderによってpropsの変更がある時だけ再renderして欲しいComponent
    • ⭕ memo化した方が良い
  • 親Componentの再renderによってpropsに変更がない時でも再renderして欲しいComponent
    • ❌ memo化しちゃだめ

と考えられます。

では「親Componentの再renderによってpropsに変更がない時でも再renderして欲しいComponent」とはどんなComponentでしょうか?

propsに変更がない時でも再renderして欲しいComponentは存在するのか?

シンプルに考えると、親Componentだけ再renderされて子Componentが再renderされないと、子Componentだけ古くなっていくので、例えば表示するデータの最新性を保つことが求められるComponentなどが候補に挙げられそうです。
ただしその場合は、最新性を保つという責任を持っているのは子Componentです。
そのため最新性を親Componentの再renderのタイミングに委ねるのは、責任の持ち方として正しくないのではないかと思います。

などなど、色々な状況を考えてみたんですが正しい責任分割を前提として考えると該当するようなComponentを僕は思いつきませんでした。
そのため「親Componentの再renderによってpropsに変更がない時でも再renderして欲しいComponent」は存在しないか、もしくはかなりニッチな用途のComponentなのではないかと思いました。(ここは僕個人の解釈なので注意です。)

すべてorほとんどのComponentはmemo化した方が良い?

つまり

  • 親Componentの再renderによってpropsの変更がある時だけ再renderして欲しいComponent
    • ⭕ memo化した方が良い
  • 親Componentの再renderによってpropsに変更がない時でも再renderして欲しいComponent
    • ❌ memo化しちゃだめ
    • 用途が存在しないか、もしくはかなりニッチな用途のComponent

となり、すべてorほとんどのComponentはmemo化して良さそうな気がしてきました。
ではなぜReactは標準でComponentをmemo化していないのでしょうか?

なぜReactは標準でComponentをmemo化しないのか?

色々と調べているうちに、Reduxの作者であり現Reactの開発者であるDan AbramovさんがComponentのmemo化について言及しているツイートを見つけました。

Componentのmemo化についてのDanさんの見解

そのツイートがこれです。

どうもmemo化によるshallow比較の負荷はprops数に比例するらしく、結果として再renderされる場合はこのshallow比較の処理は無駄になり、また多くのComponentは異なるpropsを受け取るため、比較する方が早いとは言い切れないようです。

Reactのソースコードも読んで見る

せっかくなのでReactのソースコードのmemo化されたComponentの更新処理がどうなっているかを見てみましょう。

まずはmemo化されたComponentを更新する関数です。

react/packages/react-reconciler/src/ReactFiberBeginWork.new.js
function updateMemoComponent(
    current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
): null | Fiber {
    // 省略
  if (!hasScheduledUpdateOrContext) {
       // This will be the props with resolved defaultProps,
    // unlike current.memoizedProps which will be the unresolved ones.
    const prevProps = currentChild.memoizedProps;
      // Default to shallow comparison
      let compare = Component.compare;
      compare = compare !== null ? compare : shallowEqual;
      if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
          return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
      }
  }
    // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  const newChild = createWorkInProgress(currentChild, nextProps);
  newChild.ref = workInProgress.ref;
  newChild.return = workInProgress;
  workInProgress.child = newChild;
  return newChild;
}

 compare = compare !== null ? compare : shallowEqual;で、React.memo()の第2引数で渡された独自比較関数があればそれを、なければshallow比較をpropsを比較する関数にしていて、compare(prevProps, nextProps)で変更前後のpropsを比較しています。

次に、shallow比較の関数です。

react/packages/shared/shallowEqual.js
/**
 * Performs equality by iterating through keys on an object and returning false
 * when any key has values which are not strictly equal between the arguments.
 * Returns true when the values of all keys are strictly equal.
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }

  return true;
}

for (let i = 0; i < keysA.length; i++)でpropsの数だけfor文を回して、is(objA[currentKey], objB[currentKey])でshallow比較しているのがわかりますね。
まさにpropsの数に比例してshallow比較されています。
ちなみにis()は、前述したuseStateのsetterで変更前後のstateのshallow比較でも使われている関数です。

react/packages/shared/objectIs.js
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

結論

「なぜReactは標準でComponentをmemo化しないのか?」の結論は、まさにDanさんがツイートで述べている通りで

  • memo化によるshallow比較による負荷がprops数に比例する
  • 結果として再renderされる場合はこのshallow比較の処理は無駄になる
  • 多くのComponentは異なるpropsを受け取るため、比較するほうが早いとは言い切れない(shallow比較が無駄になることが多い)

のため、標準でComponentをmemo化していないようです。
パフォーマンスを抑えるためのmemo化によってパフォーマンスが悪化してしまう可能性があるため、memo化するかどうかは任意で開発者に委ねられているんですね。

おまけ

最後に少しだけおまけです。

shallow比較の負荷ってどれくらい再renderの速度に影響がでるのか?

CodeSandBoxで大量にpropsを持つComponentを作って、memo化の有無で再renderにかかる時間にどれくらい差がでるのか調べてみましょう。

方法として、大量のpropsを持つComponentをmemo化の有無で2つ用意して、親Componentを再renderしてpropsを変化させ、2つのComponentの再renderに要する時間を比較します。

再renderに要する時間の計測は、Google Chrome拡張のReact Developer ToolsのProfilerを使用します。上記のCodeSandBoxを使用する場合は、Open preview in new windowから新しいタブを開くとProfilerが使用できます。

propsが10,000個ある場合での再renderに要した時間です。
(2段目がmemo化されたComponent, 3段目がmemo化されてないComponent)


10,000個はかなり極端な例ですが、memo化されている方が圧倒的に10~30倍くらい再renderに時間を要していますね。

propsの数を1,000個に減らすと、差が3~5倍くらいになりました。


まぁComponentもpropsもめちゃめちゃシンプルなので、あんまり実用的な数値ではありませんが、たしかにmemo化されたComponentの方が再renderに時間を要し、またpropsの数に比例して要する時間が増えることを確認できました。

Record&Tupleが入れば、標準でComponentがmemo化されるかも…?

クロパンダさんからコメントいただいた内容ですが、Record&Tupleが導入されれば、propsの数に比例せずにpropsを比較できるようになるため、標準でComponentがmemo化される日がくるかもしれません。(あくまで可能性の話ですので、参考程度ですが…!)
https://github.com/tc39/proposal-record-tuple

RecordとTupleはそれぞれ

  • Record: オブジェクトlikeなデータ構造 #{ x: 1, y: 2 }
  • Tuple: 配列likeなデータ構造 #[1, 2, 3, 4]

であり===での比較がshallow比較でなくdeep比較になるため、propsの各プロパティごとにfor文を回す必要がなくなり、propsの数に比例せずにpropsの比較が可能になります。

ObjectとRecordでの===比較の違い
// Object
const hoge = {a: 1, b: 2}
hoge === {a: 1, b: 2} // -> false

// Record
const fuga = #{a: 1, b: 2}
fuga === #{a: 1, b: 2} // -> true

まとめ

今回は「なぜReactは標準でComponentをmemo化しないのか?」という疑問を解消するために、考えたり調べた内容をまとめました。
疑問の解答は、上記の結論で述べた通りで、memo化によって逆にパフォーマンスが悪化してしまう可能性があるからです。
そのためmemo化するかどうかは、やはりComponentごとにちゃんと判断したほうが良さそうなことがわかりました。この判断軸を決めるにはまだmemo化に関する知識が足りないので、もっと勉強してまとまったら記事にしようかと思うので、よかったらまた読んでいただけると嬉しいです!
感想やご指摘、質問等があれば、ぜひぜひお待ちしてますー!

お世話になったページ

https://ja.reactjs.org/docs/react-api.html#reactmemo
https://twitter.com/dan_abramov
https://github.com/facebook/react
https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/
https://qiita.com/hellokenta/items/6b795501a0a8921bb6b5

シェルフィーメンバーのzenn記事置き場📗

シェルフィー株式会社の公式テックブログで…はありません🙅‍♀️ メンバーがzennに投稿した記事を集めています。 Greenfile.workに関する記事もあれば、全く関係ない個人の好奇心による記事まで雑多にまとめています◎ React, TypeScript, Spring, Kotlin, AWS, k8s

Discussion

関数コンポネートにuseSelectorみたいなグルバール的なフックを使っている場合は、React.memoしたらいけないですね。

Object.Isで弾かれましたら、したのscheduleUpdateOnFiberを呼ばなく、再レンダリング走りません

ログインするとコメントできます