【react】使用react新特性Hook對你的組件完成一次性能優化


一、前言

隨着16.8版本的出現,react又帶我們回到了函數式編程,其的出現解決了類組件的不足同時帶來了一些新特性;本文主要圍繞Hook所提供的新特性來拋磚引玉我們在使用類組件的時候可能從未關注過的性能方面的問題。

什么是Hook?

官方文檔給出了解釋:Hook 是 React 16.8 的新增特性。它可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性。

二、Hook在二手車-白菜商家版項目中的實踐

作為React的新特性,伴隨着穩定版的發布,我們將Hook賦能在了白菜商家版app的hybird h5業務場景中,該項目技術棧采用koa+nextJs+Hook。

下圖展示的是該項目中車輛發起訂單頁,我們采用了組件化的思想進行開發去提高代碼的復用性。
image.png

未采取任何優化方案前

在沒有進行組件性能優化之前,我們進行了一個改變車牌號碼的操作,導致了父組件和子組件均進行了重新渲染,<借助在組件內打印渲染日志>如下圖所示:

image.png

這是由於在react當中,父組件狀態變更時,父組件進行重新渲染的過程中會導致子組件也進行重新渲染,哪怕是子組件並沒有依賴於父組件某個狀態,分析頁面運行性能:

image.png

由上可知:更改子組件狀態導致父組件和子組件重新渲染,scripting耗時15ms,rendering耗時2ms

優化后

我們采用Hook提供的新特性進行組件的性能優化后,再進行一次改變車牌號碼的操作,如下圖所示,只有當前子組件和父組件進行了重新渲染

image.png

分析頁面性能:

image.png

相比較沒有優化之前,更改子組件狀態導致的重新渲染,scripting耗時15ms->10ms(優化近50%),rendering耗時2ms->1ms(優化近50%)

三、使用Hook重構Class組件

接下來,我們從0到1,一起體驗從使用hook重構一個最基礎的class組件,到逐步使用hook提供的新特性進行組件性能優化。

在16.8以前的版本中,我們可能是通過class這樣去實現一個有狀態組件:

點擊進入線上代碼沙箱,查看demo>init查看效果

功能實現是一個簡單的計數器如下所示:
image.png

而現在,我們開始考慮用hook重構它吧

// HookFather Component
const HookFather = () => {
  console.log("hook>>father component render");

  let [count, setCount] = useState(0);

  const handleBtnClick = () => {
      setCount(++count)
  }

  return (
    <>
      <div>{count}</div>
      <button onClick={handleBtnClick}>add</button>
      <HookSon />
    </>
  );
};
// HookSon Component
const HookSon = () => {
    console.log("hook>>son component render");

    return <div>this is son hook component</div>;
}

現在我們使用hook完成了重構,接下來我們開始逐步完成優化

首先明確一點,當父組件中使用了多個子組件時,在沒有做任何優化前,子組件會伴隨父組件的每次更新而重新渲染

我們點擊了兩次button,使count的狀態由0增加到了2,如下圖

image.png

從日志中我們可以發現,組件總共渲染了3次,第1次為組件首次渲染,后2次為state狀態更新導致的重新渲染,且父子組件都進行了重新渲染。但實際上,子組件並不依賴於父組件中的某個狀態,卻跟着父組件進行了兩次重新渲染,對於子組件來說后2次重新渲染是不必要的。

四、使用Hook新特性進行組件性能優化

1. React.memo

memo其實是16.6提出的,用於支持函數組件也擁有類似於class組件的PureComponent和shouldComponentUpdate的類似解決方案<此處不做詳細講解>
點擊進入線上代碼沙箱,查看demo>memo查看效果

// 改寫HookSon
const HookSon = React.memo(() => {
  console.log("hook>>son component render");

  return <div>this is son hook component</div>;
})

image.png

效果還是比較明顯,當我們更新父組件的時候,會淺比較使用memo包括的子組件前后的props值,如果props每一個屬性都一樣,就會跳過當前子組件的執行,從而達到減少不必要的渲染

