在読む前に、まず react-re-renders-guide を一度読んで、理論的な知識を理解した後に実際のビジネスシーンを通じて深く理解することをお勧めします。
React Devtool と Profiler をうまく活用する#
React devtool には「コンポーネントがレンダリングされるときに更新をハイライトする」という非常に便利な機能があります。この機能を使用すると、再レンダリングされたコンポーネントをハイライト表示できます。再レンダリングが発生すると緑の枠でハイライトされ、短時間に複数回の再レンダリングが発生した場合は黄色の枠で表示され、どのコンポーネントにパフォーマンスの問題があるかを考慮する必要があります。
このツールを使用すると、ページ上のどのコンポーネントに問題があるかを迅速に特定できますが、具体的な問題の所在を特定することはできません。
React コンポーネントの再レンダリングは必ず何らかのフックが原因で発生するため、頻繁に更新されるフックを見つけることが重要です。この時、React Profiler を利用して調査する必要があります。
Profiler タブに切り替え、左上の「Record」をクリックし、再レンダリングを引き起こす操作をトリガーすると、以下のようなページが表示されます。
上の図のように、NodeCards はリストコンポーネントで、NodeCard コンポーネントをループして遍歴しています。NodeCard にマウスをホバーすると、親コンポーネントの NodeCards が再レンダリングされ、子コンポーネントがすべて再レンダリングされるため、このパフォーマンスのオーバーヘッドはかなり大きく、長いリストコンポーネントでは注意が必要です。
Profiler を通じて、NodeCards のフック 136 が変更されたためにこの再レンダリングが発生したことがわかりました。しかし、136 が何であるかはわかりません(React devtool に対する批判)。この時、Components に戻ると、対応するコンポーネントの右側のパネルにフックの対応する番号が表示され、その番号を通じて対応するフックを特定できます。もちろん、ほとんどのフックは他の複数のフックの組み合わせであるため、大まかな判断しかできません。
例えば、上の図から、136 番のフックは実際には Jotai のフックであると判断できます。
リストコンポーネントはどのようにパフォーマンス問題を回避すべきか#
Memo を使用する?#
上の例では、リスト内の一つのコンポーネントの更新が全体のリストの更新を引き起こすのは非常に非効率的です。Profiler のいくつかのフレームを通じて、大部分の NodeCard 自体は更新されておらず、上位の NodeCards だけが更新されていることがわかりました。この時、NodeCard を一層メモ化するために memo を使用できます。これにより、親の更新がリスト要素のすべての更新を引き起こさなくなります。リスト(List)シーンでは、一般的にリスト要素(ListItem)を memo でラップすることが推奨されており、長いリストではこの方法が非常に有益です。リストの状態が絶対に更新されないことが確実でない限り、この方法を使用します。
const NodeCards = () => {
// 一部のフック
return <>
{data.map(item => <NodeCard data={item} />)}
</>
}
const NodeCard = memo((props) => {
return <div />
})
データソースではなく id
を渡す?#
一般的には、リストデータを遍歴する際にデータソースを直接渡すことができますが、React の不変性の特性により、データソースが変更されると、コンポーネントは必ず更新されます。たとえ ListItem 内でデータソースの変更された具体的な値を使用していなくてもです。以下の例を見てみましょう:
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// その他..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// data[0].desc を更新します
useEffect(() => {
setData((data) => ({ ...data, '1': { ...data['1'], desc: 'bar' } }))
}, [])
return <>
{data.map(item => <NodeCard data={item} />)}
</>
}
const NodeCard = memo((props) => {
const { name } = props.data
return <div>{name}</div>
})
NodeCard が desc
を消費しなくても、データが変更されると必ず再レンダリングされます。これは望ましくありません。しかし、もし id
を渡し、セレクタを組み合わせれば、このような非効率的な動作は発生しません。
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// その他..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// ここで状態変更やフックの更新があるかもしれません。
// data[0].desc を更新します
useEffect(() => {
setData((data) => ({ ...data, '1': { ...data['1'], desc: 'bar' } }))
}, [])
return <>
{data.map(item => <NodeCard id={item.id} />)}
</>
}
const NodeCard = memo((props) => {
const { id } = props
const name = useAtomValue(
selectAtom(
dataMapAtom,
useCallback((dataMap) => dataMap.id.name, [])
)
)
return <div>{name}</div>
})
リスト要素の状態はどこで管理すべきか?#
リスト内で、特定の状態に基づいて異なる UI 表現を表示する必要がある場合に直面することがあります。例えば、カードがホバーされたときに、モーションを行ったり、CSS だけでは実現できない他の処理を行う必要がある場合です。例えば:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// 一部のフック
return <>
{data.map(item => <NodeCard data={item} activeId={activeId} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// 処理を行う。
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
上記のコードは非常に間違っています。リスト内では、これらの動的に変化する値をリスト要素に直接渡すべきではありません。例えば、上の書き方では、NodeCard にマウスをホバーすると、すべての NodeCard も再レンダリングされます。
正しい方法は、ブール値を渡し、NodeCards で判断を完了させることです:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// 一部のフック
return <>
{data.map(item => <NodeCard data={item} isActive={activeId === item.id} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// 処理を行う。
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
isActive
に変更することで、このような問題は発生しなくなります。
さらに、もし activeId
を取得したいが、NodeCard が高コストのコンポーネントである場合はどうすればよいでしょうか。方法の一つは Ref を使用することですが、Ref は反応的なデータフローから外れています。もう一つの方法は、Jotai のような状態管理ライブラリを利用して外部状態を持ち、軽量コンポーネントに分割することです。
const activeIdAtom = atom(0)
const NodeCards = () => {
// 一部のフック
return <>
{data.map(item => <NodeCardExpensiveComponent data={item} />)}
</>
}
const NodeCardExpensiveComponent = memo((props) => {
const setActiveId = useSetAtom(activeIdAtom)
// 処理を行う。
return <>
<div onMouseEnter={() => {
props.setActiveId(props.data.id)
}}
/>
{new Array(10000).fill(0).map(() => <div />)}
<ObservedActiveIdHandler />
</>
})
const ObservedActiveIdHandler = () => {
const activeId = useAtomValue(activeIdAtom)
useEffect(() => {
// 処理を行う。
}, [activeId])
}
activeId
の反応的な処理を軽量コンポーネントに分割することで、高コストのコンポーネントに影響を与えず、非常に効率的になります。
この方法は他の最適化にも適用可能です。
Performance 火焰図からパフォーマンス問題を発見する#
Chrome devtools には非常に便利な火焰図ツールがあります。ページが空であるにもかかわらず CPU が大量に使用され、React devtool に再レンダリングされたコンポーネントのハイライトがない場合は、これを使用する必要があります。
火焰図の一部をキャプチャすることで、短時間に CPU 使用率が高すぎる問題を調査できます。上の図の火焰図は非常に密集した時間スライスで、空いている状態ではこのような JS 呼び出しの量は発生しないはずなので、Call Tree を使用してさらに調査を行います。
Timer Fired、つまりタイマーが原因の問題であり、この考え方に従って探すと、最終的に同僚が誤って delay を 0 に設定した setInterval を書いたことがわかりました。
以上が今日のいくつかの共有です。今後、Jotai と Zustand の使用方法、粒度の細かいコンポーネントと親コンポーネントの状態を子コンポーネントに沈めるなどの経験についても書く予定です。
この記事は Mix Space によって xLog に同期更新されました。元のリンクは https://innei.ren/posts/programming/experience-in-performance-optimization-in-react-applications-1