6

React和DOM的那些事-节点新增算法

 3 years ago
source link: https://segmentfault.com/a/1190000039070762
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

点击 进入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 函数的功能如下:

<div>hello</div>
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节点的过程可以总结为:

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中,最下层的节点排在最前面:

7vUfua.jpg!mobile

以上,是依据Fiber树插入DOM节点的过程。

欢迎扫码关注公众号,发现更多技术文章

2qu63ej.jpg!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK