漫談 react 系列(二): 用二叉樹的中序遍歷搞懂 fiber tree 的協調過程

語言: CN / TW / HK

theme: cyanosis

本文使用「署名 4.0 國際 (CC BY 4.0)」 許可協議,歡迎轉載、或重新修改使用,但需要註明來源。

作者: 百應前端團隊 @0o華仔o0 首發於 https://juejin.cn/post/7007622432013942798

前言

談到 react 的工作過程,不得不提的一定會有 fiber tree 的協調過程diff 演算法比較更新副作用的處理這些內容。在之前的 漫談 react 系列(一):初探 react 的工作過程 中,我們只是對 react 的工作過程做了簡單的初步梳理,並沒有對其核心內容做詳細說明。花大量的篇幅來介紹一些基本知識,主要是為本文做鋪墊,以便能幫助大家更好的理解本文。

本文將在 漫談 react 系列(一):初探 react 的工作過程 基礎上,進一步詳細介紹 fiber tree 的整個協調過程diff 演算法,以及副作用的處理。文章篇幅較長,並且使用了大量的圖片來幫助理解,可能需要花費大家一定的時間來閱讀,希望大家閱讀以後能有一些收穫。

文章的目錄列表如下: - 協調 - Reconcile - workInProgress fiber tree 中的節點 - diff 演算法 - 如何判斷一個 current fiber node 是可克隆的 - diff 演算法的核心思想 - 子元素是列表時為什麼要使用 key - diff 演算法的比較過程 - 如何判斷一個 workInProgress fiber node 發生了變化 - pendingProps & memoizedProps - fiber node 的 tag - 判斷節點是否發生變化的策略 - 節點發生變化以後的處理方式 - 副作用 - effect - effect 的型別 - effect 的收集 - effect 的處理 - 寫在最後 - 參考資料 - 傳送門

協調 - Reconcile

在之前的文章 漫談 react 系列(一):初探 react 的工作過程 中,我們已經講到一次 react 更新,本質是 fiber tree 結構的更新變化。其實,fiber tree 結構的更新,用更專業的術語來講,其實是 fiber tree 的協調 - ReconcileReconcile, 中文意思調和、使和諧一致。協調 fiber tree,就是調整 fiber tree 的結構,使其和更新以後的 jsx 模板結構dom tree 保持一致。

fiber tree 協調時,存在兩顆樹:current fiber treeworkInProgress fiber tree。整個協調過程發生在 workInProgress fiber tree 中。

fiber tree 在協調的時候,主要做三件事情: - 為 fiber tree 生成 fiber node

  • 找到發生變化的 fiber node,更新 fiber node, 標記副作用 - effect

  • 收集帶 effect 的 fiber node

那這個過程是怎樣進行的呢?

在回答這個問題前,我們先來回顧一個在學生時代耳熟能詳的知識點 - 二叉樹的中序遍歷

4.png

fiber tree 的協調過程,也可以用二叉樹的中序遍歷來理解:

5.png

上面的那一段程式碼是不是和二叉樹的中序遍歷幾乎一模一樣。如果光用程式碼看,大家還不太理解的話,那我們就這個過程拆解開來,用一張圖解來說明:

6.png

7.png

8.png

9.png

結合上面的圖解,理解起來就應該比較容易了吧。

實際上 reactfiber tree 協調過程的原始碼實現並非如此,但是思想和過程卻是類似的。如果你也有興趣看 react 原始碼中的協調過程,可以以二叉樹的中序遍歷過程為指導,這樣理解起來就非常方便了!

workInProgress fiber tree 中的節點

workInProgress fiber tree 作為一顆新樹,生成 fiber node 的方式有種: - 克隆(淺拷貝) current fiber node; - 新建一個 fiber node; - 直接複用 current fiber node

不同的建立方式,導致相關的 dom 操作也不相同: - 如果是克隆(淺拷貝) current fiber node,意味著原來的 dom 節點可以直接複用,只需要更新 dom 節點的屬性,或者移動 dom 節點; - 如果是新建一個 fiber node,需要新增加一個 dom 節點; - 如果是直接複用 current fiber node, 那麼對應的 dom 節點完全不用做任何處理

再歸納一下,就是兩種,要麼複用,要麼新建(克隆也是新建)。那麼到底什麼時候該複用,什麼時候該建立呢?

在回答這個問題之前,我們先看一個非常簡單的例子: demo

Sep-26-2021 10-41-52.gif

點選修改按鈕,呼叫 setName、setVisible。Component 元件傳入的 name 發生了變化,需要更新,而 Component2 元件由於傳入的 name 不符合 compare 的條件,不需要更新。

在這個例子中,Component 元件的子節點需要重新建立,而 Component2 元件的子節點會全部複用 current fiber node,圖解如下:

29.png

之所以會出現複用 current fiber node 的情況,是因為元件 Component2 不需要更新,對應的函式方法沒有執行,沒有返回新的 react element

同樣的的情況也適用於類元件。當類元件不需要更新時(shouldComponentUpdate 返回 false), render 方法不需要執行,不會返回新的 react element,子節點直接複用 current fiber node

因此,在一次 react 更新中,只要元件以及子元件渲染方法(類元件的 render、函式元件方法)都沒有觸發,沒有返回新的 react element子節點就可以直接複用 current fiber node

相反,只要元件的渲染方法被觸發,返回新的 react element,那麼就需要根據新的 react element 為子節點建立 fiber node

在日常開發過程中,我們可以通過合理使用 ShouldComponentUpdateReact.memo,阻止不必要的元件重新 render,通過直接複用 current fiber node,加快 workInProgress fiber tree 的協調,達到優化的目的。

除了複用 current fiber node 外,workInProgress fiber tree 的其他節點都是新建的,而新建節點的過程就和我們常常提及的 diff 演算法有關了。

diff 演算法

fiber tree 的協調過程中,如果元件節點的 render 方法被觸發,返回新的 react element,那麼元件節點的子節點都需要新建

新建節點有兩種方式: - 如果能在 current fiber tree 中找到匹配節點,那麼可以通過克隆(淺拷貝) current fiber node 的方式來建立新的節點;

  • 相反,如果無法在 current fiber tree 找到匹配節點,那麼就需要重新建立一個新的節點;

這裡其實很好理解。新建一個節點時,如果發現和原來的節點差距不大,那麼就可以照著原來的節點先建立一個,然後再簡單的修改一下;如果發現要建立的節點和原來的節點差距很大,那就只能重新建立一個了。

而我們經常談及的 diff 演算法就是用來判斷是否需要通過克隆 current fiber node 的方式來建立一個 fiber node

如何判斷一個 current fiber node 是可克隆的

有一點我們需要先搞清楚,做 diff 比較的雙方,分別是 workInProgress fiber tree 中用於構建 fiber node 的 react elementcurrent fiber tree 中的 fiber node,即 react elementcurrent fiber node 做比較,比較兩者的 keytype,根據比較結果來決定如何為 workInProgress fiber tree 建立 fiber node

key 非常好理解,它就是 jsx 模板中元素上的 key 屬性,如:

``` // key="A"

...

// key=undefined // key=undefined ... // key=undefined ``` jsx 模板轉化為 react element 後,元素的 key 屬性會作為 react elementkey 屬性。同樣的,react element 轉化為 fiber node 以後,react elementkey 屬性也會作為 fiber nodekey 屬性。

type 理解起來稍微有點複雜。jsx 中的元素,概括來講,可以分為三種類型: - 元件, 如 "< Component />"; - dom 節點,如 "< div > ... " 、 "< button >..."; - react 提供的元素,如 ""、 "...";

不同的元素型別,導致 type 也不相同,如下:

``` // type = Component, 是一個函式

...

// key=undefined // type = "div", 是一個字串 // type="button", 是一個字串 ... // type = React.Fragment, 是一個數字(react 內部定義的); ```

jsx 模板轉化為 react element 以後,react elementtype 屬性會根據 jsx 元素的型別賦不同的值,可能是元件函式,也可能是 dom 標籤字串,還可能是數字react element 轉化為 fiber node 以後,react elementtype 屬性也會作為 fiber nodetype 屬性。

綜上,判斷拷貝 current fiber node 的邏輯,概括來就是: - reactElement.key === currentFiberNode.key && reactElement.type === currentFiberNode.type, current fiber node 可以克隆; - reactElement.key !== currentFiberNode.key, current fiber node 不可克隆;

  • reactElement.key === currentFiberNode.key && reactElement.type !== currentFiberNode.type, current fiber node 不可克隆;

diff 演算法的核心思想

diff 演算法的核心思想:

  • 已匹配的父節點的直接子節點進行比較,不跨父節點比較;

    即對克隆生成的 workInProgress fiber node 和對應的 current fiber node 這兩個節點的子節點進行比較。

    注意,是已匹配的父節點的直接子節點進行比較,而不是同層的節點進行比較哈!!!很多文章裡面寫的都是同一層級的節點進行比較,這是不夠準確的。

  • 通過比較 keytype 來判斷是否需要克隆 current fiber node。只有 keytype 都相等,才克隆 current fiber node 作為新的節點,否則就需要新建一個節點。

    key 值和節點型別 - type,key 的優先順序更高。如果 key 值不相同,那麼節點不可克隆。

  • 同一個父節點的所有子節點key 要保證唯一性

子元素是列表時為什麼要使用 key

相信大家在日常開發中,也遇到過下面的警告資訊吧:

image.png

如果子元素是一個列表且沒有給每一個子元素定義 key,那麼 react 就會列印上述警告資訊。

那使用 key 有什麼意義呢?

使用 key 最大的意義,就是當列表元素只是發生位置變化時,只需要做 dom 的移動操作,不需要做多餘的新增刪除操作。

我們來看一個示例: list:

Sep-27-2021 18-15-39.gif

在示例中,我們定義了兩個元件 Componet1Component2。其中, Component1 中的列表元素定義了 keyComponent2 中的列表元素沒有定義 key。點選標題元素,調整列表元素的順序。

為了能監聽到 dom 元素是否發生了移動新增刪除,我們重寫了 createElementappendChildinsertBeforeremoveChild 方法。

當我們點選 Component1 的標題時,元素位置發生變動,控制檯只打印 insertBefore,說明 dom 元素只發生了移動

而我們點選 Component2 的標題時,元素位置發生變動,控制檯列印了 createElementremoveChildinsertBefore,說明 dom 元素髮生了建立刪除

由此可見,如果要渲染的子元素是一個列表,給子元素指定 key 值,在一定程度上能起到優化的目的。

diff 演算法的比較過程

react elementcurrent fiber node 的比較,涉及的情形包括:

  • single react element VS current fiber node list

  • react element list VS current fiber node list

在 current fiber tree 中,兄弟節點之間通過 sibling 指標相連,是一個連結串列,因此在比較時會作為一個 list。如果只有一個 fiber node,sibling 指向 null,列表中只有一個元素

single react element VS current fiber node list

single react elementcurrent fiber node list 的比較非常簡單,就是遍歷 current fiber node list,比較每個 current fiber nodereact elementkey 值和節點型別 - type。只有 keytype 相等,react elementcurrent fiber node 才能匹配。

沒有 key 值,那麼 key 為 undefined,undefined === undefined,key 值相等,比較 type。另外,在卡頌大佬的 單節點 diff 一文中對有更詳細的比較細節,大家可以去看看,更進一步瞭解。

最後的比較結果有兩種: - current fiber node list 中某個節點匹配 react element

直接**克隆(淺拷貝) current fiber node**,作為 **react element** 對應的 **workInProgress fiber node**, **workInProgress fiber node** 的 **alternate** 指標指向 **current fiber node**。

**current fiber node list**中剩餘的**未匹配節點**,全部標記 **Deletion**。
  • current fiber node list 中沒有節點可匹配 react element

    沒有可匹配的 current fiber node,就需要為 react element 重新建立一個新的 fiber node 作為 workInProgress fiber nodeworkInProgress fiber nodealternate 指標指向 null

    current fiber node 列中所有的節點,全部標記 Deletion

圖示:

10.png

react element VS current fiber node list

react element listcurrent fiber node list 的比較稍微要複雜一些。

當只有一個 react element 時,建立 workInProgress fiber node 只需要考慮是否是克隆(淺拷貝) current fiber node 還是從零開始建立一個新的 fiber node。但如果 react element 有多個時,我們還需要考慮 wokrInProgress fiber node 相對於克隆(淺拷貝)的 current fiber node 是否發生了移動。

image.png 那如何判斷一個克隆(淺拷貝)自 current fiber node 的 workInProgress fiber node 是否發生了移動

答案是:通過列表下標 index

current fiber node listworkInProgress fiber node list 作為一個列表,都有對應的下標 oldIndexnewIndex。當 workInProgress fiber nodecurrent fiber node 建立克隆(淺拷貝)匹配關係時,newIndexoldIndex 也是對應的。在上面圖例中, workInProgress fiber node - C 的 newIndex 為 0,對應的 current fiber node - C 的 oldIndex 為 2。

workInProgress fiber node 是按序生成的,在生成過程中會使用一個稱為 lastPlacedIndex 的指標來定位未發生移動 current fiber node。通過 lastPlasedIndexoldIndex 比較,就可以知道節點是否發生了移動

  • 如果 lastPlacedIndex > oldIndex,說明 oldIndex 小的節點跑到了 oldIndex 大的節點後面,發生了移動;

  • 如果 lastPlacedIndex < oldIndex, 說明節點沒有發生移動,此時要更新 lastPlacedIndex

我們結合上面圖例來說明一下節點的移動過程:

  1. workInProgress fiber node - C 匹配 current fiber node - C,newIndex 為 0,oldIndex 為 2;節點 C 未發生移動, lastPlacedIndex 為 2;

  2. workInProgress fiber node - B 匹配 current fiber node - B,newIndex 為 1, oldIndex 為 1, oldIndex < lastPlacedIndex,節點 B 發生了移動, 移動到了節點 C 之後;

  3. workInProgress fiber node - A 匹配 current fiber node - A,newIndex 為 2, oldIndex 為 0, oldIndex < lastPlacedIndex,節點 A 發生了移動,移動到了節點 C 之後;
  4. workInProgress fiber node - D 匹配 current fiber node - D,newIndex 為 3, oldIndex 為 3, oldIndex > lastPlacedIndex,節點 D 未發生了移動,將 lastPlacedIndex 更新為 3;

知道了如何判斷節點是否發生了移動,接下來我們就來看看 react 是如何比較 react element listcurrent fiber node list 的。

在比較過程中,react 會按序遍歷 react element list,從 current fiber node list 中尋找可匹配的節點。比較時,會使用 key 值出現不相等這個條件作為分水嶺,將整個過程被分為兩個階段

  • 第一階段,key 值未出現不相等

    此時,react element listcurrent fiber node list齊頭並進的方式一同遍歷type 相同,匹配,克隆 current fiber nodetype 不相同,不匹配,新建 fiber node,並給 current fiber nodeDeletion 標記。

    當出現 key 值不一樣,結束第一階段,進入第二階段;

  • 第二階段,key 值出現不匹配

    此時根據剩下的 current fiber node list,生成一個 Mapkeycurrent fiber nodekey(沒有 key,則使用 index),valuecurrent fiber node,繼續遍歷剩餘的 react element,從 Map 中找到匹配的 current fiber node

    如果可以找到,克隆 current fiber node,否則新建 fiber nodeMap未被匹配的節點全部標記 Deletion

整個比較過程,卡頌大佬的多節點 diff 有更細節的描述,大家可以結合大佬的文章一起來理解。

直接這樣講,大家可能會一臉懵逼 😳,那麼我們就通過一個輔助示例來展示一下整個過程, 幫助大家理解上面的兩個階段。

首先,我們先看示例及結果:

13.png

從上圖中,我們可以清楚的看到 workInProgress fiber node 中,哪些是新增的,哪些是克隆(淺拷貝)自 current fiber node,哪些發生了移動,哪些是需要刪除的。

接下來,我們就來分析一下中間經歷的過程:

14.png

15.png

16.png

17.png

18.png

這樣,經過 diff 演算法比較,workInProgress fiber tree 中需要新建的節點就建立了。接下來我們就需要判斷新建的節點是否發生了變化。如果節點發生了變化,那就需要對節點做更新操作,並收集更新操作引發的 effect

如何判斷一個新建的節點是否發生了變化

通過上一節 diff 演算法的學習,我們知道新建 workInProgress fiber node 的方式有兩種:克隆(淺拷貝) current fiber node重新建立一個 fiber node

如果一個 workInProgress fiber node 是重新新建的,那麼我們也可以很肯定的說這個 fiber node 是發生了變化的 - 從無到有

而如果一個 workInProgress fiber node 是克隆(淺拷貝)自 current fiber node,那我們就需要一定的策略來判斷 workInProgress fiber node 是否發生了變化。

在講解判斷策略之前,我們需要先來一些準備知識:pendingProps & memoizedPropsfiber node 的 tag

pendingProps & memoizedProps

漫談 react 系列(一):初探 react 的工作過程 中,我們已經瞭解了 jsx -> react element -> fiber node 中間的轉化過程。在這個過程中,jsx 元素上定義的所有屬性(除 key 以外),會收集到 react elementprops 屬性中。

如下:

``` // jsx {...}} />

// react element { type: Component, key: undefined, props: { name: 'xxx', age: "18", address: "xxxx", onClick: () => {...} } } ``` 當 react element 轉化為 workInProgress fiber node 後, react elementprops 屬性會賦值給 workInProgress fiber nodependingProps。當 workInProgress fiber node 結束處理後, pendingProps 屬性值再賦值給 memoizedProps。由於 workInProgress fiber node 會作為下一次更新的 current fiber node,所以我們可以通過 currentFiberNode.memoizedProps 來獲取上一次更新時 jsx 元素的屬性值。

``` // workInProgress fiber node 剛建立時 workInProgressFiberNode.pendingProps: { name: 'xxx', age: "18", address: "xxxx", onClick: () => {...}};

// workInProgress fiber node 完成建立 workInProgressFiberNode.memoizedProps: { name: 'xxx', age: "18", address: "xxxx", onClick: () => {...}};

// fiber node - 下一次更新 currentFiberNode.memoizedProps = { name: 'xxx', age: "18", address: "xxxx", onClick: () => {...} }; ```

pendingProps 是本次更新生成的 workInProgress fiber nodepropsmemoizedProps 是更新之前 current fiber nodeprops,通過比較 pendingPropsmemoizedProps,就可以知道 workInProgress fiber node 是否發生了變化。

fiber node 的 tag

在前面的 diff 演算法一節中,我們知道每個 fiber node 都有自己的型別 - type元件型別dom 節點型別react 特殊元素型別

根據 type 的不同,react 會給 fiber node 打不同的 tag: - 元件型別 - 如果是類元件tagClassComponent;如果是函式元件tagFunctionComponent; - dom 節點型別 - tagHostComponent; - react 特殊元素型別 - tagFragmentSuspenseComponent 等;

不同的 tag,在 workInProgress fiber node 確定發生變化時要做的處理也不同。

判斷節點是否發生變化的策略

所有型別的節點,都會通過比較 workInProgress fiber nodependingProps 是否和 current fiber nodememoizedProps 相等,來判斷 workInProgress fiber node 是否發生了變化。如果 workInProgressFiberNode.pendingProps !== currentFiberNode.memoizedProps,那麼就說明 workInProgress fiber node 發生了變化,需要更新。

通常,只要 workInProgress fiber node 是根據新的 react element 建立的,那麼 workInProgress fiber nodependingProps 肯定和 current fiber nodememoizedProps 不相等。

每一次 react element 建立時,props 都是一個新的物件,導致生成的 workInProgress fiber nodependingProps 也是一個新的物件。而 current fiber nodememoizedProps 是上一次更新生成的 react elementprops,兩個物件完全不一樣。

這也就解釋了當子節點是一個元件時,儘管 props 中的屬性一樣,屬性值也一樣,元件依舊需要觸發 render 方法。這是因為子元件對應的 fiber node 是新建的,儘管 props 看起來沒有發生變化,但實際上已經是一個新的物件了。props 發生了變化,節點就要更新,就會觸發 render 方法。

另外,如果節點是元件型別,還需要檢視元件的 state 是否發生了變化。只要元件的 propsstate 有一個發生了變化,節點都是需要更新的。

綜上,判斷節點是否發生變化的策略為: - 節點只要是重新建立的而不是克隆自 current fiber node,那麼節點就百分之百發生了變化,需要更新;

  • 節點克隆自 current fiber node,需要比較 props 是否發生了變化,如果 props 發生了變化,節點需要更新;
  • 節點克隆自 current fiber node,且是元件型別,還可以比較 state 是否發生了變化,如果 state 發生了變化,節點需要更新;

節點發生變化以後的處理方式

首先,workInProgress fiber node 的建立方式不同,發生變化需要更新時的處理邏輯也不相同: - 節點是重新建立而非克隆自 current fiber node,需要做 mount 操作;

  • 節點是通過克隆 current fiber node 建立的,需要做 update 操作;

另外,workInProgress fiber nodetag 不同,更新時的處理邏輯也不相同。在日常的開發中,我們接觸最多的型別是元件型別dom 型別,因此本文就重點介紹這兩種型別的節點發生更新時候的處理邏輯,其他型別的節點,我們會在以後單獨介紹。

元件型別的 fiber node

元件型別又可分為:類元件 - ClassComponent函式元件 - FunctionComponent

這兩種型別的節點的 mountupdate 操作過程如下:

  • 類元件 - ClassComponent

    19.png

  • 函式元件 - FunctionComponent

    20.png

dom 節點型別的 fiber node

21.png

副作用 - effect

workInProgress fiber tree 在協調過程中,只要有節點發生變化做了更新操作,就會產生副作用 - effect副作用,指的就是 dom 節點的新增移動刪除屬性更新,元件 componentDidMountcomponentDidUpdate 等生命週期方法的執行等。

fiber tree 協調結束以後,會進入 commit 階段。在 commit 階段, react 的主要工作就是處理協調過程中產生的所有的 effect

effect 的型別

react 中定義了很多型別的 effect,如下: - Placement - Update - PlacementAndUpdate - Ref - Deletion - Snapshot - Passive - Layout - ...

(我們只列舉了我們日常開發中會經常接觸的一些 effect,未列舉的 effect 有些我們可能接觸的較少,有些是會在後面的文章中講解。)

根據上面 effect 的名稱,我們基本上也能猜到這些 effect 代表什麼意思: - Placement放置的意思,只針對 dom 型別fiber node,表示節點需要做移動或者新增操作。

當 **fiber node** 標記 **Placement** 時,就需要在 **commit** 階段做如下操作:
- 如果節點是**新增**操作,我們需要通過 **createElement** / **appendChild** / **insertBefore** 這些原生 **API** 新增一個新的 **dom** 節點;

