我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:的盧 引入 在日常開發過程中,我們會使用很多性能優化的 API,比如像使用 memo、useMemo優化組件或者值,再比如使用 shouldComponent ...
我們是袋鼠雲數棧 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
- 大數據分散式任務調度系統——Taier
- 輕量級的 Web IDE UI 框架——Molecule
- 針對大數據領域的 SQL Parser 項目——dt-sql-parser
- 袋鼠雲數棧前端團隊代碼評審工程實踐文檔——code-review-practices
- 一個速度更快、配置更靈活、使用更簡單的模塊打包器——ko