Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
38
Help us understand the problem. What are the problem?
@seira

【React】もっと速くなる!?React.memo, useCallBack, useMemoでパフォーマンス最適化に挑戦!

Ateam Lifestyle Advent Calendar 2020 の14日目はフロントエンドデザイナー@seiraが担当します。

年末恒例アドベントカレンダーの季節がやってまいりました :santa_tone2:
最近パフォーマンス最適化に関する業務に関わることが多くなってきたことから、今年は「Reactでパフォーマンスを最適化するには」をテーマに記事を書きたいと思います。

React.memo, useCallBack, useMemo

不要な再描画や再計算をスキップすることでReactでパフォーマンスの最適化に挑戦するために、下記の3つを利用します。

  • React.memo
  • useCallBack
  • useMemo

メモ化って何?

「メモ化」というワードが頻繁に出てくるので、先に解説をしておきます。
メモ化とは、

  1. 同じ結果を返す処理について初回のみ処理を実行して記録する
  2. 値が必要となった2回目以降は、保持していた計算結果を再利用する

都度計算する必要がなくなるためパフォーマンス向上が期待できます。

React.memoでパフォーマンス最適化に挑戦してみる

React.memoって何?

React.memoはpropsの変更のみをチェック、コンポーネントが返した描画結果を記録してメモ化します。

再レンダーされそうになった場合↓

  1. 本当に再レンダーが必要かどうかをチェック
  2. メモ化によりコンポーネントの再描画をスキップ
  3. 必要な場合のみ再レンダー

デフォルトでは、等価性の判断にshallow compare(オブジェクトの浅い比較)を使っており、オブジェクトの1階層のみを比較することになります。
またReact.memoでコンポーネントをラップしていても、その実装内でuseStateやuseContext利用している場合、その変化に応じた再描画は発生します。

レンダリングコストが高いコンポーネントや、頻繁に再レンダリングされるコンポーネントの子コンポーネントの場合以外で利用しても大きなパフォーマンス効果を得られることはありません。

基本形

React.memoは、メモ化したいコンポーネントをラップして使います。
メモ化するコンポーネントは、親コンポーネントからpropsを受け取る子コンポーネントです。

React.memo(親コンポーネントからpropsを受け取る子コンポーネント)

React.memoを利用してみる

propsが渡されるCounterコンポーネントをReact.memoでラップした場合。

import React, { useState } from "react";

//Countコンポーネント(子)
//親コンポーネントのボタンがクリックされて親コンポーネントのもつcountStateが更新されたらレンダリングされる
//textとcountStateのpropsをAppコンポーネント(親)から受け取っているコンポーネントをReact.memoでラップ
const Count = React.memo(({ text, countState }) => {
  console.log("Count child component:", text);
  return (
    <p>
      {text}:{countState}
    </p>
  );
});

//Appコンポーネント(親)
export default function App() {
  const [countStateA, setCountStateA] = useState(0);
  const [countStateB, setCountStateB] = useState(0);

  //Aボタンのstateセット用関数
  const incrementACounter = () => setCountStateA(countStateA + 1);

  //Bボタンのstateセット用関数
  const incrementBCounter = () => setCountStateB(countStateB + 1);

  return (
    <>
      <Count text="A ボタン" countState={countStateA} />
      <Count text="B ボタン" countState={countStateB} />
      <button onClick={incrementACounter}>A ボタン</button>
      <button onClick={incrementBCounter}>B ボタン</button>
    </>
  );
}

親コンポーネントが更新されると、子コンポーネントも再描画されます。
React.memoでコンポーネントをラップすることで、コンポーネントが返した描画結果はメモ化されているので、前回のpropsと変更差分があった場合のみコンポーネントを再描画します。

AボタンBボタンどちらかをクリックして、数字が更新されたコンポーネントのみ再描画されているのがわかります。

React.memoでラップしない場合、コンポーネントの再描画はどうなるのかを試してみます。

//...省略

// const Count = React.memo({ text, countState }) => {
//   console.log("Count child component:", text);
//   return (
//     <p>
//       {text}:{countState}
//     </p>
//   );
// });
//React.memoでCountコンポーネントをラップしないように変更してみる
const Count = ({ text, countState }) => {
  console.log("Count child component:", text);
  return (
    <p>
      {text}:{countState}
    </p>
  );
};

//...省略

AボタンBボタンどちらかクリックして数字が更新されていないほうのコンポーネントも一緒に再描画されているのがわかります。

これはReactの通常の挙動なので、大きな問題はありませんが、不要な再描画コスト高い場合などパフォーマンス改善の必要が発生した場合に、React.memoを利用することが出来ることを覚えておけば良さそうです。

useCallbackとReact.memoを組み合わせてパフォーマンス最適化に挑戦してみる

React.memoでコンポーネントをラップすることにより、コンポーネントをメモ化し、不要な再描画をスキップ出来ることがわかりました。

ただし、React.memoを利用した場合でも、親コンポーネントからコールバック関数をpropsとして受け取った子コンポーネントは再描画されてしまいます。関数の内容が同じだとしても、コンポーネントが再描画される度に再生成されるので、等価ではないからです。

関数のメモ化に利用出来るのが、メモ化されたコールバック関数を返すuseCallbackです。

useCallbackって何?

useCallbackでメモ化したコールバック関数を返すことによりパフォーマンス向上が期待できます。
React.memoでラップしたコンポーネントに、メモ化したコールバック関数をpropsとして渡すことで、不要に再描画させません。

基本形

useCallbackは、メモ化したい関数をラップします。
useEffectと同じように、依存配列(=[deps] コールバック関数が依存している要素が格納された配列)の要素のいずれかが変化した場合のみ、メモ化した値を再計算します。

//[deps]は依存配列
useCallback(callbackFunction, [deps]);

callbackFunctionを定義してみます

//callbackFunctionはコンポーネントが再レンダーされる度に作り直されるが、a,bに変化がない限りその必要はない
const callbackFunction = () => {doSomething(a,b)}

//useCallBack利用で、依存配列[a,b]のどちらかが変化した場合にのみ、以前に作ってメモ化したcallbackFunctionの値を再計算する。
//a,bのどちらも値が変わらなければ、前回のcallbackFunctionを再利用する。
const callbackFunction = useCallback(() => doSomething(a,b),[a,b])

まずはpropsが渡される子コンポーネントをReact.memoでラップしてみる

import React, { useState } from "react";

//Buttonコンポーネント(子)
//React.memoでラップしている
const Button = React.memo(({ counterState, buttonValue }) => {
  console.log("Button child component:", buttonValue);
  return <button onClick={counterState}>{buttonValue}</button>;
});

//Appコンポーネント(親)
export default function App() {
  const [countStateA, setCountStateA] = useState(0);
  const [countStateB, setCountStateB] = useState(0);

  //Aボタンのstateセット用関数
  const incrementACounter = () => setCountStateA(countStateA + 1);

  //Bボタンのstateセット用関数
  const incrementBCounter = () => setCountStateB(countStateB + 1);

  //Buttonコンポーネント(子)を呼び出す
  return (
    <>
      <p>A ボタン: {countStateA} </p>
      <p>B ボタン: {countStateB} </p>
      <Button counterState={incrementACounter} buttonValue="Aボタン" />
      <Button counterState={incrementBCounter} buttonValue="Bボタン" />
    </>
  );
}

React.memoでpropsを受け取る子コンポーネントをラップしただけでは、Aボタン、Bボタンのうち、クリックされていないほうのボタンコンポーネントも再描画されてしまっていることが分かります。

今度はuseCallbackで関数をラップし、propsが渡される子コンポーネントをReact.memoでラップしてみる

import React, { useState, useCallback } from "react";

//Buttonコンポーネント(子)
const Button = React.memo(({ counterState, buttonValue }) => {
  console.log("Button child component:", buttonValue);
  return <button onClick={counterState}>{buttonValue}</button>;
});

//Appコンポーネント(親)
export default function App() {
  const [countStateA, setCountStateA] = useState(0);
  const [countStateB, setCountStateB] = useState(0);

  //Aボタンのstateセット用関数
  //useCallbackでラップし、依存配列にountStateAを渡して、前回と差分があるかをみる
  //const incrementACounter = () => setCountStateA(countStateA + 1);
  const incrementACounter = useCallback(() => setCountStateA(countStateA + 1), [
    countStateA
  ]); //useCallbackでラップし、依存配列にountStateBを渡して、前回と差分があるかをみる

  //Bボタンのstateセット用関数
  //const incrementBCounter = () => setCountStateB(countStateA + 1);
  const incrementBCounter = useCallback(() => setCountStateB(countStateB + 1), [
    countStateB
  ]);

  //Buttonコンポーネント(子)を呼び出す
  return (
    <>
      <p>A ボタン: {countStateA} </p>
      <p>B ボタン: {countStateB} </p>
      <Button counterState={incrementACounter} buttonValue="Aボタン" />
      <Button counterState={incrementBCounter} buttonValue="Bボタン" />
    </>
  );
}

AボタンBボタンどちらかをクリックすると、数字が更新されたコンポーネントのみ再描画されているのがわかります:heart_eyes:

useCallbackでメモ化されたコールバック関数は、React.memoでメモ化されたコンポーネントへ渡して利用することで初めて不要な再描画をスキップ出来るようになります。

React.memoとuseCallbackがそれぞれ何をしてくれるのか、役割をきちんと理解しておけば、使うべきところで使っていけそうです。

useMemoでパフォーマンス最適化に挑戦してみる

useMemoって何?

useMemoは値を保存するためのhookで、何回やっても結果が同じ場合の値などを保存(メモ化)し、そこから値を再取得するので、レンダー毎に行われる高価な計算を避けることができます。
useMemoを利用し、不要な再計算をスキップすることで、パフォーマンスの向上が期待出来ます。

useCallbackは関数自体をメモ化しますが、useMemoは関数の結果をメモ化し、保持してくれます。

React公式のuseMemoのページに以下の記述があったので、覚えておくようにします。

useMemo はパフォーマンス最適化のために使うものであり、意味上の保証があるものだと考えないでください。
将来的に React は、例えば画面外のコンポーネント用のメモリを解放するため、などの理由で、メモ化された値を「忘れる」ようにする可能性があります。useMemo なしでも動作するコードを書き、パフォーマンス最適化のために useMemo を加えるようにしましょう。

基本形

//valueCalculateLogic(a,b)は計算ロジック
//[a,b]は依存配列
useMemo(() => valueCalculateLogic(a,b), [a,b]);

依存配列が空の場合の例

依存配列が空の場合、依存配列へ空配列を渡すと何にも依存しないことから、初回1回のみ実行。
つまり、依存関係が変わらない場合はキャッシュから値(関数の結果の値)を取ってきます。

const memoResult = useMemo(() => hogeMemoFunc(), [])

依存配列が空ではない場合の例

依存配列が空ではない場合、例えばcountに変更があった場合のみ、memoResult関数を再実行させたい場合は以下のように書きます。

//依存配列に並べた要素(=hogeMemoFuncの計算に必要な要素)に変更があった場合にのみ、値が再計算される
const memoResult = useMemo(() => hogeMemoFunc(count + 1), [count])

useMemoを利用してみる

import React, { useState, useMemo } from "react";

const square = (parameter) => {
  console.log("square関数の実行観察");
  //正方形の面積を求める関数を定義する
  //パフォーマンスを観察したいので、わざと重い処理を置いてみる
  let i = 0
  while (i < 20000000) i++
  return parameter * parameter;
};

export default function App() {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

  // 1ずつカウントが増える足し算A
  const resultA = () => {
    return setCountA(countA + 1);
  };

  // 1ずつカウントが増える足し算B
  const resultB = () => {
    return setCountB(countB + 1);
  };

  //正方形の面積をcountBを使った計算結果
  //useMemoを使って、計算結果をメモ化している
  //第2引数である依存配列にcountBを渡しているので、countAを更新しても、countBが更新されなければメモ化された描画結果を再利用するためsquare関数は更新されない
  const squareArea = useMemo(() => square(countB), [countB]);

  return (
    <>
      <p>
        計算結果A: {countA} <button onClick={resultA}>計算A + 1</button>
      </p>
      <p>【正方形の面積】</p>
      <p>
        計算結果B: {countB} <button onClick={resultB}>計算B + 1</button>
      </p>
      <p>計算結果B ✕ 計算結果B = {squareArea}</p>
    </>
  );
}

resultA関数が実行されることによるコンポーネントが再描画されたタイミングでは、square関数は実行されていないことが分かります。
今回の例ではsquare関数がかなり重い処理になっているため、square関数を不要に実行させないことで、大きくパフォーマンス向上させることが期待できます。

useMemoを利用しない場合

//...省略
//square関数はコンポーネントが再描画される度に実行されてしまうため、countAを実行したときにも実行されてしまう
//countAを更新したいだけなので、不要な再描画が走っていることになる
//const squareArea = useMemo(() => square(countB), [countB]);
const squareArea = square(countB);

//...省略

resultA関数が実行されたことによるコンポーネントが再描画されたタイミングでもsquare関数が実行されています。
square関数の実行が不要な場合も含め、毎回重い処理が走ってしまうことが分かります。

useMemoは、関数コンポーネント内で、何度やっても結果が同じ場合の「関数による計算の結果」をメモ化出来るという点で、React.memoと大きく異なります。

参照: React公式

最後に

最後まで読んでいただき、ありがとうございました。
パフォーマンス最適化って本当に楽しくてモチベ上がるので大好きです:blush:
React.memo/useCallbak/useMemoの機能と使いどころをきちんと理解した上で、上手に利用して行きたいです。

明日は @ryosuketter が「【図解】1から学ぶ JavaScript の 非同期処理」を習慣化させるために大切なこと」を公開いたいます。どうぞお楽しみに!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
38
Help us understand the problem. What are the problem?