- 如果節點是**移動**操作,我們需要通過 **appendChild** / **insertBefore** 來移動一個已經存在的 **dom** 節點;
  • Update更新的意思,針對所有型別的 fiber node,表示 fiber node 需要做更新操作。

    fiber node 需要標記 Update 的情形,常見如下: - dom 型別的節點的 props 發生了變化; - 類元件需要 mount,且定義了 componentDidMount; - 類元件propsstate 發生了變化需要 update,且定義 componentDidUpdate; - 函式元件需要 mount,且使用了 useEffectuseLayoutEffect; - 函式元件propsstate 發生了需要 update,且定義了 useEffectuseLayoutEffect; - ...

    fiber node 標記了 Update,那麼就需要在 commit 階段做如下操作: - dom 型別的節點,更新 dom 節點的屬性; - 類元件節點,如果是 mount,觸發 componentDidMount;如果是 update,觸發 componentDidUpdate; - 函式元件節點,觸發上一次 useEffectuseLayoutEffect 返回的 destory,並執行本次的 callback

  • PlacementAndUpdate, 放置並更新的意思,只針對 dom 型別fiber node,表示節點發生了移動props 發生了變化。

    fiber node 標記了 PlacementAndUpdate,需要在 commit 階段通過 appendChild / insertBefore 來移動一個已經存在的 dom 節點,並修改 dom 節點的屬性

  • Ref, 表示節點存在 ref,需要初始化 / 更新 ref.current

    fiber node 標記了 Ref,需要在 commit 階段將類元件例項dom 元素賦值給 ref.current

  • Deletion刪除的意思,針對所有型別的 fiber node,表示 fiber node 需要移除

    fiber node 標記了 Deletion,就需要在 commit 階段做如下操作: - 使用 removeChild 移除要刪除的 dom 節點; - 觸發要移除的類元件子元件componetWillUnMount 生命週期方法; - 觸發要移除的函式元件子元件的上一次更新執行 useEffectuseLayoutEffect 生成的 destory 方法; - 將 ref.current 引用置為 null

  • Snapshot快照的意思,主要是針對類元件 fiber node

    類元件 fiber node 發生了 mount 或者 update 操作,且定義了 getSnapshotBeforeUpdate 方法,就會標記 Snapshot

    fiber node 標記了 Snapshot,就需要在 commit 階段觸發 getSnapshotBeforeUpdate 方法。

  • Passive,主要針對函式元件 fiber node,表示函式元件使用了 useEffect

    函式元件節點發生 mount 或者 update 操作,且使用了 useEffect hook,就會給 fiber node 標記 Passive

    fiber node 標記了 Passive,就需要在 commit 階段做如下操作: - 先執行上一次 useEffect 返回的 destory; - 非同步觸發本次更新時 useEffectcallback

  • Layout,主要針對函式元件 fiber node,表示函式元件使用了 useLayoutEffect

    函式元件節點發生 mount 或者 update 操作,且使用了 useLayoutEffect hook,就會給 fiber node 標記 Layout

    fiber node 標記了 Layout,就需要在 commit 階段做如下操作: - 先執行上一次 useLayoutEffect 返回的 destory; - 同步觸發本次更新時 useLayoutEffectcallback

react 使用二進位制數來宣告 effect,如 Placement2 (0000 0010)Update4 (0000 0100)。一個 fiber node 可同時標記多個 effect,如函式元件 props 發生變化且使用了 useEffect hook,那麼就可以使用 Placement | Update = 516(位運算子) 來標記。

effect 的收集

如果一個 fiber node 被標記了 effect,那麼 react 就會在這個 fiber node 完成協調以後,將這個 fiber node 收集起來。當整顆 fiber tree 完成協調以後,所有被標記 effectfiber node 都被收集到一起來。

收集的 fiber node 採用單鏈表結構儲存,firstEffect 指向第一個標記 effectfiber nodelastEffect 標記最後一個 fiber node,節點之間通過 nextEffect 指標連線。

由於 fiber tree 協調時採用的順序是深度優先,協調完成的順序是子節點子節點兄弟節點父節點,所以收集帶 effect 標記的 fiber node 時,順序也是子節點子節點兄弟節點父節點

這也就可以解釋為什麼 componentDidUpdatecomponentDidMount 生命週期方法的執行順序為先子節點後父節點

22.png

effect 的處理

fiber tree 協調完成, 帶 effectfiber node 收集完畢,接下來要做的就是要處理帶 effectfiber node

effect 的處理分為三個階段,這三個階段按照從前到後的順序為: 1. before mutation 階段 (dom 操作之前) 2. mutation 階段 (dom 操作) 3. layout 階段 (dom 操作之後)

