我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。
本文作者:的盧
引入
在日常開發過程中,我們會使用很多效能最佳化的 API
,比如像使用 memo
、useMemo
最佳化元件或者值,再比如使用 shouldComponentUpdate
減少元件更新頻次,懶載入等等,都是一些比較好的效能最佳化方式,今天我將從元件設計、結構上來談一下 React 效能最佳化以及數棧產品內的實踐。
如何設計元件會有好的效能?
先看下面一張圖:
這是一顆 React 元件樹,App
下面有三個子元件,分別是 Header
、Content
、Footer
,在 Content
元件下面又分別有 FolderTree
、WorkBench
、SiderBar
三個子元件,現在如果在 WorkBench 中觸發一次更新,那麼 React 會遍歷哪些元件呢?Demo1
function FolderTree() {
console.log('render FolderTree');
return <p>folderTree</p>;
}
function SiderBar() {
console.log('render siderBar');
return <p>i'm SiderBar</p>;
}
export const WorkBenchGrandChild = () => {
console.log('render WorkBenchGrandChild');
return <p>i'm WorkBenchGrandChild</p>
};
export const WorkBenchChild = () => {
console.log('render WorkBenchChild');
return (
<>
<p>i'm WorkBenchChild</p>
<WorkBenchGrandChild />
</>
);
};
function WorkBench() {
const [num, setNum] = useState<number>(1);
console.log('render WorkBench');
return (
<>
<input
value={num}
onChange={(e) => {
setNum(+e.target.value || 0);
}}
/>
<p>num is {num}</p>
<WorkBenchChild />
</>
);
}
function Content() {
console.log('render content');
return (
<>
<FolderTree />
<WorkBench />
<SiderBar />
</>
);
};
function Footer() {
console.log('render footer');
return <p>i'm Footer</p>
};
function Header() {
console.log('render header');
return <p>i'm Header</p>;
}
// Demo1
function App() {
// const [, setStr] = useState<string>();
return (
<>
<Header />
<Content />
<Footer />
{/* <input onChange={(e) => { setStr(e.target.value) }} /> */}
</>
);
};
根據上面斷點和日誌就可以得到下面的結論:
- 子孫元件每觸發一次更新,
React
都會重新遍歷整顆元件樹
當 input
輸入數字,引起 updateNum
變更狀態後,react-dom
中 beginWork
的 current
由頂層元件依次遍歷
React
更新時會過濾掉未變化的元件,達到減少更新的元件數的目的
在更新過程中,雖然 React
重新遍歷了元件樹,但 沒有列印沒有變化的 Header
、Footer
、FolderTree
、SiderBar
元件內的日誌
- 父元件狀態變化,會引起子元件更新
WorkBenchChild
屬於 WorkBench
的子元件,雖然 WorkBenchChild
沒有變化,但仍被重新渲染,列印了輸入日誌,如果更近一步去斷點會發現 WorkBenchChild
的 oldProps
和 newProps
是不相等的,會觸發 updateFunctionComponent
更新。
綜上我們可以得出一個結論,就是 React
自身會有一些效能最佳化的操作,會盡可能只更新變化的元件,比如 Demo1 中 WorkBench
、WorkBenchChild
、WorkBenchGrandChild
元件,而會繞開 不變的 Header
、Footer
等元件,那麼儘可能的讓 React
更新的粒度就是效能最佳化的方向,既然儘可能只更新變化的元件,那麼如何定義元件是否變化?
如何定義元件是否變化?
React
是以資料驅動檢視的單向資料流,核心也就是資料,那麼什麼會影響資料,以及資料的承載方式,有以下幾點:
- props
- state
- context
- 父元件不變!
父元件與當前元件其實沒有關聯性,放到這裡是因為,上面的例子中 WorkBenchChild
元件中沒有 state、props、context,理論上來說就不變,實際上卻重新 render
了,因為 其父元件 WorkBench
有狀態的變動,所以這裡也提了一下,在不使用效能最佳化 API 的前提下,只要保證 props、state、context & 其父元件不變,那麼元件就不變
還是回到剛剛的例子 Demo WorkBench
export const WorkBenchGrandChild = () => {
console.log('render WorkBenchGrandChild');
return <p>i'm WorkBenchGrandChild</p>
};
export const WorkBenchChild = () => {
console.log('render WorkBenchChild');
return (
<>
<p>i'm WorkBenchChild</p>
<WorkBenchGrandChild />
</>
);
};
function WorkBench() {
const [num, setNum] = useState<number>(1);
console.log('render WorkBench');
return (
<>
<input
value={num}
onChange={(e) => {
setNum(+e.target.value || 0);
}}
/>
<p>num is {num}</p>
<WorkBenchChild />
</>
);
}
export default WorkBench;
看一下這個 demo
,WorkBench
元件有一個 num
狀態,還有一個 WorkBenchChild
的子元件,沒有狀態,純渲染元件,同時 WorkBenchChild
元件也有一個 純渲染元件 WorkBenchGrandChild
子元件,當輸入 input
改變 num
的值時,WorkBenchChild
元件 和 WorkBenchGrandChild
元件都重新渲染。我們來分析一下在 WorkBench
元件中,它的子元件 WorkBenchChild
自始至終其實都沒有變化,有變化的其實是 WorkBench
中的 狀態
,但是就是因為 WorkBench
中的 狀態
發生了變化,導致了其子元件也一併更新,這就帶來了一定的效能損耗,找到了問題,那麼就需要解決問題。
如何最佳化?
使用效能最佳化 API
export const WorkBenchGrandChild = () => {
console.log('render WorkBenchGrandChild');
return <p>i'm WorkBenchGrandChild</p>
};
export const WorkBenchChild = React.memo(() => {
console.log('render WorkBenchChild');
return (
<>
<p>i'm WorkBenchChild</p>
<WorkBenchGrandChild />
</>
);
});
// Demo WorkBench
function WorkBench() {
const [num, setNum] = useState<number>(1);
console.log('render WorkBench');
return (
<>
<input
value={num}
onChange={(e) => {
setNum(+e.target.value || 0);
}}
/>
<p>num is {num}</p>
<WorkBenchChild />
</>
);
}
export default WorkBench;
我們可以使用 React.memo()
包裹 WorkBenchChild
元件,在其 diff
的過程中 props
改為淺對比的方式達到效能最佳化的目的,透過斷點可以知道 透過 memo
包裹的元件在 diff
時 oldProps
和 newProps
仍然不等,進入了 updateSimpleMemoComponent
中了,而 updateSimpleMemoComponent
中有個 shallowEqual
淺比較方法是結果相等的,因此沒有觸發更新,而是複用了元件。
狀態隔離(將狀態隔離到子元件中)
function ExchangeComp() {
const [num, setNum] = useState<number>(1);
console.log('render ExchangeComp');
return (
<>
<input
value={num}
onChange={(e) => {
setNum(+e.target.value || 0);
}}
/>
<p>num is {num}</p>
</>
);
};
// Demo WorkBench
function WorkBench() {
// const [num, setNum] = useState<number>(1);
console.log('render WorkBench');
return (
<>
<ExchangeComp />
<WorkBenchChild />
</>
);
}
export default WorkBench;
上面 Demo1 的結論,父元件更新,會觸發子元件更新,就因為 WorkBench
狀態改變,導致 WorkBenhChild
也更新了,這個時候可以手動創造條件,讓 WorkBenchChild
的父元件也就是 WorkBench
元件剝離狀態,沒有狀態改變,這種情況下 WorkBenchChild
滿足了 父元件不變的前提,且沒有 state
、props
、context
,那麼也能夠達到效能最佳化的結果。
對比
- 結果一樣,都是對
WorkBenchChild
進行了最佳化,在WorkBench
元件更新時,WorkBenchChild
、WorkBenchGrandChild
沒有重新渲染 - 出發點不一樣,用
memo
效能最佳化 API 是直接作用到子元件上面,而狀態隔離是在父元件上面操作,而受益的是其子元件
結論
- 只要結構寫的好,效能不會太差
- 父元件不變,子元件可能不變
效能最佳化方向
- 找到專案中效能損耗嚴重的元件(節點)
在業務專案中,找到卡頓、崩潰 的元件(節點)
- 在根元件(節點)上使用效能最佳化 API
在根元件上使用的目的就是避免其祖先元件如果沒有做好元件設計會給根元件帶來無效的重複渲染,因為上面提到的,父元件更新,子元件也會更新
- 在其他節點上使用 狀態隔離的方式進行最佳化
最佳化祖先元件,避免給子元件造成無效的重複渲染
總結
我們從 元件結構 和 效能最佳化 API 上介紹了效能最佳化的兩種不同的最佳化方式,在實際專案使用上,也並非使用某一種最佳化方式,而是多種最佳化方式結合著來以達到最好的效能
產品中的部分實踐
-
將狀態隔離到子元件內部,避免引起不必要的更新
import React, { useCallback, useEffect, useState } from 'react'; import { connect } from 'react-redux'; import type { SelectProps } from 'antd'; import { Select } from 'antd'; import { fetchBranchApi } from '@/api/project/optionsConfig'; const BranchSelect = (props: SelectProps) => { const [list, setList] = useState<string[]>([]); const [loading, setLoading] = useState<boolean>(false); const { projectId, project, tenantId, ...otherProps } = props; const init = useCallback(async () => { try { setLoading(true); const { code, data } = await fetchBranchApi(params); if (code !== 1) return; setList(data); } catch (err) { } finally { setLoading(false); } }, []); useEffect(() => { init(); }, [init]); return ( <Select showSearch optionFilterProp="children" filterOption={(input, { label }) => { return ((label as string) ?? '') ?.toLowerCase?.() .includes?.(input?.toLowerCase?.()); }} options={list?.map((value) => ({ label: value, value }))} loading={loading} placeholder="請選擇程式碼分支" {...otherProps} /> ); }; export default React.memo(BranchSelect);
比如在中後臺系統中很多表單型元件
Select
、TreeSelect
、Checkbox
,其展示的資料需要透過介面獲取,那麼此時,如果將獲取資料的操作放到父元件,那麼每次請求資料不僅會導致需要資料的那個表單項元件更新,同時,其他的表單項也會更新,這就有一定的效能損耗,那麼按照上面的例子這樣將其狀態封裝到內部,避免請求資料影響其他元件更新,就可以達到效能最佳化的目的,一般建議在外層再加上memo
效能最佳化 API,避免因為外部元件影響內部元件更新。 -
Canvas render & Svg render
// 畫一個小十字 export function createPlus( point: { x: number; y: number }, { radius, lineWidth, fill }: { radius: number; lineWidth: number; fill: string } ) { // 豎 橫 const colWidth = point.x - (1 / 2) * lineWidth; const colHeight = point.y - (1 / 2) * lineWidth - radius; const colTop = 2 * radius + lineWidth; const colBottom = colHeight; const rowWidth = point.x - (1 / 2) * lineWidth - radius; const rowHeight = point.y - (1 / 2) * lineWidth; const rowRight = 2 * radius + lineWidth; const rowLeft = rowWidth; return ` <path d="M${colWidth} ${colHeight}h${lineWidth}v${colTop}h-${lineWidth}V${colBottom}z" fill="${fill}"></path> <path d="M${rowWidth} ${rowHeight}h${rowRight}v${lineWidth}H${rowLeft}v-${lineWidth}z" fill="${fill}"></path> `; } renderPlusSvg = throttle(() => { const plusBackground = document.getElementById(`plusBackground_${this.randomKey}`); const { scrollTop, scrollLeft, clientHeight, clientWidth } = this._container || {}; const minWidth = scrollLeft; const maxWidth = minWidth + clientWidth; const minHeight = scrollTop; const maxHeight = minHeight + clientHeight; const stepping = 30; const radius = 3; const fillColor = '#EBECF0'; const lineWidth = 1; let innerHtml = ''; try { // 根據滾動情況拿到容器的四個座標點, 只渲染當前滾動容器內的十字,實時渲染 for (let x = minWidth; x < maxWidth; x += stepping) { for (let y = minHeight; y < maxHeight; y += stepping) { // 畫十字 innerHtml += createPlus({ x, y }, { radius, fill: fillColor, lineWidth }); } } plusBackground.innerHTML = innerHtml; } catch (e) {} });
問題源於在大資料情況下,由 canvas 渲染的 小十字背景渲染失敗,經測試,業務資料在 200條左右 canvas 畫布繪製寬度就已經達到了 70000px,需要渲染的小十字 數量級在 10w 左右,canvas 不適合繪製尺寸過大的場景(超過某個閥值就會出現渲染失敗,具體閥值跟瀏覽器有關係),而 svg 不適合繪製數量過多的場景,目前的業務場景卻是 畫布尺寸大,繪製元素多,後面的解決方式就是 採用 svg 渲染,將 畫布渲染出來,同時監聽容器的滾動事件,同時只渲染滾動容器中可視區域內的背景,實時渲染,渲染數量在 100 左右,實測就無卡頓現象,問題解決
參考:
最後
歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star