關於 React 效能最佳化和數棧產品中的實踐

袋鼠雲數棧前端發表於2023-10-24

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:的盧

引入

在日常開發過程中,我們會使用很多效能最佳化的 API,比如像使用 memouseMemo最佳化元件或者值,再比如使用 shouldComponentUpdate減少元件更新頻次,懶載入等等,都是一些比較好的效能最佳化方式,今天我將從元件設計、結構上來談一下 React 效能最佳化以及數棧產品內的實踐。

如何設計元件會有好的效能?

先看下面一張圖:

file

這是一顆 React 元件樹,App 下面有三個子元件,分別是 HeaderContentFooter,在 Content元件下面又分別有 FolderTreeWorkBenchSiderBar三個子元件,現在如果在 WorkBench 中觸發一次更新,那麼 React 會遍歷哪些元件呢?Demo1

file

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) }} /> */}
    </>
  );
};

file

根據上面斷點和日誌就可以得到下面的結論:

  1. 子孫元件每觸發一次更新,React都會重新遍歷整顆元件樹

input 輸入數字,引起 updateNum變更狀態後,react-dombeginWorkcurrent由頂層元件依次遍歷

  1. React更新時會過濾掉未變化的元件,達到減少更新的元件數的目的

在更新過程中,雖然 React重新遍歷了元件樹,但 沒有列印沒有變化的 HeaderFooterFolderTreeSiderBar元件內的日誌

  1. 父元件狀態變化,會引起子元件更新

WorkBenchChild屬於 WorkBench的子元件,雖然 WorkBenchChild沒有變化,但仍被重新渲染,列印了輸入日誌,如果更近一步去斷點會發現 WorkBenchChildoldPropsnewProps是不相等的,會觸發 updateFunctionComponent更新。

綜上我們可以得出一個結論,就是 React自身會有一些效能最佳化的操作,會盡可能只更新變化的元件,比如 Demo1 中 WorkBenchWorkBenchChildWorkBenchGrandChild元件,而會繞開 不變的 HeaderFooter等元件,那麼儘可能的讓 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;

看一下這個 demoWorkBench元件有一個 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;

file

file

我們可以使用 React.memo()包裹 WorkBenchChild元件,在其 diff的過程中 props改為淺對比的方式達到效能最佳化的目的,透過斷點可以知道 透過 memo包裹的元件在 diffoldPropsnewProps仍然不等,進入了 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;

file

file

上面 Demo1 的結論,父元件更新,會觸發子元件更新,就因為 WorkBench狀態改變,導致 WorkBenhChild也更新了,這個時候可以手動創造條件,讓 WorkBenchChild的父元件也就是 WorkBench元件剝離狀態,沒有狀態改變,這種情況下 WorkBenchChild 滿足了 父元件不變的前提,且沒有 statepropscontext,那麼也能夠達到效能最佳化的結果。

對比

  1. 結果一樣,都是對 WorkBenchChild進行了最佳化,在 WorkBench元件更新時, WorkBenchChildWorkBenchGrandChild沒有重新渲染
  2. 出發點不一樣,用 memo 效能最佳化 API 是直接作用到子元件上面,而狀態隔離是在父元件上面操作,而受益的是其子元件

結論

  1. 只要結構寫的好,效能不會太差
  2. 父元件不變,子元件可能不變

效能最佳化方向

  1. 找到專案中效能損耗嚴重的元件(節點)

在業務專案中,找到卡頓、崩潰 的元件(節點)

  1. 在根元件(節點)上使用效能最佳化 API

在根元件上使用的目的就是避免其祖先元件如果沒有做好元件設計會給根元件帶來無效的重複渲染,因為上面提到的,父元件更新,子元件也會更新

  1. 在其他節點上使用 狀態隔離的方式進行最佳化

最佳化祖先元件,避免給子元件造成無效的重複渲染

總結

我們從 元件結構 和 效能最佳化 API 上介紹了效能最佳化的兩種不同的最佳化方式,在實際專案使用上,也並非使用某一種最佳化方式,而是多種最佳化方式結合著來以達到最好的效能

產品中的部分實踐

  1. 將狀態隔離到子元件內部,避免引起不必要的更新

    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);
    

    比如在中後臺系統中很多表單型元件 SelectTreeSelectCheckbox,其展示的資料需要透過介面獲取,那麼此時,如果將獲取資料的操作放到父元件,那麼每次請求資料不僅會導致需要資料的那個表單項元件更新,同時,其他的表單項也會更新,這就有一定的效能損耗,那麼按照上面的例子這樣將其狀態封裝到內部,避免請求資料影響其他元件更新,就可以達到效能最佳化的目的,一般建議在外層再加上 memo效能最佳化 API,避免因為外部元件影響內部元件更新。

  2. Canvas render & Svg render

    file

    // 畫一個小十字
    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 左右,實測就無卡頓現象,問題解決

參考:

  1. React 效能最佳化的一切
  2. React 原始碼解析之 Fiber渲染
  3. 魔術師卡頌

最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star

相關文章