React重新渲染超詳細講解

Web 前端開發者對渲染和重新渲染應該不陌生,在 React 中,它們究竟是什麼意思?

  • 渲染:React 讓組件根據當前的 props 和 state 描述它要展示的內容。
  • 重新渲染:React 讓組件重新描述它要展示的內容。

要將組件顯示到屏幕上,React 的工作主要分為兩個階段,本文介紹與 React 渲染相關的知識。

  • render 階段(渲染階段):計算組件的輸出並收集所有需要應用到 DOM 上的變更。
  • commit 階段(提交階段):將 render 階段計算出的變更應用到 DOM 上。

在 commit 階段 React 會更新 DOM 節點和組件實例的 ref,如果是類組件,React會同步運行 componentDidMount 或 componentDidUpdate 生命周期方法,如果是函數組件,React會同步運行 useLayoutEffect 勾子,當瀏覽器繪制 DOM 之後,再運行所有的 useEffect 勾子。

React 重新渲染

初始化渲染之後,下面的這些原因會讓React重新渲染組件:

類組件

  • 調用 this.setState 方法。
  • 調用this.forceUpdate方法。

函數組件

  • 調用 useState 返回的 setState。
  • 調用 useReducer 返回的 dispatch。

其他

  • 組件訂閱的 context value 發生變更
  • 重新調用 ReactDOM.render(<AppRoot>)

假設組件樹如下

默認情況,如果父組件重新渲染,那麼 React 會重新渲染它所有的子組件。當用戶點擊組件 A 中的按鈕,使 A 組件 count 狀態值加1,將發生如下的渲染流程:

  • React將組件A添加到重新渲染隊列中。
  • 從組件樹的頂部開始遍歷,快速跳過不需要更新的組件。
  • React發生A組件需要更新,它會渲染A。A返回B和C
  • B沒有被標記為需要更新,但由於它的父組件A被渲染瞭,所以React會渲染B
  • C沒有被標記為需要更新,但由於它的父組件A被渲染瞭,所以React會渲染C,C返回D
  • D沒有標記為需要更新,但由於它的父組件C被渲染瞭,所以D會被渲染。

在默認渲染流程中,React 不關心子組件的 props 是否改變瞭,它會無條件地渲染子組件。很可能上圖中大多數組件會返回與上次完全相同的結果,因此 React 不需要對DOM 做任何更改,但是,React 仍然會要求組件渲染自己並對比前後兩次渲染輸出的結果,這兩者都需要時間。

Reconciliation

Reconciliation 被稱為 diff 算法,它用來比較兩顆 React 元素樹之間的差異,為瞭讓組件重新渲染變得高效,React 盡可能地復用現有的組件和 DOM。為瞭降低時間復雜度,Diff 算法基於如下兩個假設:

  • 兩個不同類型的元素對應的元素樹完全不同。
  • 在同一個列表中,如果兩個元素key屬性的值相同,那麼它們被識別為同一個元素。

元素類型對 Diff 的影響

React 使用元素的 type 字段比較元素類型是否相同,如果兩顆樹在相同位置要渲染的元素類型相同,那麼 React 就重用這些元素,並在適當的時候更新,不需要重新創建元素,這意味著,隻要一直要求 React 將某組件渲染在相同的位置,那麼 React 始終不會卸載該組件。如果相同位置的元素類型不同,例如從 div 到 span 或者從ComponentA 到 ComponentB,React會認為整個樹發生瞭變化,為瞭加快比較過程,React 會銷毀整個現有的組件樹,包括所有的 DOM 節點,然後重新創建元素。

瀏覽器內置元素的 type 字段是一個字符串,自定義組件元素的 type 字段是一個類或者函數,由於元素類型對 Diff的影響,所以在渲染期間不要創建組件,隻要創建一個新的組件,那麼它的 type 字段就是不同的引用,這將導致 React 不斷地銷毀並重新創建子組件樹。不要有如下的代碼:

function ParentCom() {
  // 每一次渲染 ParentCom 時,都會創建新的ChildCom組件
  function ChildCom() {/**do something*/}
  return <ChildCom />
}

上述代碼不推薦,正確的做法是將 ChildCom 放在ParentCom 的外面。

key 對 Diff 的影響

React 識別元素的另一種方式是通過 key 屬性,key 作為組件的唯一標識符不會當作prop傳遞到組件中,可以給任何組件添加一個 key 屬性來標註它,更改 key 的值會導致舊的組件實例和 DOM 被銷毀。

列表是使用 key 屬性的主要場景,在 React 官方文檔中提到,不要將數組的下標作為 key 值,而是用數據唯一 ID 作為 key 值。在這裡分別介紹這兩種方式的區別。

