
ReactでuseMemoを使うとき、使わないとき
useMemoとは?
useMemoは、Reactが提供するフックの一つです。
このフックを使うと、開発者は変数の値を依存関係のリストとともにキャッシュすることができます。
この依存関係リストの変数が変更された場合、Reactはこのデータに対する処理を再実行し、再キャッシュします。
依存関係リスト内の変数の値が以前にすでにキャッシュされていた場合、Reactはキャッシュから値を取得します。
これは主にコンポーネントの再レンダリングに影響を与えます。
コンポーネントが再レンダリングされるとき、配列をループさせたり、データを何度も処理したりする代わりに、
キャッシュから値を取得出来るところが、useMemoの利点なわけです。
ReactはuseMemoについて何と言っているのか
useMemoに関するReactのドキュメントを見てみると、それがいつ使われるべきかについては言及されていません。
単に何をするのか、どのように使用するのかが書かれています、つまり、パフォーマンスの最適化としてuseMemoを利用することができるよと言ってるわけです。
ここで問題となるのは、useMemo がどの時点で役に立つのかということです。
useMemo を使用することでパフォーマンス上のメリットを得るためには、データはどれくらい複雑で大きくなければならないのか?開発者が実際に useMemo を使うべきタイミングがよくわかりませんよね。
仮説
実験を始める前に、仮説を立ててみましょう。
まず、実行したいオブジェクトと処理の複雑さをnとします。n=100の場合、memo-ed変数の最終値を得るために、
100個のアイテムの配列をループする必要があります。
また、2つのアクションを分ける必要があります。1つ目のアクションは、コンポーネントの初期レンダリングです。
この場合、どちらも初期値を計算する必要があります。最初のレンダリングが完了すると、その後の再レンダリング(測定が必要な 2 番目のアクション)で useMemo を使用すると、
キャッシュから値を取得することができ、memo を使用しないバージョンと比較してパフォーマンス上の利点が確認できます。
いずれの場合も、最初のレンダリングでは、メモキャッシュの設定と値の保存のために、約 5~10% のオーバーヘッドが発生すると予想されます。
n < 1000 の場合、useMemo ではパフォーマンスの低下が予想されます。n > 1000 の場合、useMemo を使用した再レンダリングでは、同等またはそれ以上のパフォーマンスが期待できますが、余分なキャッシング アルゴリズムのため、最初のレンダリングはまだ若干遅くなるはずです。
ベンチマークの設定
以下のように小さなReactコンポーネントを設定し、説明通りに複雑さnのオブジェクトを生成します。複雑さはプロップレベルとして定義されています。
import React from 'react';
const BenchmarkNormal = ({level}) => {
const complexObject = {
values: []
};
for (let i = 0; i <= level; i++) {
complexObject.values.push({ 'mytest' });
}
return (
<div>Benchmark level: {level}</div>
);
};
export default BenchmarkNormal;
これは通常のベンチマークコンポーネントですが、useMemo用のベンチマークコンポーネント、BenchmarkMemoも作ってみましょう。
import React, {useMemo} from 'react';
const BenchmarkMemo = ({level}) => {
const complexObject = useMemo(() => {
const result = {
values: []
};
for (let i = 0; i <= level; i++) {
result.values.push({'mytest'});
};
return result;
}, [level]);
return (
<div>Benchmark with memo level: {level}</div>
);
};
export default BenchmarkMemo;
そして、ボタンを押したときにこれらのコンポーネントが表示されるように、App.jsで設定します。
また、Reactの<Profiler>を使って、レンダリング時間を表示しています。
function App() {
const [showBenchmarkNormal, setShowBenchmarkNormal] = useState(false);
// Choose how many times this component needs to be rendered
// We will then calculate the average render time for all of these renders
const timesToRender = 10000;
// Callback for our profiler
const renderProfiler = (type) => {
return (...args) => {
// Keep our render time in an array
// Later on, calculate the average time
// store args[3] which is the render time ...
};
};
// Render our component
return <p> {showBenchmarkNormal && [...Array(timesToRender)].map((index) => {
return <Profiler id={`normal-${index}`} onRender={renderProfiler('normal')}>
<BenchmarkNormal level={1} />
</Profiler>;
})}
</p>;
}
ご覧のとおり、コンポーネントを 10,000 回レンダリングして、その平均レンダリング時間を取得しています。
ここで、必要に応じてコンポーネントの再レンダリングをトリガーするメカニズムが必要になりますが、useMemo を再計算する必要はありません。
したがって、useMemo の依存関係リストの値は変更しないやり方をしたいので、このようにします。
// Add a simple counter in state
// which can be used to trigger re-renders
const [count, setCount] = useState(0);
const triggerReRender = () => {
setCount(count + 1);
};
// Update our Benchmark component to have this extra prop
// Which will force a re-render
<BenchmarkNormal level={1} count={count} />
結果をクリーンに保つために、テストを開始する前に(再レンダリングを除く)、常に新しいウェブブラウザのページから始めて、ページに残っていて結果に影響を与えている可能性のあるキャッシュを消去します。
結果
複雑さn=1の場合の結果
最初のテストは最初のレンダリング、2 番目のテストは 1 回目の再レンダリング、最後のテストは 2 回目の再レンダリングとなっています。
2 番目の列は,useMemo を使用しない通常のベンチマークの結果を示しています。最後の列は,useMemo を使用した場合のベンチマークの結果を示しています。
これらの値は,ベンチマークコンポーネントの 10,000 回のレンダリングを行った際の平均レンダリング時間です。
useMemo を使用した場合,最初のレンダリングが 19% 遅くなり,これは予想された 5 ~ 10% よりもはるかに高い値です。
useMemo のキャッシュを参照するオーバーヘッドは、実際の値を再計算するよりもコストがかかるため、その後のレンダリングはさらに遅くなります。
結論として、複雑さが n=1 の場合は、常に useMemo を使用しない方が速くなります。
複雑さn=100の場合の結果
複雑度が 100 の場合、useMemo を使用した最初のレンダリングは 62% 遅くなり、これはかなりの量です。その後の再レンダリングは、平均してわずかに速いか、似たようなものになるようです。
結論として、複雑さが 100 の場合、最初のレンダリングは大幅に遅くなりますが、その後の再レンダリングはほぼ同じで、せいぜいわずかに速い程度です。この時点では、useMemo はまだ使っても意味をなさないようです。
複雑さn = 1000の場合の結果
複雑さが 1000 の場合、useMemo を使用した最初のレンダリングは 183% 遅くなることがわかります。 これは、useMemo キャッシュが値を保存するために、より多くの作業を行っているためと思われます。その後のレンダリングでは、約 37% も速くなっています。
この時点で、再レンダリング時のパフォーマンスが向上していることがわかりますが、コストがかからないわけではありません。最初のレンダリングは非常に遅く、183%もの時間ロスがあります。
結論として、複雑さが1000の場合、最初のレンダリングでは183%と大きなパフォーマンスの低下が見られますが、その後のレンダリングでは約37%速くなっています。
これが面白いかどうかは、ユーザーの使用状況に大きく依存します。最初のレンダリング時に183%のパフォーマンス低下があるというのは難しい話ですが、コンポーネント内で多くの再レンダリングが行われる場合には、正当化できるかもしれません。
複雑さn = 5000での結果
複雑度が 5000 の場合、useMemo を使用すると初期レンダリングが 545% 遅くなることがわかります。データと処理の複雑さが増すほど、useMemo を使用した場合の最初のレンダリングは、useMemo を使用しない場合に比べて遅くなるようです。
興味深いのは、その後のレンダリングを見たときです。ここでは、後続のレンダリングごとに useMemo を使用することで 437% ~ 609% のパフォーマンスの向上が見られます。
結論として、最初のレンダリングでは useMemo を使用した方がはるかにコストがかかりますが、その後の再レンダリングではさらに大きなパフォーマンスの向上が見られます。
アプリケーションのデータ/処理の複雑さが 5000 を超え、再レンダリングが数回ある場合は、useMemo を使用することの利点がわかります。
useMemoに関してわかったこと
- 余分な処理を避けるために useMemo が有効になる閾値は、アプリケーションによって大きく異なります。
- 処理量が非常に少ない場合に useMemo を使用すると、余分なオーバーヘッドが発生する可能性があります。
おわりに
useMemo を使用すべきかどうかは、ユースケースによって大きく異なりますが、複雑さが 100 未満の場合、useMemo はほとんど興味をそそられません。
useMemo を使用した最初のレンダリングでは、パフォーマンスの面でかなりの後退があったことは注目に値します。当初は一貫して 5~10% 程度のパフォーマンスの低下を予想していましたが、データや処理の複雑さに大きく依存し、予想の 100 倍に当たる 500% のパフォーマンス低下を引き起こすこともあることがわかりました。
結果が出た後も何度かテストをやり直しましたが、その後の結果は非常に一貫しており、メモした最初の結果と同様でした。
主な収穫
useMemo は、変数の同じオブジェクト参照を維持することで、不要な再レンダリングを回避するのに便利であることは誰もが認めるところです。
useMemo を実際の計算をキャッシュするために使用する場合、主な目的はサブコンポーネントでの再レンダリングを避けることではありません。
参考にした記事