React性能優化小貼士

平常在完成業務功能開發後,不知道你是否還會精益求精,做一些性能優化方面的工作呢?React框架中有一些性能優化相關的注意事項,如果平常不怎麼關注的話,可能就很容易忽略掉。接下來的這篇文章,將圍繞工作中會用到的幾種性能優化的相關經驗進行介紹。

Key

在渲染列表結構數據的時候,使用key可以說已經成爲React開發中的最佳實踐了。那麼你知道爲什麼我們要使用key嗎?原因是使用key能夠讓組件保持結構的穩定性。我們都知道React以其DOM Diff算法而著名,在實際比對節點更新的過程中帶有唯一性的key能夠讓React更快得定位到變更的節點,從而可以做到最小化更新。

在實際使用過程中,很多人常常圖方便會直接使用數組的下標(index)作爲key,這是很危險的。因爲經常會對數組數據進行增刪,容易導致下標值不穩定。所以在開發過程中,應該儘量避免這種情況發生。

下面以商品列表組件爲例,演示一下key的使用:

class ShopMenu extends React.Component {
    render() {
        return (
            <ul>
                {
                    this.props.shopItems.map((shopItem) => <ShopItem key={shopItem.id} itemName={shopItem.name}></ShopItem>)
                }
            </ul>
        )
    }
}

數據比對

作爲一款優秀的前端框架,React本身已經爲我們做了很多工作。不過在開發過程中,如果我們能讓組件避免在非必要的情況下重新渲染,就能使開發出的組件性能更良好。

淺比較 shadowEqual

組件在更新過程中,數據比對這一過程是必不可少的,它是觸發組件重新渲染的關鍵。因此,我們有必要深入理解React組件在更新過程中的數據變化機制。React對於狀態更新的比較方式默認都是採用淺比較,我們可以看一下它的源碼實現

/**
 * Performs equality by iterating through keys on an object and returning false
 * when any key has values which are not strictly equal between the arguments.
 * Returns true when the values of all keys are strictly equal.
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true;
}

另外,對於對象做相等比對的is方法,不同於直接使用=====,它針對特殊的+0-0,NaNNaN的比對做了修復,並且不會做隱式轉換。它的實現是像這樣的:

/**
 * inlined Object.is polyfill to avoid requiring consumers ship their own
 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
 */
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y)
  );
}

可以從上面的代碼中看到,對於引用對象來說,淺比較算法首先會使用Object.keys獲取對象所有的屬性,並比對對應的屬性值。不過這裏只會比對第一層的數據,並沒有做遞歸對比。這大概就是叫做"淺比較"的原因吧。

shouldComponentUpdate

對於Class組件來說,我們可以使用shouldComponentUpdate方法來判斷是否進行組件渲染,從而更好地提高頁面性能。這個方式會在每次props和state變化的時候執行,框架對於這個方法的默認實現是直接返回true,即每次只要屬性和狀態變更,組件都會重新渲染。而如果我們對於數據的變更邏輯比較清楚,完全可以手動實現比對過程來避免重複渲染:

class ShopItem extends React.Component {
    shouldComponentUpdate(nextProps, nextState) {
        return this.props.itemName !== nextProps.itemName;
    }
    
    render() {
      return (<div>{this.props.itemName}</div>);
    }
}

pureComponent

要達到性能優化的目的,有時候也不必手動實現shouldComponentUpdate。你只要讓你的組件繼承自React.PureComponent即可,它已經內置了淺比較算法,所以上面的例子可以改寫成:

class ShopItem extends React.PureComponent {
    render() {
        return (<div>{this.props.itemName}</div>);
    }
}

關於箭頭函數

還有一點要記住的是,在使用箭頭函數的時候要小心:

class Button extends React.Component {
    render() {
        return <button onClick={() => {console.log('hello, scq000');}}>click</button>
    }
}

直接在組件上綁定箭頭函數雖然寫法簡便,但由於每次渲染的時候都會重新生成該函數,會導致性能損耗。即使組件的其他props或state沒有變更,由於使用了內聯的箭頭函數也會觸發重新渲染。

所以,爲了避免這種情況的發生,我們可以先聲明好事件監聽函數後,然後再拿到其引用傳給組件:

class Button extends React.Component {
    handleClick = () => {
        console.log('hello, scq000');
    }
    
    render() {
        return <button onClick={this.handleClick}>click</button>
    }
}

useCallback

如果我們使用的是函數式組件,React16中的useCallback的hook爲我們提供了一種新思路:

export const Button = (text, alertMsg) => {
    const handleClick = useCallback(() => {
        // do something with alertMsg
    }, [alertMsg]);
    return (
        <button onClick={handleClick}>{text}</button>
    );
}

將箭頭函數傳入useCallback方法中,這是一個高階函數,它會返回一個記憶化(memoized)的方法。這個方法只有當它所依賴的props或state變化的時候纔會更新。在上面的例子中,當它的依賴狀態alertMsg變化的時候,handleClick函數纔會更新。

在React16中,你可能還會用到useEffect這個Hook來處理一些副作用,就像這樣:

const Student = ({name, age}) => {
    useEffect(() => {
        doSomethingWithInfos(infos)
    }, [name, age]);
    
    return (
        <div>This is a child component.</div>
    );
}

const Person = () => {
    return (<Student name="scq000" age="11" />)
}

useEffect傳入的第二個參數也是它的依賴項,如果這個依賴項中使用的是一個箭頭函數,那麼每次useEffect中的回調函數都會執行。這樣一來結果可能就不是我們想要的了,此時也可以藉助useCallback來避免這種情況的發生。

useCallback雖然能夠緩存函數,但對於大多數場景來說使用它反而會增加垃圾回收和運行封裝函數的時間。只有對於大計算量的函數來說,利用useCallback才能起到良好的優化效果。

useMemo

除了直接緩存函數,有時候還需要緩存數據和計算結果。實現記憶化的關鍵是記住上一次的狀態值和輸出值。我們利用閉包就能實現一個簡化的Memorize方法:

function memorize(func) {
  let lastInput = null;
  let lastOuput = null;
  return function() {
    // 這裏使用淺比較來判斷參數是否一致
    if (!shallowEqual(lastInput, arguments)) {
      lastOuput = func.apply(null, arguments);
    }
    lastInput = arguments;
    return lastOuput;
  }
}

在React中,useMemo hook已經爲我們實現了這個功能,直接使用就可以了:

const calcResult = React.useMemo(() => expensiveCalulate(a, b), [a, b]);

當輸入參數a,b沒有發生變化的時候,會自動使用上一次的值。這也意味着我們使用useMemo只能用來緩存純函數的計算結果。對於大計算量的操作來說,可以有效避免重複計算過程。

React.Memo

針對Functional組件來說,由於缺少shouldComponentUpdate方法,可以考慮用React.Memo來優化組件性能:React.Memo是一個高階組件,它內置了useMemo方法來緩存整個組件。

考慮下面這段代碼:

function Demo() {
    return (
        <Parent props={props}>
            <Child title={title} subtitle={subtitle} />
        </Parent>
    );
}

父組件由於props中的屬性變更重新渲染,即使子組件props沒有變化,子組件Child也會跟着重新渲染。這時候,可以考慮使用React.Memo來緩存子組件:

export function Card({title, subtitle}) {
    // do some render logic
}
export const MemoziedCard = React.Memo(Card);

爲了更深入地理解這部分邏輯,讓我們看一下相關的源碼:

if (updateExpirationTime < renderExpirationTime) {
    // This will be the props with resolved defaultProps,
    // unlike current.memoizedProps which will be the unresolved ones.
    const prevProps = currentChild.memoizedProps;
    // Default to shallow comparison
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      return bailoutOnAlreadyFinishedWork(
        current,
        workInProgress,
        renderExpirationTime,
      );
    }
  }

我們可以看到React.Memo默認情況下也是使用的淺比較算法,所以對於複雜的數據,我們需要自己實現數據比對邏輯。可以在React.Memo傳入第二個參數,就像下面這樣:

const compartor = (prevProps, nextProps) => {
    return prevProps.id === nextProps.id;
}

React.Memo(Card, compartor)

不可變數據Immutable

Immutable是Facebook封裝好的抽象數據結構,由於其結構的不變性和共享性,能讓引用對象在比對的時候更加快速。使用Immutable創建的數據不可變更,因此數據在整個應用中都易於追蹤。這也符合函數式編程的思想。它的核心是採用持久化的數據結構,當改變數據的時候,只會更新變更的那一部分,而數據結構中的不變部分都會公用同一引用,達到結構共享的目的。所以,在高度嵌套的數據進行深拷貝的時候,性能也會更優。

import Immutable from 'immutable';

var obj = Immutable.fromJS({1: "one"});
var map = Immutable.Map({a: 1, b: 2, c: 3});
map.set('b', 4);
var list = Immutable.List.of(1,2,3);
list.push(5);

雖然Immutable JS在性能上有它的優勢,但請注意使用的影響面。不要讓原生對象和Immutable對象進行混用,這樣反而會導致性能下降,因爲將Immutable數據再轉換成原生JS對象在性能上是很差的。關於使用Immutable JS的最佳實踐,可以參考這篇文章

reselect

在使用Redux過程中,組件的狀態數據通常是從state派生出來的,要做很多計算的邏輯。
假設現在我應用中的狀態樹是這樣的:

const state = {
  a: {
    b: {
      c: 'c',
      d: 'd'
    }
  }
};

每次a.b.c更新的時候,即使d沒有更新,所有引用到a.b.d的地方也會重新計算。

那麼,我們在這一步要優化的點,同樣也是使用緩存或記憶化。reselect就是爲了這個目的而生的,它可以幫助我們避免重複的計算:

import {createSelector} from "reselect";

const shopItemSelector = (state) => state.shopItems;
const parentSelector = (state) => state.parent;

export const shopMenuSelector = createSelector(
    [shopItemSelector, parentSelector],
    (shopItems, parent) => {
      // do something with shopItems and parent
    }
);

只有狀態shopItemsparent變化後,纔會重新計算。

默認情況下,新舊屬性的比對也是採用淺比較來進行的。結合上一小節介紹的Immutable,我們可以進一步優化比對過程。

首先是將我們的整個state樹改用Immutable數據結構:

const state = Immutable.fromJS(originState);

接着,改寫派生狀態的時候,使用Immutable中的is進行比對:

import {createSelectorCreator, defaultMemoize} from 'reselect';
import { is } from 'immutable';

const createImmutableSelector = createSelector(defaultMemoize, is);

export const shopMenuSelector = createImmutableSelector(
    [shopItemSelector, parentSelector],
    (shopItems, parent) => {
      // do something with shopItems and parent
    }
);

按需加載

上面介紹的優化方式主要都是圍繞組件渲染機制來展開的,而接下來要介紹的方法是依靠延遲計算思想來優化應用響應性能。雖然並不能達到減少總渲染時間的目的,但可以更快地讓用戶跟頁面進行交互,從而提高應用的用戶體驗。

在React 16之前,我們一般要實現懶加載可以使用react-loadable等庫,但現在可以直接使用React.lazy方法就可以了。本質上它也是通過代碼拆分的方式,讓部分非核心的組件延遲加載。要使用React.lazy還需要配合Suspense組件一起。Suspense組件可以爲懶加載組件提供基本的過渡效果,通常情況下是提供一個loading動畫:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

不過這一策略,目前只支持瀏覽器端。至於使用了SSR的React應用,可以考慮https://github.com/smooth-code/loadable-components來達到相同的目的。

測試性能

著名管理學大師彼得.德魯克(Peter Drucker)曾說過"If you can't measure it, you can't improve it."。雖然這句話是說管理學中的事情,但放在軟件開發中也是同樣適用的。在考慮優化React頁面性能之前,我們必須要做好對應的測試工作,找到性能瓶頸。使用React DevTools Profiler可以檢測組件渲染性能,這個工具可以在谷歌商店下載到。
[圖片上傳失敗...(image-e0f95d-1564019981226)]
更具體的使用方式可以參考https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html

總結

性能優化永遠是軟件開發中的痛點和難點,要學習和實踐的知識有很多,只能說任重而道遠。不過在工作中也並不提倡過早優化。性能雖然是重要的評判標準,但在開發過程中還必須在代碼的可維護性、對未來的適應性等方面做出取捨。應用中並非所有的部分都必須快如閃電,有些部分的可維護性往往更加重要。

如果一定要做性能優化,核心還是在減少頻繁計算和渲染上,在實現策略上主要有三種方式:利用key維持組件結構穩定性、優化數據比對過程和按需加載。其中優化數據比對過程可以根據具體使用的場景,分別使用緩存數據或組件、改用Immutable不可變數據等方式進行。最後,也一定記得要採用測試工具進行前後性能對比,來保障優化工作的有效性。

參考文章

http://www.ayqy.net/blog/react-suspense/

https://zhuanlan.zhihu.com/p/56975681

https://codeburst.io/memorized-function-a-simple-implementation-of-reselect-5454f1a1523c

https://blog.bitsrc.io/lazy-loading-react-components-with-react-lazy-and-suspense-f05c4cfde10c

https://kentcdodds.com/blog/usememo-and-usecallback

https://reactjs.org/docs/optimizing-performance.html#profiling-components-with-the-chrome-performance-tab

https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html

https://redux.js.org/recipes/using-immutablejs-with-redux

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章