不同的階段,處理的 effect 種類也不相同。在每個階段,react 都會從 effect 連結串列的頭部 - firstEffect 開始,按序遍歷 fiber node, 直到 lastEffect

before mutation 階段

before mutation 階段的主要工作是處理帶 Snapshot 標記的 fiber node

firstEffect 開始遍歷 effect 列表,如果 fiber nodeSnapshot 標記,觸發 getSnapshotBeforeUpdate 方法。

mutation 階段

mutation 階段的主要工作是處理帶 Deletion 標記的 fiber node 和帶 PlacementPlacementAndUpdateUpdate 標記的 dom 型別的 fiber node

在這一階段,涉及到 dom 節點的更新新增移動刪除,元件節點刪除導致的 componentWillUnmountdestory 方法的觸發,以及刪除節點引發的 ref 引用的重置。

dom 節點的更新操作比較簡單,主要操作如下: - 通過原生的 API setAttributeremoveArrribute 修改 dom 節點的 attr; - 直接修改 dom 節點的 style; - 直接修改 dom 節點的 innerHtmltextContent

dom 節點的新增移動處理起來,稍微有點複雜。

如果新增(移動)的節點是父節點的最後一個子節點,那麼可以直接使用 appendChild 方法。但如果不是最後一個節點,就需要使用 insertBefore 方法了。使用 insertBefore 方法,必須要提供一個用於定位dom 節點。

整個過程,我們通過圖解來說明:

image.png

其中,節點 A、C 需要移動, E、F 需要新增。

新增 E 的過程:

25.png

移動 A 的過程:

26.png

移動 C 的過程:

27.png

新增 F 的過程:

28.png

標記 Deletion 的節點處理的時候也比較複雜,考慮的情況也比較多: - 如果節點是 dom 節點,通過 removeChild 移除; - 如果節點是元件節點,觸發 componentWillUnmountuseEffectdestory 方法的執行; - 如果標記 Deletion 的節點的子節點中有元件節點深度優先遍歷子節點,依次觸發子節點的 componentWillUnmountuseEffectdestory 方法的執行; - 如果標記 Deletion 的節點及子節點關聯了 ref 引用,要將 ref 引用置空,及 ref.current = null(也是深度優先遍歷);

這也就可以解釋為什麼 comonentWillUnmountdestory 方法的觸發順序是先父節點後子節點

layout 階段

layout 階段的主要工作是處理帶 update 標記的元件節點和帶 ref 標記的所有節點

工作內容如下: - 如果類元件節點是 mount 操作,觸發 componentDidMount;如果是 update 操作,觸發 componentDidUpdate; - 如果函式元件節點時 mount 操作,觸發 useLayoutEffectcallback;如果是 update 操作,先觸發上一次更新生成的 destory,再觸發這一次的 callback; - 非同步排程函式元件的 useEffect; - 如果元件節點關聯了 ref 引用,要初始化 ref.current;

注意,useEffectcallbackdestory 的觸發都是非同步的,是在瀏覽器渲染完成以後才會觸發。

寫在最後

寫到這裡,本文就結束了。

最後我們再對本文做一個總結: - 每次 react 更新,fiber tree 都會進行協調,找到發生變化的 fiber node,標記並收集 effect。等 fiber tree 協調結束後,處理收集的 effect

  • fiber tree 協調時採用的深度優先遍歷

  • workInProgress fiber tree 的節點的生成方式有三種:複用 current fiber node克隆 current fiber node新建 fiber node

  • 協調時,如果元件節點及子元件的 render 方法沒有觸發,子節點可直接複用 current fiber node,否則要通過 diff 演算法來決定如何建立子節點;

  • diff 演算法只比較已匹配的父節點子節點,不跨父節點比較;

  • diff 比較時,如果 react elementcurrent fiber nodekeytype 都一致,那麼就可以拷貝 current fiber node,否則要重新建立一個 fiber node

  • fiber tree 協調結束以後,會使用一個單鏈表收集標記 effectfiber node。連結串列中 fiber node 的順序為子節點子節點的兄弟節點父節點

參考資料

傳送門