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 的副作用管理之難
面臨上述需求的時候,我們藉助了兩種方案,但各有缺點。
-
componentWillReceiveProps
:React 16.4
中將componentWillReceiveProps
定義爲了unsafe
的方法,因爲這個方法容易被開發者濫用,引入很多副作用。正如 React 官方文檔_unsafe_componentwillreceiveprops 提到的,副作用通常建議發生在
componentDidUpdate
。但這會造成多一次的渲染,且寫法詭異。 -
getDerivedStateFromProps
和componentDidUpdate
:作爲替代方案的
getDerivedStateFromProps
是個靜態方法,也需要結合componentDidUpdate
,判斷是否需要進行必要的render
,本質上沒有發生太多改變。getDerivedStateFromProps
可以認爲是增加了靜態方法限制的componentWillReceiveProps
,它們在生命週期中觸發的時機是相似的,都起到了接收新的props
並更新的作用。
甚至當依賴項增多的時候,上述兩種方式將會提升代碼的複雜度,我們會耗費大量的精力去思考狀態的比較以及副作用的管理。而 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.memo
、useCallback
和 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);
所以對於函數式組件來說,若實現中擁有 useState
、useReducer
或 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
, 那大概就是:
-
React.memo()
:緩存虛擬 DOM(組件 UI) -
useCallback
:緩存函數 -
useMemo
:緩存值
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/U4dKSNfD1dPsMemu_HAaqA