自定義子組件是否更新

React.memo(React.Component, (nextProps, prevProps) => {
   // 根據實際使用場景編寫邏輯,用於判斷是否需要更新子組件
})

React.memo函數還支持第2個參數,該參數接收一個function,最終返回一個boolean類型值,用於判斷子組件是否需要進行本次更新.

2. React.useCallback

我們知道在js中,function() {} === function() {}的結果為false,這是因為它們雖然內容一樣,但是在內存中卻是兩塊不同空間。

回到HookFather組件中,該組件由於每次count狀態的變更都會導致整個組件重新渲染,也就是說其函數作用域里面的一切都將會重新開始。對於const handleBtnClick = () => { //... }將會前后渲染兩次,哪怕其內容相同。那么,對於此次的重復渲染是否能進行避免?

方法一:將該方法提取到組件外部,使其不受組件內部狀態影響

const handleBtnClick = () => { //... }

const HookFather = () => {
    // ... 
    return (
        <button onClick={handleBtnClick}></button>
    )
}

如上所示,這是一種辦法。但如果handleBtnClick依賴組件內部的一些變量,我們又不得不將其寫到組件中(雖然有辦法可以解決例如使用引用類型傳遞出參數,但是只會將簡單問題復雜化,反而得不償失)。

那么,我們能不能去判斷,如果前后兩次是相同的一個函數就不進行函數的重新定義?

方法二:使用函數記憶去實現

1). 什么是函數記憶?

讓函數記住上一次執行后的參數和結果,利用的是閉包原理

2). 函數記憶的作用?

用於避免重復的運算耗時

3). 什么時候使用函數記憶?

一個函數可能做重復的事情時

例如,現在我們有一個這樣的函數

function outer() {
  const inner = function() {
      console.log('inner')
  }
  inner()
}
outer() 
outer() // 執行outer函數,inner函數又被定義了一次

對於inner函數,我們是否能通過一個依賴數組來確定什么時候需要重新被定義呢?

// 實現一個記憶函數的偽代碼
let prevFunction // 記憶函數
let prevDeps // 依賴項狀態

/** 
* @description 使用記憶函數
* @param {function} fundamental 需要被記憶的函數
* @param {array} memo 記憶依賴項
*/
var memoizer = function(fn, deps) {
 
  if (prevFunction && isEqual(deps, prevDeps)) {
    return prevFunction
  }
    
  prevDeps = deps
  prevFunction = fn
  return prevFunction
};

通過上述我們了解了記憶函數的原理和使用,Hook幫我們實現了一個useCallback記憶函數,用來幫助我們僅在某個依賴項發生改變時才會更新
點擊進入線上代碼沙箱,查看demo>useCallback查看效果

const HookFather = () => {
  //參數1: 接收一個內聯回調函數,其中實現我們需要的功能
  //參數2: 接收一個依賴項數組,只有依賴項發生改變時,該方法才會重新渲染。如果該依賴項為空數組,則表示只會在該組件第一次渲染時被創建,后續該組件狀態無論如何變動都不會重新被創建
  const handleBtnClick = useCallback(() => {
      // ...do something
  }, [])
    
  return (
      <button onClick={handleBtnClick}></button>
  )
}

3.React.useMemo

我們改造一下原有例子,假設我們的場景中需要在子組件中去接收父組件的這樣一個組合狀態,在子組件中輸出計算器的價格

const HookFather = () => {
  console.log("hook>>father component render");

  let [count, setCount] = useState(0)
  let [price, setPrice] = useState(10)

  const handleBtnClick = useCallback(() => {
    setCount(++count)
  }, [count])

  // 組合父組件的狀態傳遞給子組件
  const data = {
    name: '計算器',
    price
  }

  return (
    <>
      <div>{count}</div>
      <button onClick={handleBtnClick}>add</button>
      <HookSon data={data} />
    </>
  );
};

const HookSon = memo(({ data }) => {
  console.log("hook>>son component render");

  return <div>this is son hook component, show price: {data.price}</div>;
})

我們去改變計算器的值,導致父組件重新渲染,data數據跟着被重新渲染。由於HookSon使用memo進行淺比較,對於引用類型的值前后兩次是不一樣的,所以導致子組件也跟着重新渲染了一次

但由於父組件中的狀態變更對子組件並沒有實質上改變,如何優化這一次的子組件重新渲染呢?

方法一:利用memo的第二個參數

const HookSon = memo(
  ({ data }) => {
    console.log("hook>>son component render");

    return <div>this is son hook component, show name: {data.price}</div>;
  },
  // 比較前后狀態是否相同,決定本次是否執行更新操作
  (nextProps, prevProps) => (nextProps.data.price === prevProps.data.price)
)

這是一種辦法,適用於子組件所依賴的父組件狀態比較簡單,但隨着子組件的業務復雜化,無法兼顧到所有場景,且會疲於優化

方法二,利用Hook提供的useMemo

把創建函數和依賴項數組作為參數傳入useMemo,它僅會在某個依賴項改變時才重新計算 memoized 值,這種優化有助於避免在每次渲染時都進行高開銷的計算。
點擊進入線上代碼沙箱,查看demo>useMemo查看效果

const HookFather = () => {
  console.log("hook>>father component render");

  let [count, setCount] = useState(0);
  let [price, setPrice] = useState(10)

  const handleBtnClick = useCallback(() => {
    setCount(++count)
  }, [count])
    
  // useMemo接收2個參數
  // 參數1: 接收一個內聯回調函數,其中實現我們需要的功能
  // 參數2: 重新計算依賴項數組
  const data = useMemo(() => ({
    name: '計算器',
    price
  }), [price])

  return (
    <>
       <div>{count}</div>
       <button onClick={handleBtnClick}>add</button>
       <HookSon data={data} />
    </>
    );
};

const HookSon = memo(
  ({ data }) => {
    console.log("hook>>son component render");
        
    return <div>this is son hook component, show name: {data.price}</div>;
  }
)

五、關於性能優化的實踐思考

1. 並不是所有函數組件都使用memo包裹就是性能優化!

如果一個子組件過分依賴於父組件的狀態,那么對於這個子組件來說使用memo包裹的意義可有可無,但是memo本身計算對比也是需要時間的。那么,如果某個子組件跟隨父組件重新渲染的次數比例很大,那額外的memo對比時間就成為了負擔,哪怕這個時間非常短。

2. 不要過度依賴於useMemo

useMemo本身也是有開銷的,因為記憶函數本身是將依賴項數組中的依賴取出來,和上一次記錄的值進行對比,如果相等才會節省本次的計算開銷,否則就需要重新執行回調,這個過程本身就是消耗一定的內存和計算資源。

那么,什么時候使用useMemo,思考以下2個問題?

  • 傳遞給useMemo的函數開銷是否大?

有些業務場景的計算開銷會非常大,那么這個時候我們需要去緩存上一次的值,避免每一次父組件重新渲染就進行重新計算;如果開銷並不大,那么可能useMemo本身的開銷就超過了所節省的時間

  • 計算出來的值類型是否是復雜類型?

如果返回的是復雜類型(object、array),由於每次重新渲染哪怕值不變都會生成新的引用,導致子組件重新渲染,那么可以使用useMemo;如果在父組件中使用useMemo計算出來的是基本類型的值,則子組件使用memo就可以淺比較避免重新渲染,無需使用useMemo

放在最后的話:天下沒有免費的午餐,沒有價值的性能優化將會變成性能惡化!

六、參考文獻:

  1. React官方文檔【https://reactjs.org/docs/hello-world.html】
  2. JavaScript高級程序設計【第3版】
  3. 你不知道的JavaScript


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM