useCallbackは、本当にパフォーマンスを向上させる?
始め
すべての始まりは「When to useMemo and useCallback」という記事でした。これを見て「あれ?useCallbackってパフォーマンス向上するやつじゃなかったの?」混乱し、いろいろリサーチしたので、整理したいと思います。
1. 基礎知識
- Reactコンポーネントは自分のstateが変更された場合、渡されるpropsが変更された場合に再レンダリングされる。
- 親コンポーネントが再レンダリングされると子コンポーネントも一緒に再レンダリングされる。この時子コンポーネントが最適化されていなかったら、親から渡されるpropsに変更がなくても基本的に再レンダリングされる。
- コンポーネントが再レンダリングされると、その中で宣言されている関数や変数は以前保存されていたメモリを空けて新しいメモリに再び保存される(garbage collection)。
2. useCallback
useCallback
はメモ化されたコールバックを返すHookです。
-
メモ化とは?
コストが高い呼び出しの結果を保存し、同じ入力が再び発生したときにキャッシュされた結果を返すことによってプログラム実行速度を向上させる技術
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
インラインのコールバックとそれが依存している値の配列を渡してください。useCallback はそのコールバックをメモ化したものを返し、その関数は依存配列の要素のいずれかが変化した場合にのみ変化します。
早速useCallback
の公式ドキュメントからサンプルコードと説明を持ってきました。
このサンプルコードだと、第1引数で渡してる関数はa
とb
が変更される時だけメモ化に変更が起きるとうことですね。空の配列[]
にしたら最初にレンダリングされる時だけメモ化されます。useEffect
に似てる構造でわかりやすいです。
そして今回話したい部分はこの説明の次に出てくる部分です。
これは、不必要なレンダーを避けるために(例えば shouldComponentUpdate などを使って)参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合に便利です。
useCallback
を使う目的が「不必要なレンダーを避けるため」ということがわかります。
大事なのは次の太字の部分です。これは逆に言うと「参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合じゃないと別に要らない」という意味でもあります。
「参照の同一性を見るよう最適化されたコンポーネント」って一体何やねんと思うかもしれませんが、ゆっくり何なのかを話していきましょう。
3. 参照の同一性を見る
まずこれを見てください。
//false
[] === []
{} === {}
(() => {}) === (() => {})
//true
0 === 0
"string" === "string"
true === true
false === false
上も下も同じ値を===
で比較しているのに、上はfalse
で下はtrue
です。なぜか分かりますか?
その理由は、上はオブジェクト型で下はプリミティブ型だからです。Javascriptのデータタイプについては以前投稿した「シャローコピー・ディープコピーとは」で紹介していますので、ご参考ください。
===
で比較する時、プリミティブ型は値が同じかどうかを見ます。つまり見た目が全く同じならtrue
になります。ですが、オブジェクト型はメモリアドレスが同じかどうかを見ます。つまり同じメモリに保存されてなかったら見た目が全く同じでもfalse
になるということです。
参照の同一性を見るということはオブジェクト型を===
で比較した時にtrue
かfalse
かをチェックするという意味でしょう。
ここで最初に出た基礎知識を思い出してみます。
- Reactコンポーネントは自分のstateが変更されたり、親コンポーネントから渡されるpropsが変更された場合再レンダリングされる。
- コンポーネントが再レンダリングされると、その中で宣言されている関数や変数は以前保存されていたメモリを空けて新しいメモリに再び保存される(garbage collection)。
今説明した浅い比較とこの基礎知識2つを合わせて分かる事実があります。 「When to useMemo and useCallback」記事の例を借りてきて説明します。
const CountButton = function CountButton({ onClick, count }) {
return <button onClick={onClick}>{count}</button>;
};
function DualCounter() {
const [count1, setCount1] = React.useState(0);
const increment1 = () => setCount1(c => c + 1);
const [count2, setCount2] = React.useState(0);
const increment2 = () => setCount2(c => c + 1);
return (
<>
<CountButton count={count1} onClick={increment1} />
<CountButton count={count2} onClick={increment2} />
</>
);
}
ボタンを押したらカウンターの数字が上がる簡単な例ですね。ここで上のCountButton
を押したときに起こることを整理してみましょう。
-
DualCounter
のstateであるcount1
が変更される -
DualCounter
が再レンダリングされる -
DualCounter
の中の変数や関数(count1
、setCount1
、increment1
など)たち全部がもともと保存されてたメモリを空けて新しいメモリに保存される -
CountButton
は引数で渡される変数と関数の変更チェック -
increment1
とincrement2
がオブジェクト型のため、保存されたメモリが変わったこどで新しいやつだと判断 -
CountButton
両方とも再レンダリングされる。
…という一連の過程が起きます。結局一つのCountButton
を押しただけなのにCountButton
が全部再レンダリングされてしまいます。
直接確認してみましょう。分かりやすくするためにCountButton
にconsole.log("Rendered!")
を入れましたので、console窓を見てください。一つのボタンを押しただけでconsoleの数字が2ずつ上がることがわかりますね。
もしこのCountButton
がものすごく重かったり数が1000個ほどあったりしたら性能に良くない影響を与えるかもしれません。
4. 最適化の理由
ここで、「引数で渡す関数をuseCallback
で囲んでメモ化したら、子コンポーネントが同じものだと認識して再レンダリングされないのでは?」と思うかもしれません。やってみましょう。
const CountButton = function CountButton({ onClick, count }) {
return <button onClick={onClick}>{count}</button>;
};
function DualCounter() {
const [count1, setCount1] = React.useState(0);
const increment1 = React.useCallback(() => setCount1(c => c + 1), []);
const [count2, setCount2] = React.useState(0);
const increment2 = React.useCallback(() => setCount2(c => c + 1), []);
return (
<>
<CountButton count={count1} onClick={increment1} />
<CountButton count={count2} onClick={increment2} />
</>
);
}
このようにincrement1
とincrement2
をuseCallback
に囲みました。これで押したボタンだけ再レンダリングされる…でしょうか?
残念ながら、まだすべてのCountButton
たちが再レンダリングされてます。なぜでしょうか?これは基礎知識の2番をみたら答えが出ます。
- 親コンポーネントが再レンダリングされた時も一緒に再レンダリングされる。子コンポーネントが最適化されていなかったら、親から渡されるpropsに変更がなくても基本的に再レンダリングされる。
そうです。今のCountButton
たちは渡される引数に変更がなくても親であるDualCounter
が再レンダリングされたので自分自身も再レンダリングされたのです。これが子コンポーネントを最適化しなければならない理由です。
5. React.memo
子コンポーネントを最適化する方法としてReact.memo
を紹介します。同じく公式ドキュメントからサンプルコードと説明を持ってきました。
const MyComponent = React.memo(function MyComponent(props) {
/* render using props */
});
React.memo
はパフォーマンス最適化のための高階コンポーネント(HOC, higher-order component)です。
高階コンポーネントが何かと言うと、コンポーネントを引数としてもらって新しいコンポーネントを返す関数です。React.memo
の場合は引数としてコンポーネントをもらい、最適化されたコンポーネントを返してくれるでしょう。
説明の続きです。
もしあるコンポーネントが同じ props を与えられたときに同じ結果をレンダーするなら、結果を記憶してパフォーマンスを向上させるためにそれを React.memo でラップすることができます。つまり、React はコンポーネントのレンダーをスキップし、最後のレンダー結果を再利用します。
React.memo は props の変更のみをチェックします。
要するにReact.memo
でコンポーネントを囲んだら、渡された引数に変化があるかどうかチェックして変化がある場合のみ再レンダリングされる機能が追加されます。
const CountButton = React.memo(function CountButton({ onClick, count }) {
return <button onClick={onClick}>{count}</button>
})
function DualCounter() {
const [count1, setCount1] = React.useState(0)
const increment1 = React.useCallback(() => setCount1(c => c + 1), [])
const [count2, setCount2] = React.useState(0)
const increment2 = React.useCallback(() => setCount2(c => c + 1), [])
return (
<>
<CountButton count={count1} onClick={increment1} />
<CountButton count={count2} onClick={increment2} />
</>
)
}
このようにCountButton
をReact.memo
で囲んだらなんと、クリックしたやつだけ再レンダリングされます!これがまさに「参照の同一性を見るよう最適化」ですね。
ここで「useCallback
使わずにReact.memo
だけやっといたらどうなる?」と思うかもしれません。参照の同一性の部分を思い出してみて、useCallback
なしでReact.memo
だけある場合に起きることを整理して見ましょう。
-
DualCounter
が再レンダリングされる -
DualCounter
のなかの関数たちは新しいメモリに保存される - その関数を渡された
React.memo
が以前のやつと比較する - メモリアドレスが変わってるから変更されたやつだと判断する
-
CountButton
も全部再レンダリングされる
という過程で結局この場合もCountButton
たちが全部再レンダリングされてしまいます。
つまり、子コンポーネントが「メモ化された関数(オブジェクト型)を引数に渡される」かつ「自分も最適化もされてる」場合に子コンポーネントの不要な再レンダリングを防げます。
6. 良くない使い方
以前、あるReactチュートリアルで「コンポーネントの中で宣言されてる関数はコンポーネントが再レンダリングされる度に再生成されるからuseCallback
で囲んだほうが良い」と聞いたことがあります。これが良くない使い方です。
私たちは既に上のCountButton
の例でuseCallback
だけ使ったら子コンポーネントの再レンダリングを防げないことを学びました。useCallback
の目的である「不必要なレンダーを避ける」ができてないのによい使い方のはずがないでしょう。
それに関数宣言はコストが安い処理なので、わざわざuseCallback
まで使って防げるべきものではありません。
useCallback
を使うことにもコストがかかります。useCallback
というHookを読み込むし、第2引数で配列の宣言もするし、レンダリングの度にuseCallback
が動きます。useCallback
を使ったほうが得な時もあしますが、場合によっては逆に余計なメモリを食う時だってあります。
7. まとめ
「When to useMemo and useCallback」の著者であるKent C. Doddsはこう言いました。
MOST OF THE TIME YOU SHOULD NOT BOTHER OPTIMIZING UNNECESSARY RERENDERS. React is VERY fast and there are so many things I can think of for you to do with your time that would be better than optimizing things like this.
ほとんどの場合、不要なレンダリングの最適化は気にしなくていいです。Reactは非常に速いし、このようなものを最適化するよりも、他に時間を割いてやるべきことはたくさんあります
そして今まで話した最適化の必要性も極稀と言ってます。彼がPayPalで働いた3年間、そしてそれよりも長いReact歴の間もそいういう最適化が必要な瞬間はなかったようです。
要するにuseCallback
を使うべき瞬間はそんなに多くないということになります。それなのに必要でもないHookを「とりあえず入れとこう」という気持ちで使うことはやめたほうがいいでしょう。
終わり
今回はuseCallback
に集中して話しましたが、useMemo
も使ったほうが得な時だけ!使いましょう。公式ドキュメントのサンプルコードでもuseMemo
に渡す関数の名前がcomputeExpensiveValue
なぐらいです。
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
難しい内容でしたが、知らなかったことをたくさん勉強できてうれしいです🤗
Discussion