iT邦幫忙

0

React和DOM的那些事-節點新增算法

點擊進入React源碼調試倉庫。

本篇是詳細解讀React DOM操作的第二篇文章,文章所講的內容發生在commit階段。

插入DOM節點操作的是fiber節點上的stateNode,對於原生DOM類型的fiber節點來說stateNode存儲著DOM節點。commit階段插入節點的操作就是循著fiber樹把DOM節點插入到真實的DOM樹中。

commitPlacement是插入節點的入口,

function commitMutationEffectsImpl(
  fiber: Fiber,
  root: FiberRoot,
  renderPriorityLevel,
) {

  ...

  switch (primaryEffectTag) {
    case Placement: {
      // 插入操作
      commitPlacement(fiber);
      fiber.effectTag &= ~Placement;
      break;
    }

    ...

  }
}

我們將需要被執行插入操作的fiber節點稱為目標節點,commitPlacement函數的功能如下:

  1. 找到目標節點DOM層面的父節點(parent)
  2. 根據目標節點類型,找到對應的parent
  3. 如果目標節點對應的DOM節點目前只有文字內容,類似<div>hello</div>,並且持有ContentReset(內容重置)的effectTag,那麽插入節點之前先設置壹下文字內容
  4. 找到基準節點
  5. 執行插入
function commitPlacement(finishedWork: Fiber): void {
  ...

  // 找到目標節點DOM層面的父節點(parent)
  const parentFiber = getHostParentFiber(finishedWork);

  // 根據目標節點類型,改變parent
  let parent;
  let isContainer;
  const parentStateNode = parentFiber.stateNode;
  switch (parentFiber.tag) {
    case HostComponent:
      parent = parentStateNode;
      isContainer = false;
      break;
    case HostRoot:
      parent = parentStateNode.containerInfo;
      isContainer = true;
      break;
    case HostPortal:
      parent = parentStateNode.containerInfo;
      isContainer = true;
      break;
    case FundamentalComponent:
      if (enableFundamentalAPI) {
        parent = parentStateNode.instance;
        isContainer = false;
      }
  }
  if (parentFiber.effectTag & ContentReset) {
    // 插入之前重設文字內容
    resetTextContent(parent);
    // 刪除ContentReset的effectTag
    parentFiber.effectTag &= ~ContentReset;
  }

  // 找到基準節點
  const before = getHostSibling(finishedWork);

  // 執行插入操作
  if (isContainer) {
    // 在外部DOM節點上插入
    insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
  } else {
    // 直接在父節點插入
    insertOrAppendPlacementNode(finishedWork, before, parent);
  }
}

這裏要明確的壹點是DOM節點插入到哪,也就是要根據目標節點類型,找到對應的parent

如果是HostRoot或者HostPortal類型的節點,第壹它們都沒有對應的DOM節點,第二實際渲染時它們會將DOM子節點渲染到對應的外部節點上(containerInfo)。所以當fiber節點類型為這兩個時,就將節點插入到這個外部節點上,即:

// 將parent賦值為fiber上的containerInfo
parent = parentStateNode.containerInfo

...

// 插入到外部節點(containerInfo)中
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent)

如果是HostComponent,則直接向它的父級DOM節點中插入,即

// 直接在父節點插入
insertOrAppendPlacementNode(finishedWork, before, parent);

我們看到,在實際執行插入的時候,都有壹個before參與,那它是幹什麽的呢?

定位基準節點

React插入節點的時候,分兩種情況,新插入的DOM節點在它插入的位置是否已經有兄弟節點,沒有,執行parentInstance.appendChild(child),有,調用parentInstance.insertBefore(child, beforeChild)。這個beforeChild就是上文提到的before,它是新插入的DOM節點的基準節點,有了它才可以在父級DOM節點已經存在子節點的情況下,將新節點插入到正確的位置。試想如果已經有子節點還用parentInstance.appendChild(child)去插入,那是不是就把新節點插入到最末尾了?這顯然是不對的。所以找到before的位置十分重要,before(基準節點)通過getHostSibling函數來定位到。

我們用壹個例子來說明壹下getHostSibling的原理:

p為新生成的DOM節點。a為已存在且無變化的DOM節點。它們在fiber樹中的位置如下,p需要插入到DOM樹中,我們可以根據這棵fiber樹來推斷出最終的DOM樹形態。

                    Fiber樹                    DOM樹

                   div#root                  div#root
                      |                         |
                    <App/>                     div
                      |                       /   \
                     div                     p     a
                    /   ↖
                   /      ↖
 Placement  -->   p ----> <Child/>
                             |
                             a

可以看到,在Fiber樹中,a是p的父節點的兄弟節點,而在DOM樹中,p和a是兄弟節點的關系,p最後要插入到a之前。

按照以上的圖示我們來推導壹下過程:

p有兄弟節點<Child/>,它有子節點a,a是壹個原生DOM節點,並且a已存在於DOM樹,那麽a作為結果返回,p插入到a之前。

再來壹個例子,p同樣也是新插入的節點,h1作為已有節點存在於DOM樹中。

                      Fiber樹           DOM樹

                     div#root         div#root
                        |                |
                      <App/>            div
                        |               /  \
                       div             p   h1
                      /   ↖
                     /      ↖
               <Child1/>--><Child2/>
                  |            |
                  |            |
 Placement  --->  p            h1

p沒有兄弟節點,往上找到<Child1/>,它有兄弟節點<Child2/><Child2/>不是原生DOM節點,找<Child2/>的子節點,發現了h1,h1是原生DOM節點並且h1已存在於DOM樹,那麽h1作為結果返回,p插入到h1之前。

經過兩個例子,getHostSibling尋找到新插入節點的兄弟DOM節點的過程可以總結為:

  1. 優先查找同級兄弟節點,過濾出原生DOM組件。
  2. 過濾不出來就查找同級節點的子節點,過濾出原生DOM組件。
  3. 重復查找兄弟節點再查找子節點的過程,直到再也找不到兄弟節點。
  4. 向上查找到父節點,兄對父節點也重復前三步。
  5. 直到過濾出原生DOM節點,如果該DOM節點不是需要插入的節點,那麽它作為結果返回,也就是定位到了before(基準節點),新節點需要插入到它的前面。

這其中有如下規律:

需要插入的節點如果有同級fiber節點且是原生DOM節點,那麽它壹定是插入到這個節點之前的。如果同級節點不是原生DOM節點,那麽它和同級節點的子節點在DOM層面是兄弟節點的關系。

需要插入的節點如果沒有同級節點,那麽它和父節點的兄弟節點的子節點在DOM層面是兄弟節點的關系。

基準節點和目標節點在DOM樹中是兄弟關系,且它的位置壹定在目標節點之後

接下來按照上面總結的過程看壹下它源碼:

function getHostSibling(fiber: Fiber): ?Instance {

  let node: Fiber = fiber;
  siblings: while (true) {

    while (node.sibling === null) {
      if (node.return === null || isHostParent(node.return)) {
        // 代碼執行到這裏說明沒有兄弟節點,並且新節點的父節點為DOM節點,
        // 那麽它將作為唯壹的節點,插入父節點
        return null;
      }
      // 如果父節點不為null且不是原生DOM節點,那麽繼續往上找
      node = node.return;
    }

    // 首先從兄弟節點裏找基準節點
    node.sibling.return = node.return;
    node = node.sibling;

    // 如果node不是以下三種類型的節點,說明肯定不是基準節點,
    // 因為基準節點的要求是DOM節點
    // 會壹直循環到node為dom類型的fiber為止。
    // 壹旦進入循環,此時的node必然不是最開始是傳進來的fiber
    while (
      node.tag !== HostComponent &&
      node.tag !== HostText &&
      node.tag !== DehydratedFragment
    ) {
      if (node.effectTag & Placement) {
        // 如果這個節點也要被插入,繼續siblings循環,找它的基準節點
        continue siblings;
      }
      if (node.child === null || node.tag === HostPortal) {
        // node無子節點,或者遇到了HostPortal節點,繼續siblings循環,
        // 找它的基準節點。
        // 註意,此時會再進入siblings循環,循環的開始,也就是上邊的代碼
        // 會判斷這個節點有沒有siblings,沒有就向上找,有就從siblings裏找。
        continue siblings;
      } else {
        // 過濾不出來原生DOM節點,但它有子節點,就繼續往下找。
        node.child.return = node;
        node = node.child;
      }
    }

    if (!(node.effectTag & Placement)) {
      // 過濾出原生DOM節點了,並且這個節點不需要動,
      // stateNode就作為基準節點返回
      return node.stateNode;
    }
  }
}


此時基準節點已經找到,接下來執行插入操作。

插入節點

插入節點操作的是DOM樹,除了插入目標節點,還需要遍歷它的fiber子樹,保證所有子DOM節點都被插入,遍歷的過程是深度優先。

我們以將節點插入父節點的insertOrAppendPlacementNode函數為主,來梳理壹下插入的過程。

                    Fiber樹
                   div#root
                      |
                    <App/>
                      |
                    div#App
                      |
Placement  -->     <Child/>
                    /
                   /
                  p ------> span ----- h1
                             |
                             a

