30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React.memo を使ったパフォーマンス最適化について

この記事はReact #2 Advent Calendar 2019の20日目の記事です。

パフォーマンス・チューニングには、「こうすれば必ず上手くいく」という方法論や銀の弾丸はなく、地道に試行と計測を繰り返すしかない。
しかしだからこそ、基本的な考え方や仕組みを理解することが大切であり、それがなければ、どのように対処していけばいいのか見当をつけることすら出来ず、的外れな対応をすることにもなりかねない。
React.memoを使った処理の最適化は、React アプリのパフォーマンス改善のための、基本となるテクニックのひとつである。

この記事のコードは React のv16.10.2で動作確認している。

メモ化という概念

React アプリのパフォーマンス最適化を理解するためにはまず、メモ化(Memoization)という概念を把握しておく必要がある。
大雑把に言ってしまうとメモ化とは、何らかの計算によって得られた値を記録しておき、その値が再度必要になったときに、再計算することなく値を得られるようにすることである。
メモ化によって都度計算する必要がなくなるため、パフォーマンスの向上が期待できる。
React.memoとは言ってみれば、メモ化を利用して不要な処理をスキップするための機能であり、それを活用することが、React アプリのパフォーマンス最適化の基本的な戦略である。

JavaScript においてはメモ化にはもうひとつ重要な役割がある。それは、同じ参照のオブジェクトを得られるようになるということ。
記録しておいたオブジェクトを取り出すので、「値が一緒だが参照が異なる別のオブジェクト」になってしまうことを防げる。
JavaScript のオブジェクトの等価性は「参照が同じかどうか」で判断することが多く、React も例外ではないため、これは重要な意味を持つ。
後述するように、React.memoを上手く使うためにはこの効果も把握しておく必要がある。

再レンダーによるコスト

コンポーネントのstateが更新されると、そのコンポーネントは再レンダーされる。
そして、親コンポーネントが再レンダーされると、その子コンポーネントも無条件で再レンダーされる。

下記の例では、ボタンを押す度にAppChildが再レンダーされるため、その度にログが流れる。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState(0);

  const onClick = () => {
    setState(s => s + 1);
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      {state}
      <Child />
    </div>
  );
};

const Child = () => {
  console.log('render Child');

  return <div>Child</div>;
};

export default App;

f:id:numb_86:20191220213937g:plain

再レンダーには当然、それを実行するためのコストがある。
上記のサンプルではconsole.logを実行しているが、この部分で高コストな処理を行っていれば、その分だけパフォーマンスに悪影響を与える。

また、再レンダーの度に React が内部的に行う処理もある。
例えば、再レンダーによって返された React 要素を前回のレンダー時の React 要素と比較して、DOM を更新する必要があるかをチェックしている。
そして、更新する必要があった場合は、差分に応じて DOM を更新する。
これらの処理にも当然、少なからずコストが掛かっている。

だが上記のChildは、stateが何であれレンダーする内容は変わらないため、Childの再レンダーが不要であることは一目瞭然。
そのため再レンダーをスキップできれば、その分だけ、パフォーマンスの向上が期待できる。

それを実現するために利用するのがメモ化であり、その具体的な方法がReact.memoである。

props の等価性をチェックする

React.memoでは、コンポーネントが返した React 要素を記録する。
そして、再レンダーが行われそうになったときは本当に再レンダーが必要なのかチェックし、必要なときだけ再レンダーし、必要ないときは記録しておいた React 要素を再利用することで、再レンダーを最低限に抑える。

再レンダーが必要かどうかは、propsの等価性によって判断される。
新しく渡されたpropsを前回のpropsと比較し、同じであると見なせば再レンダーは不要であると判断し、異なると見なせば再レンダーを行う。

デフォルトでは、等価性の判断にshallow compareを使う。これは、オブジェクトの1階層のみを比較することである。
先程の例のChildではpropsは常に{}であるため、shallow compareによって等価であると判断される。
早速React.memoを使ってみる。

React.memoは、メモ化したいコンポーネントをラップして使う。そのため、Childを以下のように書き換える。

const Child = React.memo(() => {
  console.log('render Child');

  return <div>Child</div>;
});

これで、Childの再レンダーは常にスキップされるようになった。

f:id:numb_86:20191220214002g:plain

次は、propsshallow compareでは再レンダーをスキップできないパターンを見てみる。

stateをオブジェクトにして、そのなかにappchildというプロパティを持たせた。どちらもボタンを押して個別にカウントアップできる。
Childコンポーネントはstateを受け取り、state.childを画面に表示している。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState({
    app: 0,
    child: 0,
  });

  const countUpApp = () => {
    setState(s => ({
      app: s.app + 1,
      child: s.child,
    }));
  };

  const countUpChild = () => {
    setState(s => ({
      app: s.app,
      child: s.child + 1,
    }));
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={countUpApp}>
        count up app
      </button>
      <br />
      <button type="button" onClick={countUpChild}>
        count up child
      </button>
      <br />
      App: {state.app}
      <Child state={state} />
    </div>
  );
};

const Child = React.memo(({state}) => {
  console.log('render Child');

  return <div>Child: {state.child}</div>;
});

export default App;

この場合、state.childの値が更新されたとき、つまりcount up childボタンが押したときだけ再レンダーすれば、十分なはず。
しかしこの実装だと、count up appボタンを押したときも再レンダーされてしまう。

f:id:numb_86:20191220214043g:plain

原因は、Childstateというオブジェクトを渡してしまったこと。
これをshallow compareすると、前回のprops.stateと今回のprops.stateは常に違うオブジェクトになるので、等価性がないと判断される。そのため、必ず再レンダーされてしまう。

この問題の正しい解決法は、Childstateを渡すのを止め、state.childだけを受け渡すようにすることである。Childstate.appを必要としていないのだから、stateオブジェクトを丸ごと渡しているのが、そもそも間違っている。

だが今回はReact.memoの説明のため、敢えて違う方法で解決する。

React.memoの第二引数には関数を渡すことができ、公式ドキュメントではこの関数のことをareEqualと呼んでいる。
areEqualは、第一引数として前回のpropsprevProps)を、第二引数として今回のpropsnextProps)を受け取る。
そしてareEqualは、真偽値を返すように書く必要がある。
そのため、このような構文になる。

const メモ化されたコンポーネント = React.memo(元のコンポーネント, (prevProps, nextProps) => {/* 真偽値を返す */})

areEqualtrueを返したときは再レンダーをスキップし、falseを返したときは再レンダーを行う。
areEqualを省略した場合は、上述の通りpropsshallow compareで等価性を判断する。

そのため、Childを以下のように書き換えると、state.childの値が変化していないときは再レンダーを不要と判断し、スキップする。

const Child = React.memo(
  ({state}) => {
    console.log('render Child');

    return <div>{state.child}</div>;
  },
  (prevProps, nextProps) => prevProps.state.child === nextProps.state.child
);

f:id:numb_86:20191220214124g:plain

繰り返しになるが、今回のケースでは、propsとしてstate.childだけを渡す、というのが正しい解決方法である。パフォーマンスの問題以前にコンポーネントの設計として、不必要なpropsを渡すべきではない。今回は説明のしやすさのために、敢えてこのようなサンプルにした。

areEqualで再レンダーをコントロールできるので、これを使えば、表示を制御することが可能になる。データの変化を表示に反映させたい場合にのみ、areEqualfalseを返すようにすればよい。
だが、そのような使い方をしてはいけない。公式ドキュメントにあるように、React.memoはパフォーマンス最適化のためにのみ、使う。

これはパフォーマンス最適化のためだけの方法です。バグを引き起こす可能性があるため、レンダーを「抑止する」ために使用しないでください。

React.memo – React

もうひとつ注意しなければならないのは、等価性のチェックにも、当然コストがかかるということ。
そしてareEqualで複雑なことをすればするほど、コストは高くなっていく。
もし等価性のチェックにかかるコストが再レンダーにかかるコストより高くなれば、意味がないどころか逆効果ということになる。
そのため、常にReact.memoを使えばよいというものではない。冒頭に書いたように試行と計測を繰り返して、最適化を行っていく。
パフォーマンス向上に僅かな効果しか得られない場合、他の要素(可読性やメンテナンス性など)とのトレードオフを考慮し、敢えて最適化しないという選択肢も十分にあり得る。

useCallback と組み合わせる

メモ化が使われている機能はReact.memoだけではない。useCallbackもそのひとつ。
useCallbackはメモ化を利用して、関数の不要な再作成を防ぐ。

ある関数コンポーネントのなかに以下の記述があったとする。

const showAandB = () => {
  console.log(props.a, props.b);
};

showAandBは、再レンダーされる度に新しく作られる。
だが、props.aprops.bが変わらない限り、showAandBを新しく作り直す必要はない。
useCallbackを使えば、以前作ったshowAandBを再利用できる。

const showAndB = useCallback(() => {
  console.log(props.a, props.b);
}, [props.a, props.b]);

useCallbackの第一引数に作成する関数を、第二引数にその関数が使用している値を列挙した配列を、渡す。
そうすると、配列に列挙した値を前回と比較し、それがひとつでも変われば、新しくshowAandBを作り直す。
だが全て前回と同じであれば、前回のshowAandBを再利用する。そうすると、前回と同じ参照の関数を得ることになる。
関数やオブジェクトの等価性は参照が同じかどうかで判定することが多いので、このことはメモ化を利用する上で大きな意味を持つ。

サンプルを少し書き換えて、ChildvalueonClickを渡すようにした。valueは数値だが、onClickは関数である。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState({
    app: 0,
    child: 0,
  });

  const countUpApp = () => {
    setState(s => ({
      app: s.app + 1,
      child: s.child,
    }));
  };

  const countUpChild = () => {
    setState(s => ({
      app: s.app,
      child: s.child + 1,
    }));
  };

  const alertChildState = () => {
    alert(state.child);
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={countUpApp}>
        count up app
      </button>
      <br />
      <button type="button" onClick={countUpChild}>
        count up child
      </button>
      <br />
      App: {state.app}
      <Child value={state.child} onClick={alertChildState} />
    </div>
  );
};

const Child = React.memo(({value, onClick}) => {
  console.log('render Child');

  return (
    <div>
      Child: {value}
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
});

export default App;

ChildReact.memoでラップしているが、count up appを押下したときも再レンダーされてしまう。

f:id:numb_86:20191220214201g:plain

原因はprops.onClickにある。 count up app押下時にpropsshallow compareすると、valueは等価であると判断される。しかし、onClickとして渡されているalertChildStateは、Appが再レンダーされる度に新しく作られるため、参照が異なり、常に等価性がないと判断されてしまう。

このようなケースで、useCallbackが役に立つ。前述のように同じ参照の関数を返すので、React.memoが等価性があると判断するようになる。 alertChildStateの定義を以下のように書き換えると、state.childが変わったときにのみ関数を作り直すようになる。

// import React, {useState, useCallback} from 'react'; として useCallback をインポートしておくことを忘れない

const alertChildState = useCallback(() => {
  alert(state.child);
}, [state.child]);

これで、state.childが変わった時、つまりcount up childが押下された時にのみ、Childが再レンダーされるようになった。

f:id:numb_86:20191220214314g:plain

React.memo によるメモ化の影響範囲

React.memoで再レンダーをスキップすると、そのコンポーネントの子コンポーネントの再レンダーもスキップされる。

まずは、React.memoを使わない場合を見てみる。
Appが持っているstateを、Child、そしてGrandchildと受け渡している。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState(0);

  const onClick = () => {
    setState(s => s + 1);
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      App
      <Child value={state} />
    </div>
  );
};

const Child = ({value}) => {
  console.log('render Child');

  return (
    <div>
      Child
      <Grandchild value={value} />
    </div>
  );
};

const Grandchild = ({value}) => {
  console.log('render Grandchild');

  return <div>{value}</div>;
};

export default App;

当然、ボタンを押す度に全てのコンポーネントが再レンダーされる。

f:id:numb_86:20191220214357g:plain

だが、React.memoを使ってChildの再レンダーを常にスキップするようにすると、ChildだけでなくGrandchildの再レンダーも行われなくなることを確認できる。

const Child = React.memo(
  ({value}) => {
    console.log('render Child');

    return (
      <div>
        Child
        <Grandchild value={value} />
      </div>
    );
  },
  () => true // 検証用のコードであり、areEqual が常に true や false を返す書き方は通常はしない
);

f:id:numb_86:20191220214444g:plain

さらに検証してみると、GrandchildReact.memoでラップしても、そのReact.memoは実行されていない。

const Child = React.memo(
  ({value}) => {
    console.log('render Child');

    return (
      <div>
        Child
        <Grandchild value={value} />
      </div>
    );
  },
  () => {
    console.log("Child's areEqual");
    return true;
  }
);

const Grandchild = React.memo(
  ({value}) => {
    console.log('render Grandchild');

    return <div>{value}</div>;
  },
  () => {
    console.log("Grandchild's areEqual");
    return false;
  }
);

f:id:numb_86:20191220214507g:plain

Childの場合はareEqualを実行した上で再レンダーするかどうかを決めているが、Grandchildの場合はareEqualの実行自体が行われていない。
このことから、React.memoで再レンダーをスキップすると、その時点でその下のコンポーネントツリーの処理が全てスキップされるのだと思われる。

参考資料