React アプリケーション開発では、すべてをメモ化でカバーするというアプローチが広く採用されています。多くの開発者がこの最適化手法を自由に適用し、コンポーネントをメモ化でラップして不要な再レンダリングを防止しています。表面的には、これはパフォーマンスを向上させる確実な戦略のように見えます。しかし、 Social Discovery Groupチームは、メモ化を誤って適用すると、予期しない方法でメモ化が機能しなくなり、パフォーマンスの問題につながる可能性があることを発見しました。この記事では、メモ化が失敗する意外な場所と、React アプリケーションでこれらの隠れた罠を回避する方法について説明します。
メモ化は、プログラミングにおける最適化手法であり、コストのかかる操作の結果を保存し、同じ入力に再び遭遇したときにそれらを再利用します。メモ化の本質は、同じ入力データに対する冗長な計算を避けることです。この説明は、従来のメモ化に当てはまります。コード例では、すべての計算がキャッシュされていることがわかります。
const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }
メモ化には、パフォーマンスの向上、リソースの節約、結果のキャッシュなど、いくつかの利点があります。ただし、React のメモ化は新しいプロパティが入力されるまで機能するため、最後の呼び出しの結果のみが保存されます。
const prev = { value: null, result: null } function calculate(a) { if (prev.value === a) { return prev.result } prev.value = a prev.result = a * a return prev.result }
React ライブラリは、メモ化のためのツールをいくつか提供しています。これらは、HOC React.memo、フック useCallback、useMemo、useEvent、および React.PureComponent とクラス コンポーネントのライフサイクル メソッド shouldComponentUpdate です。最初の 3 つのメモ化ツールを調べ、React での目的と使用方法を調べてみましょう。
リアクトメモ
この高階コンポーネント (HOC) は、最初の引数としてコンポーネントを受け入れ、2 番目の引数としてオプションの比較関数を受け入れます。比較関数を使用すると、以前のプロパティと現在のプロパティを手動で比較できます。比較関数が指定されていない場合、React はデフォルトで浅い等価性を使用します。浅い等価性は表面レベルの比較のみを実行することを理解することが重要です。したがって、プロパティに非定数参照の参照型が含まれている場合、React はコンポーネントの再レンダリングをトリガーします。
const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })
React.useCallback
useCallback フックを使用すると、最初のレンダリング中に渡された関数への参照を保持できます。後続のレンダリングでは、React はフックの依存関係配列の値を比較し、依存関係が変更されていない場合は、前回と同じキャッシュされた関数参照を返します。つまり、useCallback は、依存関係が変更されるまでレンダリング間で関数への参照をキャッシュします。
const callback = useCallback(() => { // do something }, [a, b, c])
React.useMemo
useMemo フックを使用すると、レンダリング間の計算結果をキャッシュできます。通常、useMemo は、高価な計算をキャッシュするだけでなく、メモ HOC でラップされた他のコンポーネントに渡すときやフックの依存関係として渡すときにオブジェクトへの参照を保存するために使用されます。
const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])
React 開発チームでは、包括的なメモ化が広く実践されています。このアプローチには通常、次のものが含まれます。
しかし、開発者は、この戦略の複雑さや、メモ化がいかに簡単に危険にさらされるかを常に理解しているわけではありません。メモ HOC でラップされたコンポーネントが予期せず再レンダリングされることは珍しくありません。Social Discovery Group では、同僚が「すべてをメモ化しても、メモ化しない場合に比べてコストはそれほど高くない」と主張するのを耳にしました。
メモ化の重要な側面を誰もが完全に理解しているわけではないことに私たちは気づきました。メモ化は、プリミティブがメモ化されたコンポーネントに渡された場合にのみ、追加の調整なしで最適に機能します。
このような場合、コンポーネントは、プロパティ値が実際に変更された場合にのみ再レンダリングされます。プロパティ内のプリミティブは適切です。
2 番目のポイントは、props で参照型を渡す場合です。React には魔法は存在しないことを覚えて理解することが重要です。React は JavaScript のルールに従って動作する JavaScript ライブラリです。propsの参照型 (関数、オブジェクト、配列) は危険です。
例えば:
const a = { c: 1 } const b = { c: 1 } a === b // false First call: MemoComponent(a) Second call: MemoComponent(b) const MemoComponent = memo(({object}) => { return <div /> }, (prevProps, props) => (prevProps.object === props.object)) // false
オブジェクトを作成した場合、同じプロパティと値を持つ別のオブジェクトは、参照が異なるため、最初のオブジェクトと等しくありません。
コンポーネントのその後の呼び出しで同じオブジェクトのように見えるが、実際には異なるオブジェクト (参照が異なるため) を渡した場合、React が使用する浅い比較ではこれらのオブジェクトが異なるものとして認識されます。これにより、メモでラップされたコンポーネントの再レンダリングがトリガーされ、そのコンポーネントのメモ化が解除されます。
メモ化されたコンポーネントで安全な操作を確保するには、memo、useCallback、useMemo を組み合わせて使用することが重要です。これにより、すべての参照型に定数参照が設定されます。
チームワーク: memo、useCallback、useMemo
上で説明したことはすべて論理的でシンプルに聞こえますが、このアプローチで作業するときに起こりがちな最も一般的な間違いを一緒に見てみましょう。いくつかは微妙なものかもしれませんが、いくつかは少し無理があるかもしれませんが、完全なメモ化で実装したロジックが壊れないようにするためには、それらを認識し、そして最も重要なことに、理解する必要があります。
メモ化へのインライン化
まず、典型的な間違いから始めましょう。親コンポーネントの後続の各レンダリングでは、params で渡されるオブジェクトへの参照が常に新しいため、メモ化されたコンポーネント MemoComponent が常に再レンダリングされます。
const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }
この問題を解決するには、前述の useMemo フックを使用するだけで十分です。これで、オブジェクトへの参照は常に一定になります。
const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }
あるいは、配列に常に静的データが含まれている場合は、これをコンポーネント外部の定数に移動することもできます。
const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }
この例で似たような状況は、メモ化せずに関数を渡すことです。この場合、前の例と同様に、MemoComponent のメモ化は、親コンポーネントの各レンダリングで新しい参照を持つ関数を渡すことによって解除されます。その結果、MemoComponent は、メモ化されていないかのように新たにレンダリングされます。
const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }
ここで、useCallback フックを使用して、親コンポーネントのレンダリング間で渡された関数への参照を保持できます。
const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }
メモを取りました。
また、useCallback では、別の関数を返す関数を渡すのを妨げるものは何もありません。ただし、このアプローチでは、関数 `someFunction` がレンダリングごとに呼び出されることを覚えておくことが重要です。`someFunction` 内で複雑な計算を避けることが重要です。
function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }
次によくある状況は、props の拡散です。コンポーネントのチェーンがあるとします。InitialComponent から渡された data prop が、このチェーン内の一部のコンポーネントにとって不要になる可能性がある範囲をどのくらい頻繁に考慮しますか? この例では、この prop は、InitialComponent がレンダリングされるたびに値が常に変化するため、ChildMemo コンポーネントのメモ化を破壊します。メモ化されたコンポーネントのチェーンが長くなる可能性がある実際のプロジェクトでは、常に値が変化する不要な props が渡されるため、すべてのメモ化が破壊されます。
const Child = () => {} const ChildMemo = React.memo(Child) const Component = (props) => { return <ChildMemo {...props} /> } const InitialComponent = (props) => { // The only component that has state and can trigger a re-render return ( <Component {...props} data={Math.random()} /> ) }
安全のために、メモ化されたコンポーネントに必要な値のみが渡されるようにしてください。その代わりに、次の操作を行います。
const Component = (props) => { return <ChildMemo {...props} /> }
使用方法(必要なプロパティのみを渡します):
const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )
次の例を考えてみましょう。よくある状況としては、JSX を子として受け入れるコンポーネントを作成する場合です。
const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }
一見無害に思えますが、実際はそうではありません。メモ化されたコンポーネントに JSX を子として渡すコードを詳しく見てみましょう。この構文は、この `div` を `children` という名前のプロパティとして渡すための糖衣構文に他なりません。
子は、コンポーネントに渡す他のプロパティと何ら変わりありません。この場合、JSX を渡しています。JSX は、`createElement` メソッドの構文糖なので、基本的には、`div` 型の通常のオブジェクトを渡しています。ここでは、メモ化されたコンポーネントの通常のルールが適用されます。メモ化されていないオブジェクトがプロパティに渡された場合、そのコンポーネントは親がレンダリングされるときに再レンダリングされます。これは、このオブジェクトへの参照が毎回新しくなるためです。
このような問題の解決策は、メモ化されていないオブジェクトの受け渡しに関するブロックで、少し前に説明しました。したがって、ここでは、渡されるコンテンツは useMemo を使用してメモ化することができ、それを ChildMemo の子として渡しても、このコンポーネントのメモ化は壊れません。
const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }
もっと興味深い例を考えてみましょう。
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }
一見、無害に思えます。2 つのコンポーネントがあり、どちらも記憶されています。ただし、この例では、子である ChildMemo が記憶されていないため、ParentMemo はメモにラップされていないかのように動作します。ChildMemo コンポーネントの結果は JSX になりますが、JSX はオブジェクトを返す React.createElement の単なる構文糖です。そのため、React.createElement メソッドが実行されると、ParentMemo と ChildMemo は通常の JavaScript オブジェクトになり、これらのオブジェクトはいかなる形でもメモ化されません。
親メモ | チャイルドメモ |
---|---|
| |
その結果、メモ化されていないオブジェクトが props に渡され、親コンポーネントのメモ化が壊れてしまいます。
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }
この問題に対処するには、渡された子をメモ化して、親 App コンポーネントのレンダリング中にその参照が一定のままであることを保証すれば十分です。
const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }
もう 1 つの危険で暗黙的な領域は、カスタム フックです。カスタム フックは、コンポーネントからロジックを抽出して、コードを読みやすくし、複雑なロジックを隠すのに役立ちます。ただし、データと関数に定数参照があるかどうかも隠されます。私の例では、送信関数の実装はカスタム フック useForm に隠されており、親コンポーネントがレンダリングされるたびにフックが再実行されます。
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };
コードから、メモ化されたコンポーネント ComponentMemo に、submit メソッドをプロパティとして渡すことが安全かどうかを理解できるでしょうか? もちろん、そうではありません。最悪の場合、カスタム フックの実装は次のようになります。
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }
メモ化されたコンポーネントに submit メソッドを渡すと、親コンポーネントのレンダリングごとに submit メソッドへの参照が新しくなるため、メモ化が壊れます。この問題を解決するには、useCallback フックを使用できます。ただし、私が強調したい主なポイントは、実装したメモ化を壊したくない場合は、カスタム フックからのデータを盲目的に使用してメモ化されたコンポーネントに渡すべきではないということです。
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }
他のアプローチと同様に、完全なメモ化は慎重に使用する必要があり、明らかに過剰なメモ化を避けるように努める必要があります。次の例を考えてみましょう。
export function App() { const [state, setState] = useState('') const handleChange = (e) => { setState(e.target.value) } return ( <Form> <Input value={state} onChange={handleChange}/> </Form> ) } export const Input = memo((props) => (<input {...props} />))
この例では、handleChange メソッドを useCallback でラップしたくなります。これは、handleChange を現在の形式で渡すと、handleChange への参照が常に新しいものになるため、Input コンポーネントのメモ化が壊れてしまうためです。ただし、状態が変化すると、新しい値が value プロパティで渡されるため、Input コンポーネントはいずれにしても再レンダリングされます。したがって、handleChange を useCallback でラップしなかったとしても、Input コンポーネントが常に再レンダリングされることを防ぐことはできません。この場合、useCallback の使用は過剰です。次に、コード レビュー中に見られる実際のコード例をいくつか示します。
const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])
2 つの数値を加算したり文字列を連結したりする操作がいかに単純であるか、またこれらの例ではプリミティブが出力として得られることを考慮すると、ここで useMemo を使用するのは意味がないことは明らかです。同様の例をここに示します。
const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])
useMemo の依存関係に基づいて、さまざまな結果が得られますが、繰り返しになりますが、それらはプリミティブであり、内部に複雑な計算はありません。コンポーネントの各レンダリングでこれらの条件のいずれかを実行すると、useMemo を使用するよりもコストがかかりません。
アプリケーション コードのパフォーマンスを測定するには、少なくとも 4 つのツールをお勧めします。
React.プロファイラー
と
React 開発者ツール
React レンダリングトラッカー
もう一つの興味深いツールは
storybook-addon-performance アドオンを使用した Storybook。
また、Storybookでは、興味深いプラグインをインストールすることができます。
**執筆者:Sergey Levkovich、Social Discovery Group シニア ソフトウェア エンジニア