在閱讀之前,推薦先把 react-re-renders-guide 閱讀一遍,了解完理論知識之後再通過實際業務場景去深刻理解。
善用 React Devtool 和 Profiler#
React devtool 中有非常好用的功能叫做 Highlight updates when components render。使用這個功能可以把 re-render 組件高亮出來,一次 re-render 會用綠框高亮,如果是黃框就是在短時間內造成了多次 re-render 就需要考慮哪個組件有性能問題了。
通過這個工具可以快速定位頁面上哪個組件存在問題,但卻無法定位具體的問題所在。
我們知道 React 組件的重渲染一定是因為某個 hook 導致的,關鍵就是如何找到頻繁更新的 hook。此時我們就需要借助 React Profiler 去排查了。
切換到 Profiler Tab,點擊左上的 Record,然後觸發會導致 re-render 的操作之後可以看到如下的頁面:
如上圖,NodeCards 這是一個列表組件,循環遍歷了 NodeCard 這個組件。在鼠標懸浮某個在 NodeCard 時,造成了父級組件 NodeCards 發生了 re-render,導致子代組件全部發生 re-render,這個性能開銷是比較大的,在長列表組件中時刻要注意牽一發而動全身。
我們通過 Profiler 發現 NodeCards 的 Hook 136 發生了改變導致了這次 re-render。但是我們並不知道 136 到底是啥(批評 React devtool),我們需要這個時候切回到 Components,可以看到對應的組件右邊面板有 Hook 對應的序號,通過序號就能摸到對應的 Hook。當然大部分 Hook 都是有其他多個 hooks 組合起來的,所以只能判斷個大概。
比如上圖我們可以判斷 136 號 Hook 其實是 Jotai 的一個 Hook。
列表組件應該如何規避性能問題#
使用 Memo?#
上面的例子中,列表中的一個組件更新導致整個列表更新是非常不環保的。又通過 Profiler 的幾幀發現,大部分的 NodeCard 本身並沒有更新,只是上層 NodeCards 更新了而已。這個時候我們可以使用 memo 去對 NodeCard 包一層。這樣父級更新就不會導致列表元素都在更新。在列表(List)場景中,一般都是推薦對 列表元素(ListItem)包裹 memo,用這種空間換時間的在長列表十分受益,除非你確保列表的狀態一定不會更新。
const NodeCards = () => {
// some hooks
return <>
{data.map(item => <NodeCard data={item} />)}
</>
}
const NodeCard = memo((props) => {
return <div />
})
傳入 id
而不是數據源?#
一般的,我們可以直接在列表數據遍歷的時候直接傳入數據源,如上面所示。但是由於 React 的 immutable 的特徵,如果數據源發生了更改,你的組件一定會發生更新,即使在 ListItem 中並沒有使用數據源更改的具體值。如下個例子:
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// others..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// we update 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
但是一旦數據發生改變他一定會被 re-render。這是我們不希望看到的。但是如果我們傳入是 id,再配合 selector 就不會有這樣的不環保行為。
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// others..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// maybe some state change and hook update here.
// we update 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 表達。比如一個卡片 hover 時,我需要做一些 motion 或者其他不能簡單通過 CSS 就能做到的場景。比如:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// some hooks
return <>
{data.map(item => <NodeCard data={item} activeId={activeId} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// do thing.
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
以上代碼是非常非常錯誤的。在列表中你不應該把這些動態可變值直接傳入到列表元素。舉個例子,上面的寫法就會導致鼠標 hover 到一個 NodeCard 時,所有 NodeCard 也被 re-render。
正確的應該是傳入一個布爾值,在 NodeCards 就完成判斷:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// some hooks
return <>
{data.map(item => <NodeCard data={item} isActive={activeId === item.id} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// do thing.
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
上面修改為 isActive
則不會出現這樣的問題。
再者有說了,如果我就是要拿到 activeId
但是 NodeCard 又是一個 ExpensiveComponent 怎麼辦呢。方法一是我們可以使用 Ref,但是 Ref 是脫離響應式數據流的。方法二借助 Jotai 類似的狀態庫外置狀態 + 拆分輕量組件。
const activeIdAtom = atom(0)
const NodeCards = () => {
// some hooks
return <>
{data.map(item => <NodeCardExpensiveComponent data={item} />)}
</>
}
const NodeCardExpensiveComponent = memo((props) => {
const setActiveId = useSetAtom(activeIdAtom)
// do thing.
return <>
<div onMouseEnter={() => {
props.setActiveId(props.data.id)
}}
/>
{new Array(10000).fill(0).map(() => <div />)}
<ObservedActiveIdHandler />
</>
})
const ObservedActiveIdHandler = () => {
const activeId = useAtomValue(activeIdAtom)
useEffect(() => {
// do thing.
}, [activeId])
}
我們把 activeId
的響應式處理拆分到輕量組件就不會影響開銷很大的組件就會非常環保。
這種方法在其他地方的優化一樣適用。
從 Performance 火焰圖發現性能問題#
Chrome devtools 有個很好用的火焰圖工具。在頁面空載但是 CPU 大量使用,並且 React devtool 沒有任何高亮組件的 re-render 的時候,就需要用到它了。
通過截取一段火焰圖可以排查到短時間內 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