React 組件性能優(yōu)化——function component
1. 前言
函數(shù)式組件是一種非常簡潔的數(shù)據(jù)驅(qū)動(dòng) UI 的實(shí)現(xiàn)方式。如果將 React 組件拆分成三個(gè)部分 —— 數(shù)據(jù)、計(jì)算和渲染,我們可以看到性能優(yōu)化的幾個(gè)方向。

數(shù)據(jù):利用緩存,減少
rerender的次數(shù)計(jì)算:精確判斷更新時(shí)機(jī)和范圍,減少計(jì)算量
渲染:精細(xì)粒度,降低組件復(fù)雜度
今天主要分享數(shù)據(jù)層面的性能優(yōu)化技巧。
1.1. 有什么是 Hook 能做而 class 做不到的?
在學(xué)習(xí) React hook api 的過程中,發(fā)現(xiàn)其相比類組件的生命周期,更加抽象且靈活。在 React 官方文檔的 FAQ 中,有一個(gè)非常有趣的問題 —— 有什么是 Hook 能做而 class 做不到的?
前陣子我終于找到了其中一個(gè) 參考答案 ,此前在開發(fā)一個(gè)需求時(shí),需要通過 url 或緩存?zhèn)鬟f一個(gè) 參數(shù) 給新打開的 Tab。當(dāng) Tab 下的頁面開始加載時(shí),會(huì)去讀取這個(gè) 參數(shù),并且使用它去做一些請求,獲取更多的信息進(jìn)行渲染。
最初拿到這個(gè)需求時(shí),我使用了 類組件 去開發(fā),但實(shí)踐過程中發(fā)現(xiàn)編寫出的代碼不易理解和管理。最后重構(gòu)為 函數(shù)式組件,讓代碼簡潔了許多。
1.2. 一個(gè)不好的 ??( getDerivedStateFromProps + componentDidUpdate )
最初我通過 getDerivedStateFromProps 和 componentDidUpdate 這兩個(gè)生命周期。其中 getDerivedStateFromProps 去實(shí)現(xiàn) props 的前后對比, componentDidUpdate 控制組件去請求和更新。
首先我們有一個(gè)來自于 url 和緩存的參數(shù),叫做 productId,也可以叫做 商品id,它在發(fā)生更新后如何通知父組件,這一點(diǎn)我們不需要在意。現(xiàn)在父組件被通知 商品id 發(fā)生了更新,于是通過 props 將其傳遞給了子組件,也就是我們的頁面容器。
function Parent() {
/**
* 通過某種方式,監(jiān)聽到 productId 更新了
*/
return <Child productId={productId} />
}
在父組件改變 props 中的 商品id 時(shí),我們的子組件通過 getDerivedStateFromProps 去監(jiān)聽,經(jīng)過一段比較邏輯,發(fā)生改變則更新 state 觸發(fā)組件的重新渲染。
// 監(jiān)聽 props 變化,觸發(fā)組件重新渲染
static getDerivedStateFromProps(nextProps, prevState) {
const { productId } = nextProps;
// 當(dāng)傳入的 productId 與 state 中的不一致時(shí),更新 state
if (productId !== prevState.productId) {
// 更新 state,觸發(fā)重新渲染
return { productId };
}
return null;
}
接下來,因?yàn)?nbsp;商品id 發(fā)生了更新,組件需要再發(fā)一次請求去更新并重新渲染 商品 的詳情信息。
componentDidUpdate(prevProps, prevState) {
/**
* state 改變,重新請求
* PS: 細(xì)心的你可能發(fā)現(xiàn)這里又跟舊的 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,
});
};
到這里就實(shí)現(xiàn)了我們的需求,但這份代碼其實(shí)有很多不值得參考的地方:
1、componentDidUpdate 中的 setState —— 出于更新 UI 的需要,在 componentDidUpdate 中又進(jìn)行了一次 setState,其實(shí)是一種危險(xiǎn)的寫法。假如沒有包裹任何條件語句,或者條件語句有漏洞,組件就會(huì)進(jìn)行循環(huán)更新,隱患很大。
2、分散在兩個(gè)生命周期中的兩次數(shù)據(jù)比較 —— 在一次更新中發(fā)生了兩次 state 的比較,雖然性能上沒有太大影響,但這意味著修改代碼時(shí),要同時(shí)維護(hù)兩處。假如比較邏輯非常復(fù)雜,那么改動(dòng)和測試都很困難。
3、代碼復(fù)雜度 —— 僅僅是 demo 就已經(jīng)編寫了很多代碼,不利于后續(xù)開發(fā)者理解和維護(hù)。
1.3. 另一個(gè)不好的 ??( componentWillReceiveProps )
上面的 ?? 中,導(dǎo)致我們必須使用 componentDidUpdate 的一個(gè)主要原因是,getDerivedStateFromProps 是個(gè)靜態(tài)方法,不能調(diào)用類上的 this,異步請求等副作用也不能在此使用。

為此,我們不妨使用 componentWillReceiveProps 來實(shí)現(xiàn),在獲取到 props 的時(shí)候就能直接發(fā)起請求,并且 setState。
componentWillReceiveProps(props) {
const { productId } = props;
if (`${productId}` === 'null') {
// 不請求
return;
}
if (productId !== this.state.productId) {
// 商品池詳情的id發(fā)生改變,重新進(jìn)行請求
this.getProductDetailRequest(productId);
}
}
將邏輯整合到一處,既實(shí)現(xiàn)了可控的更新,又能少寫很多代碼。
但這僅限 React 16.4 之前。
1.4. class component 的副作用管理之難
面臨上述需求的時(shí)候,我們借助了兩種方案,但各有缺點(diǎn)。
componentWillReceiveProps:React 16.4中將componentWillReceiveProps定義為了unsafe的方法,因?yàn)檫@個(gè)方法容易被開發(fā)者濫用,引入很多副作用。正如 React 官方文檔_unsafe_componentwillreceiveprops 提到的,副作用通常建議發(fā)生在
componentDidUpdate。但這會(huì)造成多一次的渲染,且寫法詭異。getDerivedStateFromProps和componentDidUpdate:作為替代方案的
getDerivedStateFromProps是個(gè)靜態(tài)方法,也需要結(jié)合componentDidUpdate,判斷是否需要進(jìn)行必要的render,本質(zhì)上沒有發(fā)生太多改變。getDerivedStateFromProps可以認(rèn)為是增加了靜態(tài)方法限制的componentWillReceiveProps,它們在生命周期中觸發(fā)的時(shí)機(jī)是相似的,都起到了接收新的props并更新的作用。
甚至當(dāng)依賴項(xiàng)增多的時(shí)候,上述兩種方式將會(huì)提升代碼的復(fù)雜度,我們會(huì)耗費(fèi)大量的精力去思考狀態(tài)的比較以及副作用的管理。而 React 16.8 之后的 函數(shù)式組件 和 hook api,很好地解決了這一痛點(diǎn)。看看使用了 函數(shù)式組件 是怎樣的:
function Child({ productId }) {
const [productDetail, setProductDetail] = useState({});
useEffect(() => {
const { result } = await getProductDetail({
f_id: +productId,
});
setProductDetail(result);
}, [productId]);
return <>......</>;
}
相比上面兩個(gè)例子,是不是簡單得多?上面的 useEffect() 通過指定依賴項(xiàng)的方式,把令人頭疼的副作用進(jìn)行了管理,僅在依賴項(xiàng)改變時(shí)才會(huì)執(zhí)行。
到這里,我們已經(jīng)花了很長的篇幅去突出 函數(shù)式組件 的妙處。我們能夠發(fā)現(xiàn),函數(shù)式組件 可以讓我們更多地去關(guān)注數(shù)據(jù)驅(qū)動(dòng),而不被具體的生命周期所困擾。在 函數(shù)式組件 中,結(jié)合 hook api,也可以很好地觀察組件性能優(yōu)化的方向。
這里我們從數(shù)據(jù)緩存的層面,介紹一下函數(shù)式組件的三個(gè)性能優(yōu)化方式 —— React.memo、useCallback 和 useMemo。
2. 函數(shù)式組件性能優(yōu)化
2.1. 純組件(Pure Componet)
純組件(Pure Component)來源于函數(shù)式編程中純函數(shù)(Pure Function)的概念,純函數(shù)符合以下兩個(gè)條件:
其返回值僅由其輸入值決定
對于相同的輸入值,返回值始終相同
類似的,如果 React 組件為相同的 state 和 props 呈現(xiàn)相同的輸出,則可以將其視為純組件。
2.1.1. 淺層比較
根據(jù)數(shù)據(jù)類型,淺層比較分為兩種:
基本數(shù)據(jù)類型:比較值是否相同
引用數(shù)據(jù)類型:比較內(nèi)存中的引用地址是否相同
淺層比較這一步是優(yōu)先于 diff 的,能夠從上游阻止重新 render。同時(shí)淺層比較只比較組件的 state 和 props,消耗更少的性能,不會(huì)像 diff 一樣重新遍歷整顆虛擬 DOM 樹。
淺層比較也叫 shallow compare,在 React.memo或 React.PureComponent出現(xiàn)之前,常用于 shouldComponentUpdate 中的比較。
2.1.2. 純組件 api
對組件輸入的數(shù)據(jù)進(jìn)行淺層比較,如果當(dāng)前輸入的數(shù)據(jù)和上一次相同,那么組件就不會(huì)重新渲染。相當(dāng)于,在類組件的 shouldComponentUpdate() 中使用淺層比較,根據(jù)返回值來判斷組件是否需要渲染。
純組件適合定義那些 props 和 state 簡單的組件,實(shí)現(xiàn)上可以總結(jié)為:類組件繼承 PureComponent 類,函數(shù)組件使用 memo 方法。
2.1.3. PureComponent
PureComponent 不需要開發(fā)者自己實(shí)現(xiàn) shouldComponentUpdate(),就可以進(jìn)行簡單的判斷,但僅限淺層比較。
import React, { PureComponent } from 'react';
class App extends PureComponent {}
export default App;
假如依賴的引用數(shù)據(jù)發(fā)生了深層的變化,頁面將不會(huì)得到更新,從而出現(xiàn)和預(yù)期不一致的 UI。當(dāng) props 和 state 復(fù)雜,需要深層比較的時(shí)候,我們更推薦在 Component 中自行實(shí)現(xiàn) shouldComponentUpdate()。
此外,React.PureComponent 中的 shouldComponentUpdate() 將跳過所有子組件樹的 prop 更新。因此,請確保所有子組件也都是純組件。
2.1.4. React.memo
React.memo 是一個(gè)高階組件,接受一個(gè)組件作為參數(shù)返回一個(gè)新的組件。新的組件僅檢查 props 變更,會(huì)將當(dāng)前的 props 和 上一次的 props 進(jìn)行淺層比較,相同則阻止渲染。
function MyComponent(props) {
/* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
/*
memo 的第二個(gè)參數(shù)
可以傳入自定義的比較邏輯(僅比較 props),例如實(shí)現(xiàn)深層比較
ps:與 shouldComponentUpdate 的返回值相反,該方法返回 true 代表的是阻止渲染,返回 false 代表的是 props 發(fā)生變化,應(yīng)當(dāng)重新渲染
*/
}
export default React.memo(MyComponent, areEqual);
所以對于函數(shù)式組件來說,若實(shí)現(xiàn)中擁有 useState、useReducer 或 useContext 等 Hook,當(dāng) state 或 context 發(fā)生變化時(shí),即使 props 比較相同,組件依然會(huì)重新渲染。所以 React.memo,或者說純組件,更適合用于 renderProps() 的情況,通過記憶輸入和渲染結(jié)果,來提高組件的性能表現(xiàn)。
2.1.5. 總結(jié)
將類組件和函數(shù)組件改造為純組件,更為便捷的應(yīng)該是函數(shù)組件。React.memo() 可以通過第二個(gè)參數(shù)自定義比較的邏輯,以高階函數(shù)的形式對組件進(jìn)行改造,更加靈活。
2.2. useCallback
在函數(shù)組件中,當(dāng) props 傳遞了回調(diào)函數(shù)時(shí),可能會(huì)引發(fā)子組件的重復(fù)渲染。當(dāng)組件龐大時(shí),這部分不必要的重復(fù)渲染將會(huì)導(dǎo)致性能問題。
// 父組件傳遞回調(diào)
const Parent = () => {
const [title, setTitle] = useState('標(biāo)題');
const callback = () => {
/* do something to change Parent Component‘s state */
setTitle('改變標(biāo)題');
};
return (
<>
<h1>{title}</h1>
<Child onClick={callback} />
</>
)
}
// 子組件使用回調(diào)
const Child = () => {
/* onClick will be changed after Parent Component rerender */const { onClick } = props;
return (
<>
<button onClick={onClick} >change title</button>
</>
)
}
props 中的回調(diào)函數(shù)經(jīng)常是我們會(huì)忽略的參數(shù),執(zhí)行它時(shí)為何會(huì)引發(fā)自身的改變呢?這是因?yàn)榛卣{(diào)函數(shù)執(zhí)行過程中,耦合了父組件的狀態(tài)變化,進(jìn)而觸發(fā)父組件的重新渲染,此時(shí)對于函數(shù)組件來說,會(huì)重新執(zhí)行回調(diào)函數(shù)的創(chuàng)建,因此給子組件傳入了一個(gè)新版本的回調(diào)函數(shù)。
解決這個(gè)問題的思路和 memo 是一樣的,我們可以通過 useCallback 去包裝我們即將傳遞給子組件的回調(diào)函數(shù),返回一個(gè) memoized 版本,僅當(dāng)某個(gè)依賴項(xiàng)改變時(shí)才會(huì)更新。
// 父組件傳遞回調(diào)
const Parent = () => {
const [title, setTitle] = useState('標(biāo)題');
const callback = () => {
/* do something to change Parent Component‘s state */
setTitle('改變標(biāo)題');
};
const memoizedCallback = useCallback(callback, []);
return (
<>
<h1>{title}</h1>
<Child onClick={memoizedCallback} />
</>
)
}
// 子組件使用回調(diào)
const Child = (props) => {
/* onClick has been memoized */const { onClick } = props;
return (
<>
<button onClick={onClick} >change title</button>
</>
)
}
此外,使用上, useCallback(fn, deps) 相當(dāng)于 useMemo(() => fn, deps)。
2.3. useMemo
React.memo() 和 useCallback 都通過保證 props 的穩(wěn)定性,來減少重新 render 的次數(shù)。而減少數(shù)據(jù)處理中的重復(fù)計(jì)算,就需要依靠 useMemo 了。
首先需要明確,useMemo 中不應(yīng)該有其他與渲染無關(guān)的邏輯,其包裹的函數(shù)應(yīng)當(dāng)專注于處理我們需要的渲染結(jié)果,例如說 UI 上的文本、數(shù)值。其他的一些邏輯如請求,應(yīng)當(dāng)放在 useEffect 去實(shí)現(xiàn)。
function computeExpensiveValue() {
/* a calculating process needs long time */
return xxx
}
const memoizedValue = useMemo(computeExpensiveValue, [a, b]);
如果沒有提供依賴項(xiàng)數(shù)組,useMemo 在每次渲染時(shí)都會(huì)計(jì)算新的值。
以階乘計(jì)算為例:
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);
}
經(jīng)過 useMemo 封裝,factorial 成為了一個(gè)記憶值。當(dāng)我們點(diǎn)擊重新渲染的按鈕時(shí),inc 發(fā)生了改變引起函數(shù)式組件的 rerender,但由于依賴項(xiàng) number 未發(fā)生改變,所以 factorial 直接返回了記憶值。
3. 總結(jié)
1、通過 函數(shù)式組件 結(jié)合 hook api,能夠以更簡潔的方式管理我們的副作用,在涉及到類似前言的問題時(shí),更推薦把組件改造成函數(shù)式組件。
2、用一個(gè)通俗的說法去區(qū)分 React.memo 、useCallback 和 useMemo , 那大概就是:
React.memo():緩存虛擬 DOM(組件 UI)useCallback:緩存函數(shù)useMemo:緩存值