現在要將<Child/>插入到div#App中,真實的插入過程是先找到div#App作為parent,此後再找<Child/>是否有sibling節點,然後調用insertOrAppendPlacementNode來執行插入操作。
來看壹下它的調用方式:

insertOrAppendPlacementNode(finishedWork, before, parent);

壹共有三個參數:

  • finishedWork:就是需要插入的fiber節點,當前是<Child/>
  • before: <Child/>的sibling節點,該場景下為null
  • parent: <Child/>的parent節點,也就是div#App

進入函數,它的任務是將DOM節點插入到parent之下或before之前,如果finishedWork是原生DOM節點,那麽依據有無before來決定節點的插入方式,無論哪種方式都會將DOM實實在在地插入到正確的位置上。

如果不是原生DOM節點,就是<Child/>這種,不能對它進行插入操作,那麽怎麽辦呢?向下,從它的child切入,再次調用insertOrAppendPlacementNode,也就是遞歸地調用自己,將child壹個不剩地全插入到parent中。在例子中,會把p插入到parent。

此時<Child/>的子節點已經全部完成插入,這時會再找到p的兄弟節點span,對它進行插入,然後發現span還有兄弟節點h1,將h1也插入。

這就是節點插入的完整過程。有壹個特點與completeWork中的插入節點類似,也就是只將目標節點的第壹層子DOM節點插入到正確的位置,因為子DOM節點的再下層的DOM節點已經在處理該層的時候插入過了。

來對照著上面的過程看壹下insertOrAppendPlacementNode的源碼

function insertOrAppendPlacementNode(
  node: Fiber,
  before: ?Instance,
  parent: Instance,
): void {
  const {tag} = node;
  const isHost = tag === HostComponent || tag === HostText;
  if (isHost || (enableFundamentalAPI && tag === FundamentalComponent)) {
    // 如果是原生DOM節點,直接進行插入操作
    const stateNode = isHost ? node.stateNode : node.stateNode.instance;
    if (before) {
      // 插入到基準節點之前
      insertBefore(parent, stateNode, before);
    } else {
      // 插入到父節點之下
      appendChild(parent, stateNode);
    }
  } else if (tag === HostPortal) {
    // HostPortal節點什麽都不做
  } else {
    // 不是原生DOM節點,找它的子節點
    const child = node.child;
    if (child !== null) {
      // 對子節點進行插入操作
      insertOrAppendPlacementNode(child, before, parent);
      // 然後找兄弟節點
      let sibling = child.sibling;
      while (sibling !== null) {
        // 插入兄弟節點
        insertOrAppendPlacementNode(sibling, before, parent);
        // 繼續檢查兄弟節點
        sibling = sibling.sibling;
      }
    }
  }
}

在遞歸調用insertOrAppendPlacementNode插入節點的時候也傳入了before,這個before是最開始那個待插入的目標節點的基準節點。我們來用源碼中的兩個場景看壹下這樣做的意義。

假設目標節點不是原生DOM節點,且有已存在DOM的兄弟節點(就是基準節點before,span):

  • 有子節點,對子節點插入到div,最終的DOM形態是右邊的DOM樹,p雖然在fiber樹裏和span不是同級的關系,但在DOM層面是,所以要插入到span的前面,這是before在這種場景下存在的意義
                    Fiber樹                  DOM樹
                     div                     div
                      |                      / \
Placement  -->     <Child/>----> span       /   \
                      |                    p    span
                      |
                      p
  • 子節點完成插入,最終形成的DOM樹裏,p、a、span三者是兄弟關系,p和a要依次插入到span之前,所以這種場景也需要before。
                    Fiber樹                  DOM樹
                     div                     div
                      |                      /|\
Placement  -->     <Child/>----> span       / | \
                      |                    p  a  span
                      |
                      p ----> a

總結

結合實際插入節點產生的問題不難總結出commit階段插入節點過程的特點:

  1. 定位DOM節點插入的正確位置
  2. 避免DOM節點的多余插入

找到基準節點before是第1點的關鍵,有了基準節點就能知道即將插入的父級節點上是否有已經存在,並且位置在目標節點之後的子節點。根據有無基準節點來決定執行哪種插入策略。

如何避免DOM節點的多余插入呢?上面分析插入過程的時候已經講過,只會將目標節點的第壹層子DOM節點插入到正確的位置,因為子DOM節點的插入工作已經完成了。這和effectList中收集的fiber節點的順序有關,因為是自下而上收集的,所以fiber的順序也是自下而上,導致DOM節點的插入也是自下而上的,可以類比壹下累加的過程。

如下,可以看到最終的effectList中,最下層的節點排在最前面:

以上,是依據Fiber樹插入DOM節點的過程。


尚未有邦友留言

立即登入留言