React性能優化,六個小技巧教你減少組件無效渲染

壹 ❀ 引

在過去的一段時間,我一直圍繞項目中體驗不好或者無效渲染較為嚴重的組件做性能優化,多少積累了一些經驗所以想着整理成一片文章,下圖就是優化後的一個組件,可以對比優化前一次切換與優化後多次切換的渲染顏色深度與按鈕的切換速度:

關於減少組件無效渲染,與其說是提幾點建議,不如說是在優化過程中所記錄的一些不規範的寫法,能寫出更好的代碼總是更棒的,也希望這幾點建議能對大家能有些許幫助。當然,以下建議不管class組件還是hooks中其實都會犯,所以都有參考意義,那麼本文開始。

貳 ❀ 減少無效渲染

在介紹如何減少無效渲染之前,我覺得有必要先聲明兩點,這對於理解如何減少無效渲染很有幫助:

第一點,所有的無效渲染都是由於數據的不穩定所造成的,而這裡的不穩定一般分為兩種,一種情況是數據前後不管是值還是引用都是完全相同的,可以說就是同一份數據;另一種是前後數據引用每次都不同,但它們的數據結構和值看起來又完全一樣。所以對於第一種情況,既然你的值完全相同那我們完全沒必要重複渲染,而對於第二種情況當你的值的結構沒變化時我們保證其引用穩定就好了。

說到這裡有同學可能就會想,那是不是組件內只要產生新引用數據的行為就不對呢?其實並不是,當數據本身就應該更新時,它在這一刻產生一個全新的引用很合情合理,不然我們項目里什麼filter、map之類的豈不是都用不了。你也可以想想常見的state更新,我們更新state時本身也是得傳入一個全新的對象而不是直接修改,所以要更新時產生新對象很合理:

const App = () => {
  const [state, setState] = useState({ name: "聽風", age: 29 });

  const handleClick = () => {
    // 錯誤做法,直接修改 state, 不會更新
    // state.name = '行星飛行';
    // 正確做法就得重新賦予一個全新的對象,不然 state 不會更新
    setState({ ...state, name: '行星飛行' });
  }

  return (
    <div>
      <div>{state.name}</div>
      <div>{state.age}</div>
      <button onClick={handleClick}>change name</button>
    </div>
  )
};

第二,當我們發現某個組件無效渲染嚴重時,你的關注點應該往上看,簡單來說,我們的優化應該自上而下,當上層組件數據穩定了,在做下層組件優化時會方便很多,不然你在某個中間組件一頓操作,結果數據加工的入參自身就不穩定,這就很頭疼。

強調了這兩點,我們正式來了解如何減少無效渲染。

貳 ❀ 壹 合理使用memo與PureComponent

我們知道class組件的PureComponent以及函數組件的memo都具有淺比較的作用,所謂淺比較就是直接比較前後兩個數據是否相等,比如:

const a = [];
const b = a;
// 因為 a b 引用和值都相同,所以相等
a === b; // true
const c = [];
// 雖然 c 也是空數組,但是引用不同所以不相等
a === c; // false

我們假設組件前後都接收了一個空數組,且它們引用也相同,那麼此時如果我們組件套用了memo,那麼組件就不會因為這個完全相同的數據重複渲染。因為PureComponentmemo效果相同,這裡我寫了一個在線的memo例子方便大家理解效果,大家可以點擊按鈕查看控制台,直接對比加與不加memo的差異。

在這個例子中,我在組件外層定義了一份引用始終相同的數據user,之後通過點擊按鈕故意改變父組件P的狀態讓其渲染,以此帶動子組件C1 C2渲染,可見加了memoC2除了初次渲染之後並不會跟隨父組件重複渲染,這就是memo的作用。

當然,假設我們的user每次都是重新創建的新對象,那我們加了memo也沒任何作用,畢竟引用不同淺比較判斷為false,還是會重複渲染。

另外,請合理使用這兩個api,並不是所有場景都需要這麼做,假設你的組件的數據流足夠簡單甚至沒有props,你完全沒必要在組件外層套一層memo

其次,某些情況下,因為我們為組件嵌套了memo會導致我們通過react插件查看組件時顯示組件名為Unkown,不利於調試,所以如果你要用,你可以通過displayName顯示聲明組件的名稱,比如:

import React, { memo } from 'react';
const Echo = memo((props) => {});
// 某些情況下,使用 memo 會導致控制台調試時組件名顯示為 Unkown ,可通過 displayName 顯示聲明組件名解決
Echo.displayName = 'Echo';
export {Echo};

在了解了如何減少引用相同引用時的無效渲染,接下來看看那些造成引用不同的問題場景。

貳 ❀ 貳 props直接傳遞新對象

第一種也是最直接也最容易看出來的一種不規範寫法,一般存在於對於react不太了解的新人或者一些老舊代碼中,比如:

const App = () => {
  return (
    // 這裡每次都會傳遞一個新的空數組過去,導致Child每次都會渲染,加了 memo 都救不了
    <Child userList={[]} />
  )
};

當然,它也可能不是一個空數組,但註定每次都是一個全新引用的數據:

const App = (props) => {
  return (
    <Child userList={[...props.list]} />
  )
};

貳 ❀ 叄 不穩定的默認值

正常來說,比如子組件的userList屬性規定類型是數組,在父組件加工數據時提供數據默認值是非常好的習慣,於是我常常在組件內部或者mapStateToProps中看到類似的寫法:

const App = (props) => {
  // 當存在時賦予空數組,保證下層數組類型的正確性
  const userList = props.userList || [];
  return (
    <Child userList={userList} />
  )
};

App多次渲染且props.userList為假值時,此時的userList也會被不斷的賦予全新的空數組。還記得前文說的嗎,當你結構沒變化時,我們保證其引用不變不就好了,所以結合問題一與問題二,對於空數組都可以在全局賦予一個空數組,比如:

const emptyArr = [];
const App = (props) => {
  // 當存在時賦予空數組,保證下層數組類型的正確性
  const userList = props.userList || emptyArr;
  return (
    <Child userList={userList} />
  )
};

這樣不管App如何渲染,當userList被賦予為空數組時也能讓前後引用相同。

貳 ❀ 肆 合理使用useMemo與useCallback

我們知道useMemouseCallback都能起到緩存的作用,比如下面這個例子:

// 只要 App 自身重複渲染,此時 handleClick 與 list 都會重新創建,導致引用不同,所以 C 即便加了 memo 還是會重複渲染
const App = (props)=> {
    const handleClick = () => {};
    const fn = () => {}
    const list = [];
    const user = userList.filter();
    return <C onClick={handleClick} list={list} user={user} />
}

只要組件App自身重複渲染,組件內的這些屬性方法本質上會被重新創建一遍,這就導致子組件C即便添加memo也無濟於事,所以對於函數組件而言,一般要往下傳遞的數據我們可以通過useMemouseCallback包裹,保證其引用穩定性。當然,如果一份數據只是App組件自己用,那就沒必要特意包裹了:

// 常量提到外層,保證引用唯一
const list = [];

const App = ()=> {
    // 使用 useCallback 緩存函數
    const handleClick = useCallback(() => {});

    // 只是自己使用,不作為props傳遞時,沒必要使用 useCallback 嵌套
    const handleOther = () => {}

    // 使用 useMemo 緩存結果
    const user = useMemo(()=>{
        return userList.filter();
    },[userList])

    return <C onClick={handleClick} list={list} user={user} />
}

一般useMemo、useCallbackmemo會聯合使用,既然你的下層組件都會做淺比較,我們儘可能穩定上層數據引用的穩定性就很有必要;而假設組件連memo都沒有,即便我們做了緩存子組件還是一樣會重複渲染,所以要不要用以及為何而用大家一定要搞清楚。

貳 ❀ 伍 更穩定的useSelector

我們可以使用useSelector監聽全局store的變化並從中取出我們想要的數據,而相同的數據獲取如果是在class組件中則應該寫在mapStateToProps中,但不管哪種寫法,當我們從state中獲取數據後就應該注意保持數據的穩定性,來看個例子:

const userList = useSelector((state) => {
  const users = state.userList;
  return users.filter((user) => user.age > 18);
});

在上述例子中,我們從state中獲取了userList,之後又進行了數據加工過濾出年齡大於18的用戶,這個寫法看似沒什麼問題,但事實上全局state的狀態並沒有我們的想的那麼穩定,所以useSelector執行的次數要比你想的要多,此時只要useSelector執行一次,我們都會從state中獲取數據,並通過filter加工成一個全新的數組,這對於子組件而言是非常致命的。

如何改善呢?其實很簡單,將加工的行為提到外部即可,比如:

const users = useSelector((state) => {
  return state.userList;
});

const userList = useMemo(() => {
  return users.filter(user => user.age > 18);
}, [users])

有同學可能就要說了,這不對吧,此時的useSelector每次都返回state.userList難道不是一個全新的對象?那useMemo不還是每次都會執行,導致userList每次都是全新的數組嗎?其實並不是。

對於redux而言,我們可以將整個react appstore理解成一顆巨大的樹,而樹有很多分支的樹根,每一枝樹根都可以理解成某個組件所依賴的state,那麼請問假設A組件的樹根被更新了,它會對store的其它樹根的引用造成影響嗎?此時樹還是這顆樹啊,而那些沒變的樹根依舊是之前的樹根。

我們可以通過下面的例子來理解這個過程:

const store = {
  A: [],
  B: {},
};
const b = store.B;
store.A = [1, 2];
const c = store.B;
c === b; // true

所以回到上文的代碼,假設state中關於state.userList就沒有變化,那麼前後不管取多少次,因為引用相同,useMemo除了初始化會執行一次之外,之後都不會重新執行,這就能讓userList徹底穩定下來。

而假設我們因為成員接口讓state.userList進行了更新,正常來說應該在reducer中重新生成一個新數組再賦予給store,那麼在下次useSelector執行時,我們也能拿到全新引用的users,而監聽usersuseMemo就能按照正確的預期再度更新了。

