React 組件性能優化——function component

1. 前言

1.1. 有什麼是 Hook 能做而 class 做不到的?

前陣子我終於找到了其中一個 參考答案 ,此前在開發一個需求時,需要通過 url 或緩存傳遞一個 參數 給新打開的 Tab。當 Tab 下的頁面開始加載時,會去讀取這個 參數,並且使用它去做一些請求,獲取更多的信息進行渲染。

最初拿到這個需求時,我使用了 類組件 去開發,但實踐過程中發現編寫出的代碼不易理解和管理。最後重構爲 函數式組件,讓代碼簡潔了許多。

1.2. 一個不好的 🌰( getDerivedStateFromProps + componentDidUpdate )

最初我通過 getDerivedStateFromProps 和 componentDidUpdate 這兩個生命週期。其中 getDerivedStateFromProps 去實現 props 的前後對比, componentDidUpdate 控制組件去請求和更新。

首先我們有一個來自於 url 和緩存的參數,叫做 productId,也可以叫做 商品id,它在發生更新後如何通知父組件,這一點我們不需要在意。現在父組件被通知 商品id 發生了更新,於是通過 props 將其傳遞給了子組件,也就是我們的頁面容器。

在父組件改變 props 中的 商品id 時,我們的子組件通過 getDerivedStateFromProps 去監聽,經過一段比較邏輯,發生改變則更新 state 觸發組件的重新渲染。

// 監聽 props 變化,觸發組件重新渲染
static getDerivedStateFromProps(nextProps, prevState) {
  const { productId } = nextProps;
  // 當傳入的 productId 與 state 中的不一致時,更新 state
  if (productId !== prevState.productId) {
    // 更新 state,觸發重新渲染
    return { productId };
  }
  return null;
}

接下來,因爲 商品id 發生了更新,組件需要再發一次請求去更新並重新渲染 商品 的詳情信息。

componentDidUpdate(prevProps, prevState) {
  /**
   * state 改變,重新請求
   * PS: 細心的你可能發現這裏又跟舊的 state 比較了一次
   */
  if (prevState.productId !== this.state.productId) {
    this.getProductDetailRequest();
  }
}

getProductDetailRequest = async () => {
  const { productId } = this.state;

  // 用更新後的 productId 去請求商品詳情
  const { result } = await getProductDetail({
    f_id: +productId,
  });

  // setState 重新渲染商品詳情
  this.setState({
    productDetail: result,
  });
};

到這裏就實現了我們的需求,但這份代碼其實有很多不值得參考的地方:

1、componentDidUpdate 中的 setState —— 出於更新 UI 的需要,在 componentDidUpdate 中又進行了一次 setState,其實是一種危險的寫法。假如沒有包裹任何條件語句,或者條件語句有漏洞,組件就會進行循環更新,隱患很大。

2、分散在兩個生命週期中的兩次數據比較 —— 在一次更新中發生了兩次 state 的比較,雖然性能上沒有太大影響,但這意味着修改代碼時,要同時維護兩處。假如比較邏輯非常複雜,那麼改動和測試都很困難。

3、代碼複雜度 —— 僅僅是 demo 就已經編寫了很多代碼,不利於後續開發者理解和維護。

1.3. 另一個不好的 🌰( componentWillReceiveProps )

上面的 🌰 中,導致我們必須使用 componentDidUpdate 的一個主要原因是,getDerivedStateFromProps 是個靜態方法,不能調用類上的 this,異步請求等副作用也不能在此使用。

爲此,我們不妨使用 componentWillReceiveProps 來實現,在獲取到 props 的時候就能直接發起請求,並且 setState

componentWillReceiveProps(props) {
  const { productId } = props;
  if (`${productId}` === 'null') {
    // 不請求
    return;
  }
  if (productId !== this.state.productId) {
    // 商品池詳情的id發生改變,重新進行請求
    this.getProductDetailRequest(productId);
  }
}

將邏輯整合到一處,既實現了可控的更新,又能少寫很多代碼。

但這僅限 React 16.4 之前。

1.4. class component 的副作用管理之難

面臨上述需求的時候,我們藉助了兩種方案,但各有缺點。

甚至當依賴項增多的時候,上述兩種方式將會提升代碼的複雜度,我們會耗費大量的精力去思考狀態的比較以及副作用的管理。而 React 16.8 之後的 函數式組件 和 hook api,很好地解決了這一痛點。看看使用了 函數式組件 是怎樣的:

function Child({ productId }) {
  const [productDetail, setProductDetail] = useState({});
  useEffect(() => {
    const { result } = await getProductDetail({
      f_id: +productId,
    });
    setProductDetail(result);
  }, [productId]);

  return <>......</>;
}

相比上面兩個例子,是不是簡單得多?上面的 useEffect() 通過指定依賴項的方式,把令人頭疼的副作用進行了管理,僅在依賴項改變時纔會執行。

到這裏,我們已經花了很長的篇幅去突出 函數式組件 的妙處。我們能夠發現,函數式組件 可以讓我們更多地去關注數據驅動,而不被具體的生命週期所困擾。在 函數式組件 中,結合 hook api,也可以很好地觀察組件性能優化的方向。

這裏我們從數據緩存的層面,介紹一下函數式組件的三個性能優化方式 —— React.memouseCallback 和 useMemo

2. 函數式組件性能優化

2.1. 純組件 (Pure Componet)

純組件(Pure Component)來源於函數式編程中純函數(Pure Function)的概念,純函數符合以下兩個條件:

類似的,如果 React 組件爲相同的 state 和 props 呈現相同的輸出,則可以將其視爲純組件。

2.1.1. 淺層比較

根據數據類型,淺層比較分爲兩種:

淺層比較這一步是優先於 diff 的,能夠從上游阻止重新 render。同時淺層比較只比較組件的 state 和 props,消耗更少的性能,不會像 diff 一樣重新遍歷整顆虛擬 DOM 樹。

淺層比較也叫 shallow compare,在 React.memo或 React.PureComponent出現之前,常用於 shouldComponentUpdate 中的比較。

2.1.2. 純組件 api

對組件輸入的數據進行淺層比較,如果當前輸入的數據和上一次相同,那麼組件就不會重新渲染。相當於,在類組件的 shouldComponentUpdate() 中使用淺層比較,根據返回值來判斷組件是否需要渲染。

純組件適合定義那些 props 和 state 簡單的組件,實現上可以總結爲:類組件繼承 PureComponent 類,函數組件使用 memo 方法

2.1.3. PureComponent

PureComponent 不需要開發者自己實現 shouldComponentUpdate(),就可以進行簡單的判斷,但僅限淺層比較。

import React, { PureComponent } from 'react';

class App extends PureComponent {}
export default App;

假如依賴的引用數據發生了深層的變化,頁面將不會得到更新,從而出現和預期不一致的 UI。當 props 和 state 複雜,需要深層比較的時候,我們更推薦在 Component 中自行實現 shouldComponentUpdate()

此外,React.PureComponent 中的 shouldComponentUpdate() 將跳過所有子組件樹的 prop 更新。因此,請確保所有子組件也都是純組件。

2.1.4. React.memo

React.memo 是一個高階組件,接受一個組件作爲參數返回一個新的組件。新的組件僅檢查 props 變更,會將當前的 props 和 上一次的 props 進行淺層比較,相同則阻止渲染。

function MyComponent(props) {
  /* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
  /*
  memo 的第二個參數
  可以傳入自定義的比較邏輯(僅比較 props),例如實現深層比較
  ps:與 shouldComponentUpdate 的返回值相反,該方法返回 true 代表的是阻止渲染,返回 false 代表的是 props 發生變化,應當重新渲染
  */
}
export default React.memo(MyComponent, areEqual);

所以對於函數式組件來說,若實現中擁有 useStateuseReducer 或 useContext 等 Hook,當 state 或 context 發生變化時,即使 props 比較相同,組件依然會重新渲染。所以 React.memo,或者說純組件,更適合用於 renderProps() 的情況,通過記憶輸入和渲染結果,來提高組件的性能表現。

2.1.5. 總結

將類組件和函數組件改造爲純組件,更爲便捷的應該是函數組件。React.memo() 可以通過第二個參數自定義比較的邏輯,以高階函數的形式對組件進行改造,更加靈活。

2.2. useCallback

在函數組件中,當 props 傳遞了回調函數時,可能會引發子組件的重複渲染。當組件龐大時,這部分不必要的重複渲染將會導致性能問題。

// 父組件傳遞迴調
const Parent = () => {
   const [title, setTitle] = useState('標題');
   const callback = () => { 
     /* do something to change Parent Component‘s state */
     setTitle('改變標題');
   };
   return (
      <>
              <h1>{title}</h1>
         <Child onClick={callback} />
      </>
   )
}

// 子組件使用回調
const Child = () => {
   /* onClick will be changed after Parent Component rerender */const { onClick } = props;
   return (
      <>
         <button onClick={onClick} >change title</button>
      </>
   )
}

props 中的回調函數經常是我們會忽略的參數,執行它時爲何會引發自身的改變呢?這是因爲回調函數執行過程中,耦合了父組件的狀態變化,進而觸發父組件的重新渲染,此時對於函數組件來說,會重新執行回調函數的創建,因此給子組件傳入了一個新版本的回調函數。

解決這個問題的思路和 memo 是一樣的,我們可以通過 useCallback 去包裝我們即將傳遞給子組件的回調函數,返回一個 memoized 版本,僅當某個依賴項改變時纔會更新。

// 父組件傳遞迴調
const Parent = () => {
   const [title, setTitle] = useState('標題');
   const callback = () => { 
     /* do something to change Parent Component‘s state */
     setTitle('改變標題');
   };
   const memoizedCallback = useCallback(callback, []);
   return (
      <>
              <h1>{title}</h1>
         <Child onClick={memoizedCallback} />
      </>
   )
}

// 子組件使用回調
const Child = (props) => {
   /* onClick has been memoized */const { onClick } = props;
   return (
      <>
         <button onClick={onClick} >change title</button>
      </>
   )
}

此外,使用上, useCallback(fn, deps) 相當於 useMemo(() => fn, deps)

2.3. useMemo

React.memo() 和 useCallback 都通過保證 props 的穩定性,來減少重新 render 的次數。而減少數據處理中的重複計算,就需要依靠 useMemo 了。

首先需要明確,useMemo 中不應該有其他與渲染無關的邏輯,其包裹的函數應當專注於處理我們需要的渲染結果,例如說 UI 上的文本、數值。其他的一些邏輯如請求,應當放在 useEffect 去實現。

function computeExpensiveValue() {
  /* a calculating process needs long time */
  return xxx
}

const memoizedValue = useMemo(computeExpensiveValue, [a, b]);

如果沒有提供依賴項數組,useMemo 在每次渲染時都會計算新的值。

以階乘計算爲例:

export function CalculateFactorial() {
  const [number, setNumber] = useState(1);
  const [inc, setInc] = useState(0);

  // Bad —— calculate again and console.log('factorialOf(n) called!');
  // const factorial = factorialOf(number);
  
  // Good —— memoized
  const factorial = useMemo(() => factorialOf(number), [number]);

  const onChange = event => {
    setNumber(Number(event.target.value));
  };
  const onClick = () => setInc(i => i + 1);
  
  return (
    <div>
      Factorial of 
      <input type="number" value={number} onChange={onChange} />
      is {factorial}
      <button onClick={onClick}>Re-render</button>
    </div>
  );
}

function factorialOf(n) {
  console.log('factorialOf(n) called!');
  return n <= 0 ? 1 : n * factorialOf(n - 1);
}

經過 useMemo 封裝,factorial 成爲了一個記憶值。當我們點擊重新渲染的按鈕時,inc 發生了改變引起函數式組件的 rerender,但由於依賴項 number 未發生改變,所以 factorial 直接返回了記憶值。

3. 總結

1、通過 函數式組件 結合 hook api,能夠以更簡潔的方式管理我們的副作用,在涉及到類似前言的問題時,更推薦把組件改造成函數式組件。

2、用一個通俗的說法去區分 React.memo 、useCallback 和 useMemo , 那大概就是:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/U4dKSNfD1dPsMemu_HAaqA