react-react 如何处理 dom 新增

996 阅读7分钟

react 如何处理 dom 新增

在 react 协调阶段会根据数据变动进行 diff,并将 diff 结果用于修改真实的 dom。目前已经有许多文章对 react 的 diff 算法进行了详细的分析。本文主要分析 react 在获取 diff 结果后是如何根据其对真实 dom 进行改变。

diff 与副作用

react 在 diff 阶段会对新增/移动的 fiber 打上 Placement 的副作用标签,其存放在 fiber.flags 上。 react 并不关心节点是新增还是移动,都是同一种处理方式。但是并不是所有新增节点都会打上 Placement 的标签,对于一个新增的子树来说只有子树的根节点会被打上 Placement 标签

例如假设现在 A 有 B, C, D 三个子节点,现在要插入一个新节点 E 及其子树(假设都是 dom 节点),并且该节点也有它自己子节点:

image.png

那么此时,只有 E 节点会被打上 Placement 的标签,而其子节点并不会。

节点插入第一站-completeWork

很多人会说,dom 变动难道不是在 commit 阶段才会进行的吗?怎么在 complete 阶段也会进行阶段插入了。这么说其实也没问题,显示在屏幕上的 dom 变动确实发生在 commit 阶段,但是在 complete 阶段实际上也进行了一些准备工作。即为对于新增的 dom 节点的 fiber,会将其直接的 dom 子节点插入到该 dom 节点。这句话听起来可能有点抽象,当时我们用刚才的例子说明一下就明白了:

  1. 在 G 节点的 completeWork 时,会将 I 节点插入到 G 节点的子元素。即让 I 节点成为 G 节点的子元素。
  2. 在 E 节点的 completeWork 时,会将 G, H 节点插入到 E 节点的子元素。即让 G, H 节点成为 E 节点的子元素。

由于 completeWork 是从叶子节点向上执行的,因此到执行到新增 dom 子树的根节点时,整个 dom 子树的父子关系已经建立完成。但要注意的一点是此时这个新增的 dom 子树还未插入到现有的 dom 树中

节点插入第二站 - commit

在 compleWork 阶段,新增 dom 子树已经建立完成。但是这个 dom 子树还未插入到现有的 dom 树中。现在就差临门一脚,把这个新增 dom 子树的根节点插入到 dom 树中整个插入流程就结束了。这也是为什么副作用只需要挂载到新增子树的根节点的原因。但是这个时候还需要一个准备工作,即找到这个待插入节点的第一个兄弟节点,将其作为基准节点。待插入节点始终插入到基准节点之前。当然如果这个 dom 并没有任何兄弟节点,那么将其直接插入到 dom父节点 即可。

在 fiber 树中查找 dom 树的父节点非常简单,通过 return 不断往上找,第一个 dom 节点必定是其 dom 树的父节点。但是在fiber 树中找 dom 树的兄弟节点并不容易,例如待插入 dom A 与其 dom 兄弟节点 B 可能的关系如下:

image.png

假设用方块表示 dom 节点,圆形表示非 dom 节点。那么 B 可能是 A 的兄弟 fiber 节点,但是也有可能是 A 兄弟 fiber 节点的子节点,甚至可能是 A 父 fiber 节点的子节点。现实的情况可能比这几种情况更复杂。因此在 react 中,会采用后序遍历的方式去寻找dom 兄弟节点。即深度遍历,且从对于同一层级的节点从左到右遍历(是不是很眼熟,其实这也是协调节点 diff dom 树的遍历方式)。采用这种方式从待插入节点出发,遇到的第一个不需要插入的 dom 节点就一定是基准节点。具体方式为:

  1. 如果待插入节点的第一个兄弟节点是 dom 节点,则该节点是基准节点,查找过程结束
  2. 如果待插入节点没有兄弟节点,则寻找一个存在兄弟节点的祖先节点,以该祖先节点为起始节点
  3. 如果待插入节点有兄弟节点,则以兄弟节点为起始节点
  4. 从起始节点开始,执行以下操作:
    1. 如果该节点是 dom 节点,则该节点为基准节点,查找结束
    2. 否则如果该节点有子节点,则访问子节点
    3. 否则如果该节点有兄弟节点,则访问兄弟节点
    4. 否则寻找找一个存在兄弟节点的祖先节点,访问该祖先节点的兄弟节点
    5. 如果访问到的节点不存在父节点,也不存在兄弟节点,则表示遍历完整个 dom 树也没找到其兄弟 dom 节点,表明待插节点是不存在兄弟节点,查找结束

上面提到的基准 dom 节点,都有一曾隐藏含义,即该节点不是本次需要插入的。例如有以下情况:

插入前(dom 节点): a -> b -> c
插入后(dom 节点): a -> b -> e -> f -> c

其中 e, f 作为新增节点,插入到 b, c 之前。在 react 中,对于同一层节点,遍历总是从左到右。因此对于 e 节点来说,f 虽然是其第一个兄弟节点,但是却不能作为基准节点。因为此时 f 尚未插入到 dom 树中。因此在寻找基准节点的遍历过程中,如果找到某个节点是 dom 节点,但也是本次需要插入的。那该节点就不能作为基准节点,必须继续寻找,直到寻找到第一个不是本次需要插入的 dom 节点才能结束。在上面的例子中,新增节点 e 的基准节点实际上应该是 c。但 c 并非 e 的第一个兄弟节点,如果使用 c 作为基准节点是否会出问题?答案是不会,同样 react 对于同一层节点遍历总是从左到右。因此插入 e 时采用 c 作为基准节点,我们得到:

a -> b -> e -> c

随后,插入 f 时我们同样使用 c 作为基准节点:

a -> b -> e -> f -> c

可以得到我们需要的结果。

最终,在找到基准节点后,react 会使用 insertBefore 来将待插入节点插入到父节点下的基准节点之前。这里的 newNode 就是指待插入节点,而 referenceNode 则是指基准节点。

parentNode.insertBefore(newNode, referenceNode);

如果待插入元素没有兄弟节点,则直接调用:

parentNode.appendChild(aChild)

将其插入到父节点。

首次渲染

首次渲染主要逻辑和新增基本一致,只在细节处理上有一点不同。我们知道插入首先要找到其父节点,但是首次时由于整个 dom 树都不存在,因此在整个 dom 树的根节点上会挂载 Placement 的副作用。但是此时整个 dom 树的根节点在 fiber 树中并不能通过 return 的方式查找到对应的 dom 父节点,因此在首次渲染时会使用

parentFiber.stateNode.containerInfo(实际上就是 ReactDOM.createRoot 传入的挂载节点)

将整个 dom 树的父节点插入到挂载节点。当然,如果在更新阶段出现了整个 dom 树的父节点发生了变化,需要重新插入也是同样的方式。对于一般新增的节点,只需要通过 retrun 找到第一个 dom 节点作为父节点即可。

移动节点?

上面大部分都谈的是节点新增的场景。实际上处理节移动点和节点新增非常相似。不过节点移动在 completeWork 节点不需要插入子元素。只需要在 commit 阶段需要基准阶段进行插入即可,其使用的 api 与新增节点完全相同

源码

本文并不是源码解析,这里提供 dom 节点插入过程中几个比较重要的函数。如果对源码有兴趣的同学可以查阅:

  • appendAllChildren: completeWork 阶段将新增节点的子节点插入到当前节点
  • commitPlacement: commit 阶段处理 dom 节点新增
  • getHostSibling: 寻找基准节点