其實在實際項目中,大家可能還有使用useSelector + createSelector的用法,useSelector用於監聽statecreateSelector中負責提取state中的部分數據進行加工以及緩存,所以我在我們項目中多次看到類似如下的代碼:

const userList = useSelector((state) => {
  // 這裡你就理解成一個具有緩存效果的函數就好了,入參不變結果就不變
  const users = userSelector.getUser(state, userIDs);
  return users.filter(user => user.age > 18);
});

本來userSelector就具備緩存的作用,當你userIDs沒變化時,我失蹤走緩存給你穩定的數據,結果前面剛幫你穩定完,後面通過filter又產生了一個全新的數據,數據的穩定性直接被破壞了,所以改法還是跟之前一樣:

const users = useSelector((state) => {
  return userSelector.getUser(state, userIDs);
});

const userList = useMemo(() => {
  return users.map(user => user.age > 18);
}, [users])

貳 ❀ 陸 貪心的createSelector

既然上文提到了createSelector,這裡再講一個項目中大家很容易犯的錯誤寫法。前文已經說了createSelector你可以理解成一個緩存函數,只是一般我們會將其與state掛鈎,用於在加工state時初步做一些緩存,但實際開發中我發現了部分同事寫了類似這樣的代碼:

export const getUserListSelector = createSelector([a, b], (a, b) => {
  // 一些加工
});

這裡我們定義了一個名為getUserListSelector的方法,它接受a,b兩個參數,只要這兩個參數不變,那麼緊跟其後的callback就不會重複執行,這樣就起到緩存的作用。

但同事在調用時是這麼用的:

// a場景
const users = useSelector((state) => {
  return getUserListSelector(state, userIDs);
});

// b場景
const users = useSelector((state) => {
  return getUserListSelector(state);
});

簡單來理解就是,其實存在兩種取數據的場景,我們將其糅合到了getUserListSelector中,當a場景時我們需要傳遞兩個參數,而b場景我們只用一個即可,有問題嗎?有很大的問題。

createSelector這個東西與傳統的緩存函數不同,一般的緩存函數是,只要你的參數不同,我們就用你參數的作為key,讓結果作為value存起來,對應到上面的場景執行兩次後,我們最終緩存可能是這樣:

const cache = {
  a-b:value1,
  a:value2
}

兩次入參不同,導致2次不同的緩存結果。但很尷尬的是,createSelector永遠只緩存最新一次的緩存結果,也就是說對於上述createSelector只要a b兩個場景都會調用,那麼這個最終的數據永遠都穩定不下來,兩個場景始終會影響彼此。

怎麼解決呢?解耦即可,我們應該讓createSelector去取更穩定的數據,即便這個數據不夠精準,返回後再分別在a b兩個場景中單獨去加工,為什麼強調這一點呢?要知道createSelector經常存在嵌套關係,某個selector可能是另一個selector的入參,假設上述這個不穩定的selector返回的數據又成了其它selector的參數,這就會導致多條數據源全部不穩定,這是非常糟糕的。

OK,關於如何提升數據的穩定性,我們先介紹到這裡,我也相信通過本文的閱讀對於你之後的開發多少會有一些清晰的幫助。

叄 ❀ 如何排查不穩定數據?

其實聊到這裡,我想大家多少都有了一些體會,可能有同學就想提問了,我自己新寫一個組件我知道如何去規避這些點,那一個現有的組件假設渲染很嚴重,我又該如何去排查是哪些不穩定的數據導致了重複渲染呢?

方法肯定是有的,我們可以藉助why-did-you-update或者why-did-you-render性能監測庫,具體用法可見GitHub文檔,這裡就不贅述,當配置好後打開控制台刷新頁面,你就能看到對應組件重複渲染的原因,比如:

上圖就是因為新舊props引用不同所導致的無效渲染,至於如何減少無效渲染我想你現在也有了一些答案。

除了上面這兩個庫之外,我們還能利用react官網的插件Profiler,我們可以點擊設置將記錄組件渲染原因的選項勾上:

之後通過點擊錄製組件的渲染,hover到對應的組件上去就知道渲染的原因了,這也是一種排查手段,比如有如下組件:

const C = memo((props) => {
  return <div>{props.num}</div>
});

const Parent = () => {
  const [state, setState] = useState(1);
  const handleClick = () => {
    setState(state + 1);
  };
  return (
    <>
      <C num={state} />
      <button onClick={handleClick}>changeState</button>
    </>
  );
}

我們點擊父組件的按鈕每次修改state,並將state傳遞給子組件C,通過錄製,我們可以很清晰的看到是因為props num變化所導致的渲染。

有了排查手段以及修復手段,其實只要大家耐心和細心,我相信大家都能寫出更優雅的組件。

叄 ❀ 總

那麼到這裡,本文圍繞如何減少無效渲染的介紹就結束了,其實說了那麼多,核心點還是關注在如何保證數據引用的穩定性,除此之外,我們也順帶介紹了幾點如何排查組件渲染的小技巧。下周就離職回武漢了,希望一切能順利,本文結束。

Tags: