关于javascript:React和DOM的那些事节点新增算法

38次阅读

共计 6842 个字符,预计需要花费 18 分钟才能阅读完成。

点击进入 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 节点的过程。

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

正文完
 0