假如 Todo List 中有 10 項,先用數組下標作為 key 的值,這 10 項 Todo 的 key 值為 0…9,現在刪除數組的第 6 項和第 7 項,並在數組末尾添加 3 個新的數據項,我們最終將得到 key 值為0..10的 Todo,看起來隻是在末尾新增 1 項,將原來的列表從10項變成瞭11項,React 很樂意復用已有的 DOM 節點和組件實例,這意味著原來 #6 對應的組件實例沒有被銷毀,現在它接收新的 props 用於呈現原來的 #8。在這個例子中 React 會創建 1 個Todo,更新 4 個Todo。

如果使用數據的 ID 作為 key 值,React 能發現第 6 項和第 7 項被刪除瞭,它也能發現數組新增瞭 3 項,所以 React 會銷毀 #6 和 #7 項對應的組件實例及其關聯的 DOM,還會創建 3 個組件實例及其關聯的 DOM。

提高渲染性能

要將組件顯示在界面上,組件必須經歷渲染流程,但渲染工作有時候會被認為是浪費時間,如果渲染的輸出結果沒有改變,它對應的DOM節點也不需要更新,此時與該組件相關的渲染工作真的是在浪費時間。React組件的輸出結果始終基於當前 props 和 state 的值,因此,如果我們知道組件的 props 和 state 沒有改變,那麼我們可以無後顧之憂地讓組件跳過重新渲染。

跳過重新渲染

React 提供瞭 3 個主要的API讓我們跳過重新渲染:

  • React.Component 的 shouldComponentUpdate:這是類組件可選的生命周期函數,它在組件 render 階段早期被調用,如果返回false,React 將跳過重新渲染該組件,使用它最常見的場景是檢查組件的 props 和 state 是否自上次以來發生瞭變更,如果沒有改變則返回false。
  • React.PureComponent:它在 React.Component 的基礎上添加默認的 shouldComponentUpdate 去比較組件的 props 和 state 自上次渲染以來是否有變更。
  • React.memo():它是一個高階組件,接收自定義組件作為參數,返回一個被包裹的組件,被包裹的組件的默認行為是檢查 props 是否有更改,如果沒有,則跳過重新渲染。

上述方法都通過‘淺比較’來確定值是否有變更,如果通過 mutable 的方式修改狀態,這些 API 會認為狀態沒有變。

  • 如果組件在其渲染過程中返回的元素的引用與上一次渲染時的引用完全相同,那麼 React 不會重新渲染引用相同的組件。示例如下:
function ShowChildren(props: {children: React.ReactNode}) {
    const [count, setCount] = useState<number>(0)
    return (
        <div>
            {count} <button onClick={() => setCount(c => c + 1)}>click</button>
            {/* 寫法一 */}
            {props.children}
            {/* 寫法二 */}
            {/* <Children/> */}
        </div>
    )
}

上述 ShowChildren 的 props.children 對應 Children 組件,因此寫法一和寫法二在瀏覽器中呈現一樣。點擊按鈕不會讓寫法一的 Children 組件重新渲染,但是會使寫法二的 Children 組件重新渲染。

上述4種方式跳過重新渲染意味著 React 會跳過整個子樹的重新渲染。

Props 對渲染優化的影響

默認情況,隻要組件重新渲染,React 會重新渲染所有被它嵌套的後代組件,即便組件的 props 沒有變更。如果試圖通過 React.memo 和 React.PureComponent 優化組件的渲染性能,那麼要註意每個 prop 的引用是否有變更。下面的示例試圖使用 React.memo 讓組件不重新渲染,但事與願違,組件會重新渲染,代碼如下:

const MemoizedChildren = React.memo(Children)
function Parent() {
    const onClick = () => { /** todo*/}
    return <MemoizedChildren onClick={onClick}/>
}

上述代碼中,Parent 組件重新渲染會創建新的 onClick 函數,所以對 MemoizedChildren 而言,props.onClic k的引用有變化,最終被 React.memo 包裹的Children 會重新渲染,如果讓組件跳過重新渲染對你真的很重要,那麼在上述代碼中將 React.memo 與 useCallback 配合使用才能達到目的。

總結

渲染與更新 DOM 是不同的事情,組件經歷瞭渲染,DOM 不一定會更新,如果渲染組件返回的結果與上次的相同,那麼它的 DOM 節點不需要有任何更新。與 React 渲染密切相關的還有另一個概念,即Immutability,在React 狀態的不變性一文已介紹過它。

到此這篇關於React重新渲染超詳細講解的文章就介紹到這瞭,更多相關React重新渲染內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: