react@18 mount 阶段的 DOM 树构建过程

584 阅读16分钟

Note: 本文是基于 react@18.2.0 源码进行研究的成果。

我的直觉

在浏览器语境下,扒开 react 数据驱动的外衣,里面毫不意外的都是 DOM。这是众所周知的。再往下面去追问一下,我相信是大部分人都会有这样的的一个疑问:“对啊,我们写的是 react component,那 react 是怎样将它转换为 DOM ,然后渲染出我们想要的界面呢?”

这里,为了把问题表达得更严谨点,我们加个上下文:“在 react 应用的 mount 阶段”。加上这个上下文,这个问题完整表述为:“在 react 应用的 mount 阶段,react 是怎样创建 DOM 节点,又是怎样渲染出最终的界面效果的呢?”

熟悉 react 原理的人都知道,无论是 react 应用的 mount 阶段还是 update 阶段,界面的更新流程都可以大致划分为两个子阶段:

  • render 阶段
  • commit 阶段

众多讲述 react 原理的文章都在讲,render 阶段主要负责给 fiber 打上 work tag,然后在 commit 阶段,根据 work tag 来 commit 相应的 work。其中的一种 work 就是「操作真实的 DOM」。

到这里,我们有合理的理由觉得我们的关注点 - 「DOM 节点的创建」也是在 commmit 阶段完成的。

以上,就是在我在没有研究源码之前的直觉。也许在 react@18.2.0 之前的某个版本之前这样实现过,但是在 react@18.2.0 中,这不是真相。

意外发现

image.png

从 chrome dev tool 的 performance 的 profile 结果(如上图)来看:

  • render 阶段的起点函数是renderRootSync(并发模式下是 renderRootConcurrent(),为了简化,我们先关注同步模式);
  • commit 阶段的起点函数是 finishConcurrentRender()

我在准备研究 commit 阶段的时候,想看看在进入 commit 阶段之前 fiber 树是怎样的。首先,我找到了 render 阶段和 commit 阶段的分界处,它是在 performConcurrentWorkOnRoot() 函数里面。为了聚焦到我们的关注点,我们把 performConcurrentWorkOnRoot() 函数简化为下面的样子:

// react@18.2.0/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
const RootInProgress = 0;
const RootFatalErrored = 1;
const RootErrored = 2;
const RootSuspended = 3;
const RootSuspendedWithDelay = 4;
const RootCompleted = 5;
const RootDidNotComplete = 6;

function performConcurrentWorkOnRoot(root, didTimeout) {
  const shouldTimeSlice =
    !includesBlockingLane(root, lanes) &&
    !includesExpiredLane(root, lanes) &&
    (disableSchedulerTimeoutInWorkLoop || !didTimeout);
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);
    
 if(exitStatus === RootCompleted){
      root.finishedWork = finishedWork;
      root.finishedLanes = lanes;
      finishConcurrentRender(root, exitStatus, lanes);
 }
}

从上面简化版的 performConcurrentWorkOnRoot(),我们可以看到 render 阶段的入口函数 renderRootSync(),commit 阶段的入口函数 finishConcurrentRender()

最重要的是,我们找到了两者之间的分界线,那就下面的这两行代码:

 root.finishedWork = finishedWork;
 root.finishedLanes = lanes;

当前,我们的 <App> 组件是这样的:

function App(){
return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

于是乎,我尝试把 render 阶段的最重要的产物 - finishedWork 打印出来看看了。果真是不看不知道,一看吓一跳:fiber 树上所有的 HostComponent 类型的 fiber 节点的 stateNode 属性已然是挂载了真实的 DOM 节点。下面,拿 div.App 这个 fiber 节点做个截图说明(沿着 fiber 树往下展开,查看 相应节点的 stateNode 属性,结果也是如此的):

image.png

猜想

所以,到这里,我产生了一个大胆的猜想:“是不是在进入 commit 阶段之前,已经有有一颗完整的 DOM 树存在于 fiber 树上?” 也就是说, DOM 节点的创建并不发生在 commit 阶段,而是发生在 render 阶段

验证猜想

在深入 react 源码之前,其实我们可以快速地验证一下的。验证的思路是:既然在进入 commit 阶段之前,我们已经可以拿到一个棵完整的 DOM 树,那么我们可以写点代码去自己把它挂载到页面上。实施步骤如下:

  1. 把 react 源码中的 commit 阶段入口函数 finishConcurrentRender() 注释掉;
  2. html 文档里面准备好 div#root2 这个 DOM 容器;
  3. 用下面的代码代替 finishConcurrentRender() 函数:
     (() => {
          const appEle = root.finishedWork.child.child.stateNode;
          const newAppEle = appEle.cloneNode(true);
          const appHeader = newAppEle.getElementsByClassName("App-header")[0];
          appHeader.style.backgroundColor = "#fff";
          const root2 = document.getElementById("root2");
          console.log("root2:", root2);
          root2.appendChild(newAppEle);
        })();
    

经过上面的操作,保存代码,刷新界面,我们将看到下面的界面:

image.png

上面截图中,我们把一颗完整的 DOM 树通过我们自己的代码把它挂载到 div#root2 这个 DOM 容器里面了。如此一来,我们的猜想被验证了。也即是说 -

“在进入 commit 阶段之前,在 fiber 树上已经有一棵对于完整应用的 DOM 树”。构建一棵完整的 DOM 树由下面的两个操作所组成:

  • 创建 DOM 节点
  • 把所有父子关系的 DOM 节点链接起来

所以,我们可以换句话说就是:「① 创建 DOM 节点 ② 把所有属于父子关系的 DOM 节点链接起来 」这两个动作都是发生在 render 阶段。

空口无凭。源码是唯一的真理。下面,我们到源码去看看, react 是如何实现在 render 阶段去完成整棵 DOM 树 的构建的。

react 源码实现讲解

快速定位

在 DOM 这个宿主环境中,要想创建 DOM 节点,无论你怎么封装,最底层的 DOM API 肯定是 document.createElement()。基于这个事实,我们可以通过全局搜索的方式,快速地在 react 的源码中找出真正去创建 DOM 节点的地方。

搜索出来,一个个地筛选一下。结果发现,它被封装到function createElement(){}(源码位置:react@18.2.0/packages/react-dom/src/client/ReactDOMHostConfig.js) 里面了,而 createElement 函数的调用又被封装到function createInstance(){} 里面,所以,这么看来,对 createInstance() 调用的地方就是真正创建 DOM 节点的地方。最后,我们全局搜索一下“createInstance(”(注意这里是特意少写了右括号),结果如下:

image.png

在搜索结果中,只有两个搜索结果是createInstance 函数的调用语法。我甚至不用点击去看第一个搜索结果(createReactNoop.js 里面的调用),我的直觉告诉我,我们要找的肯定是 completeWork() 函数里面的createInstance()

真相

真相肯定藏在 completeWork() 这个函数体里面。这次直觉肯定不会错了。因为在上面的小节,我们已经证实了 DOM 节点的创建就发生在 render 阶段。而 render 阶段是一个 work-loop 循环 - 也就是说每个 fiber 节点都会依次经历 begin-work 和 complete-work。

770 行代码还原 react fiber 初始链表构建过程一文中,我深入探索了 beginWork() 这个函数所所实现的功能。用一句话来总结就是「根据 workInProgress fiber 节点和它对应的 react element 的子 element(nextChildren),通过 reconciliation 流程来创建 子 fiber 节点。然后将两者链接起来,形成父子关系」。

我觉得更简单的记忆方式还是用公式来表达: childFiber = reconcileChildren(workInProgress, nextChildren)

全文我聚焦在 begin-work 子流程,完全跳过了 complete-work 子流程的分析。种种迹象表明,真相就是藏在了 completeWork() 这个函数体里面。查看 completeWork() 的源码,我们会发现,果不其然:

  function completeWork(current, workInProgress, renderLanes) {
   // .....

    switch (workInProgress.tag) {
     
      // other cases......

      case HostComponent: {
         // ......
         const instance = createInstance(
              type,
              newProps,
              rootContainerInstance,
              currentHostContext,
              workInProgress
            );
            appendAllChildren(instance, workInProgress, false, false);
            workInProgress.stateNode = instance; 
            // ......
      }

      // others case......
    }

    // ......
  }

completeWork() 是一个大函数。该函数内部采用了跟 beginWork() 一样的实现架构。即,通过 switch(workInProgress.tag){} 来 case-by-case 地枚举不同类型的 fiber和针对不同类型的 fiber 进行不同的处理。如果把代码全粘贴上来,那真的是巨大无比,没有必要。就我们的关注点,主要关注两个 case 就好了:

  • HostComponent
  • HostText

为什么创建 react 只会在这两种类型的节点上创建 DOM 节点呢?因为,他们俩是 react element 树的叶子节点。

因为两者的 complete-work 的原理是一样的。所以,这里,我们只讲解 fiber 节点为 HostComponent 即可。HostText 情况的原理,同理可得。

为了完整讲述这里的逻辑,我把 HostComponent 情况的代码补回来:

 function completeWork(current, workInProgress, renderLanes) {
   const newProps = workInProgress.pendingProps;
   // .....

    switch (workInProgress.tag) {
     
      // other cases......

      case HostComponent: {
         popHostContext(workInProgress);
        const rootContainerInstance = getRootHostContainer();
        const type = workInProgress.type;

        if (current !== null && workInProgress.stateNode != null) {
          updateHostComponent$1(
            current,
            workInProgress,
            type,
            newProps,
            rootContainerInstance
          );

          if (current.ref !== workInProgress.ref) {
            markRef$1(workInProgress);
          }
        } else {
          if (!newProps) {
            if (workInProgress.stateNode === null) {
              throw Error(formatProdErrorMessage(166));
            } // This can happen when we abort work.

            bubbleProperties(workInProgress);
            return null;
          }

          const currentHostContext = getHostContext(); // TODO: Move createInstance to beginWork and keep it on a context
          // "stack" as the parent. Then append children as we go in beginWork
          // or completeWork depending on whether we want to add them top->down or
          // bottom->up. Top->down is faster in IE11.

          const wasHydrated = popHydrationState(workInProgress);

          if (wasHydrated) {
            // TODO: Move this and createInstance step into the beginPhase
            // to consolidate.
            if (
              prepareToHydrateHostInstance(
                workInProgress,
                rootContainerInstance,
                currentHostContext
              )
            ) {
              // If changes to the hydrated node need to be applied at the
              // commit-phase we mark this as such.
              markUpdate(workInProgress);
            }
          } else {
            const instance = createInstance(
              type,
              newProps,
              rootContainerInstance,
              currentHostContext,
              workInProgress
            );
            appendAllChildren(instance, workInProgress, false, false);
            workInProgress.stateNode = instance; // Certain renderers require commit-time effects for initial mount.
            // (eg DOM renderer supports auto-focus for certain elements).
            // Make sure such renderers get scheduled for later work.

            if (
              finalizeInitialChildren(
                instance,
                type,
                newProps,
                rootContainerInstance
              )
            ) {
              markUpdate(workInProgress);
            }
          }

          if (workInProgress.ref !== null) {
            // If there is a ref on a host node we need to schedule a callback
            markRef$1(workInProgress);
          }
        }

        bubbleProperties(workInProgress);
        return null;
      }

      // others case......
    }

    // ......
  }

从上面的代码,我们看出,要通过三个条件判断,react 才会进入最终进入「创建 DOM 节点」流程。这三个条件是:

  1. if (current !== null && workInProgress.stateNode != null){...} - 因为,我们当前研究的是 react 应用的 mount 阶段,所以 current fiber是为 null 的。所以,react 不会进入这个分支语句里面;
  2. if (!newProps){...} - 即使一个 host component 啥 prop(注意 jsx 的 children 最终也是会被转为 prop) 都没有,它的 props 值都会是空的字面量对象 {}。所以,极少数 react 会进入这个分支语句;
  3. if (wasHydrated) {} - 我们现在不研究 SSR,故 fiber 节点并没有被 Hydrated 过,所以,我们也不会进入这个分支语句里面。

最后,我们还是如愿以偿地来到目的地。注意,上面只提到「创建 DOM 节点」是不严谨的,实际上,下面的三行代码做了我们上面被证实猜想里面所提的两件事:

  • 创建 DOM 节点
  • 把所有父子关系的 DOM 节点链接起来
  const instance = createInstance(
      type,
      newProps,
      rootContainerInstance,
      currentHostContext,
      workInProgress
    ); // 语句 1
    appendAllChildren(instance, workInProgress, false, false); // 语句 2
    workInProgress.stateNode = instance; // 语句 3

下面,我们仔细看看这三行语句。

  1. 「语句1」 没啥好讲的,在 DOM 这个宿主环境里,它就是对 document.createElement()的封装,参数type就是所需创建的 DOM 节点类型,比如 divpaimg 等。最终返回所创建 DOM 节点元素。

    也许你很好奇,这里为什么称之为“instance(实例)”呢?其实,你应该想到 react 是一个跨平台的架构,就 react core 而言,它应该是平台无关的。假如我们此处称之为“domElement”,那么当我们切换看代码的上下文,比如在 react 的另外的一个 renderer 中 - react native,那么这个命名就不准确了。

  2. 「语句2」 - appendAllChildren() 才是重点。就是在这个函数里面,react 实现了 DOM 树的上下层级关系的构建。当前,instance 就是父 DOM 节点,而它的所有子 DOM 节点都储存在以 workInProgress 为根节点的 fiber 子树中。所以, appendAllChildren 函数的职责就是遍历当前 fiber 子树,找到子树上的所有的直属子 DOM 节点(注意,这里强调的是「直属」!),依次把它们 append 到当前的 instance DOM 容器里面来。

  3. 「语句3」就是负责把新构建好的 DOM 子树的根节点挂载到 fiber 的 stateNode 属性上。之前我也说过,不同类型的 fiber 节点,它的 stateNode 属性值的语义是不一样的。 从这里,我们也可以看出,对于 HostComponent 的 fiber 节点来说,它的 stateNode 属性值挂载的就是 DOM 节点。

细节深究

在上面的「语句2」中,有两个实现细节的原理值得我们拿放大镜来看看。那就是:

  • react 是如何在「以 workInProgress 为根节点的 fiber 子树」去找到它所有的直属子 DOM 节点的呢?
  • 对于特定的 workInProgress fiber 进行 complete-work,只是构建了一组上下层的 DOM 节点的父子关系,那 react 是如何构建出一棵完整的 DOM 树呢?

给定一个 HostComponent 类型的 fiber,如何去找到它所有的直属子 DOM 节点的呢?

其实,如果 react 规定组件只能够由宿主环境的原生 UI 标签(在 DOM 这个环境下,就是指 div 等原生 HTML 标签,其实严谨来说,应该得说 host component)来组成的话,那么这个问题就没有什么难度。解决的算法是: 从第一子 fiber 开始,遍历 workInProgress 的所有直属子 fiber 节点,依次将当前 fiber 节点已经创建好的 DOM 节点(通过访问 stateNode 属性值即可得到 DOM 节点)直接 append 到 父 DOM 容器里面就好:

function appendAllChildren(workInProgress){
    const parent = workInProgress.stateNode;
    const firstChild = workInProgress.child;
    let currentChild = firstChild
    
    while(currentChild !== null){
        const domInstance = currentChild.stateNode;
        parent.appendChild(domInstance);
        currentChild = currentChild.sibling;
    }
}

但是,问题是什么呢?问题是 react 构建组件的方式不是这样的!react 主打的就是「可组合性」。也即是说,一个组件的内部构成既可以包含宿主环境的原生 UI 标签也可以包含用户自定义的组件。而前者所说的自定义组件的内部构建既可以包含宿主环境的原生 UI 标签也可以包含用户自定义的组件,如此循环往复......上面这里所描述的正是 「react 复合组件」。再通俗点来讲,这里的复合组件基本上就是指我们平时所说的「function component」和 「class component」

因为有了复合组件,事情就变得复杂起来了。为什么?请看下面的代码:

当前我们有这样的组件树:

const Counter = () => {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  const decrement = useCallback(() => {
    setCount(count - 1);
  }, [count]);

  return (
    <div>
      <h1>Counter: {count}</h1>
      <button onClick={increment} style={{ fontSize: 10 + count }}>
        +
      </button>
      <button onClick={decrement}>-</button>
    </div>
  );
};


function App(){
    return (
        <div className="App">
          <header className="App-header">
            <img src={logo} className="App-logo" alt="logo" />
            <Counter />
            <p>
              Edit <code>src/App.js</code> and save to reload.
            </p>
            <a
              className="App-link"
              href="https://reactjs.org"
              target="_blank"
              rel="noopener noreferrer"
            >
              Learn React
            </a>
          </header>
        </div>
      );
}

那请问还能使用上面的那一套算法吗?答案是:“不能”。因为,相比于 DOM 树, 复合组件是一个抽象层,它所对应的 fiber 节点并不会保存真实的 DOM 节点。也就是说,上面给出的算法中,当遍历到 <Counter /> 的时候, domInstance 的值是为 null 的。

再换句话说,fiber 树跟真实的 DOM 树在层级上并不是一一对应的!!!

因为这种情况存在,所以,我们需要继续往子树的底层去访问,直到遍历到第一个为 HostComponent 或者 HostTextComponent 为止。

到了上面这一步我们就可以直接返回到 fiber 树的上层了吗?不能,因为,存在 <React.Fragment><React.Fragment>是所谓的空标签(有了它,我们就可以不必用一个多余的 host 标签去包住多个兄弟组件)。

也就说,我们还需要考虑 <Counter /> 组件的是下面这种实现的情况(用<></>代替了 <div></div>):

const Counter = () => {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  const decrement = useCallback(() => {
    setCount(count - 1);
  }, [count]);

  return (
    <>
      <h1>Counter: {count}</h1>
      <button onClick={increment} style={{ fontSize: 10 + count }}>
        +
      </button>
      <button onClick={decrement}>-</button>
    </>
  );
};

综上所述,给定一个 workInProgress fiber,基于以下的两种情况,我们不能只是往下遍历一层:

  • workInProgress fiber 的直属子 fiber 所对应的组件并不都是 host component,有可能包含 function component 或者 class component 等复合组件;
  • 复合组件有可能返回多个子组件

鉴于以上原因,我们需要使用「深度优先遍历算法」来向下找到 workInProgress 某个包含复合组件的子树中的第一个 host component 类型的 fiber。找到之后,就不需要继续往下查找了,而是用同样的算法在相邻的子树中查找该子树中第一个直属于 workInProgress 的 host component 类型的 fiber。这就是以下 react 源码的原理:

let appendAllChildren;
appendAllChildren = function (
      parent,
      workInProgress,
      needsVisibilityToggle,
      isHidden
    ) {
      // We only have the top Fiber that was created but we need recurse down its
      // children to find all the terminal nodes.
      let node = workInProgress.child;

      while (node !== null) {
        if (node.tag === HostComponent || node.tag === HostText) {
          appendInitialChild(parent, node.stateNode);
        } else if (node.tag === HostPortal);
        else if (node.child !== null) {
          node.child.return = node;
          node = node.child;
          continue;
        }

        if (node === workInProgress) {
          return;
        }

       // while (node.sibling === null) {
       //  if (node.return === null || node.return === workInProgress) {
       //     return;
       //   }
       //
       //   node = node.return;
       // }
        
        // 为了可读,我把源码中的两条语句调换一下
         while (node.sibling === null) {
          node = node.return;
          
          if(node  === nulll || node === workInProgress){
              return;
          }
        }

        node.sibling.return = node.return;
        node = node.sibling;
      }
    };

整个算法用流程表示如下:

graph TD
A(开始) --> B["let node = workInProgress.child;"]
B --> C{"当前 fiber 节点是 host component 类型吗?"}
C-->|是| D["把当前 node 所对应的 DOM 节点直接 append 到父 DOM 容器中"]
C-->|否| E{"当前 node 有子 fiber 吗?"}
E-->|有| F["node = node.child"]
F-->C
E-->|没有| G{"当前 node 有兄弟 fiber 吗?"}
G-->|有| H["node = node.sibling"]
H--> C
G-->|"没有,则返回上一级"|I["node = node.return"]
I-->J{"当前 node 已经等于 workInProgress 了吗?"}
J-->|是|K[证明已经遍历完了所有遍历的 fiber 节点]
J-->|否|G
D-->G
K--> L(结束)

react 是如何构建出一棵完整的 DOM 树呢?

上一个小节讲的是构建一层父子关系 DOM 树的实现原理,而一棵完整的 DOM 树是有很多这样的 DOM 层,那 react 是如何构建出一棵完整的 DOM 树呢?这个问题的答案就藏在 render 阶段的 work-loop 架构里面。

work-loop 架构包括两个要素:

  • 数据结构 - 需要被遍历的 react element 树;
  • 遍历算法 - 深度优先的遍历算法

在遍历一开始,react 首先会使用深度优先的算法依次「递」到当前子树中处于最底层的叶子节点,一路遍历,一路调用beginWork() 去创建 fiber 节点。当到达叶子节点的时候,就开始「归」了。归的时候,react 会对当前节点调用completeWork() 。调用完completeWork()之后,react 还会横向检查当前叶子节点是否有兄弟节点,如果有,则用同样的算法对「以该兄弟节点为根节点的 fiber 子树」采用同样的遍历算法。当同一层级中,只有当所有的 fiber 节点都完成了 complete-work 之后,react 才会往上回归,对父 fiber 节点进行 complete-work。

重点来了。正是这种遍历算法,就决定了两个事实:

  • 任何时刻,如果一个父 fiber 节点开始 complete-work,那么我们就可以推出另外一个事实:该父 fiber 节点的所有子 fiber 节点都已经完成了 complete-work;
  • 这是一个递归算法,遍历先「递」出去,最后层层「归」回来,直至到整个 fiber 树的根节点 - hostRootFiber

如果在 complete-work 的时候,我们只关注 host component 的 fiber 节点的话,那么整个过程就是一个自底向上,一层层地去构建 DOM 父子关系,直至到 fiber 树中最顶层的 host component 类型的 fiber 节点 为止的不断重复的「循环过程」。

  • 自底向上,层层向上 appendChild
  • 不断重复的循环过程
  • 直至到 fiber 树中最顶层的 host component 类型的 fiber 节点 为止

这正是 react 是建出一棵完整的 DOM 树的原理之所在。

总结

看来,我们在770 行代码还原 react fiber 初始链表构建过程一文中因为略过了 completeWork() 函数的研究而错过了 render 阶段的另外一个任务线:构建完成的 DOM 树。

总的说来,在 react 应用的 mount 阶段的 render 子阶段,存在两条主线任务线:

  • 第一条任务线:自顶向下地调用 beginWork() 构建一棵 fiber 树;
  • 第二条任务线:自底向上地调用 completeWork()构建一棵 DOM 树。

“在 react 应用的 mount 阶段,react 是怎样创建 DOM 节点,又是怎样渲染出最终的界面效果的呢?”

这是文章一开始抛出的疑问。细心之人可能会发现,到文章快结束了,我们其实还没有谈论这句话的后半句:「......,又是怎样渲染出最终的界面效果的呢?」。

是的,鉴于篇幅所限,本文到此为止。我会另外撰写一篇文章进行介绍这一点,敬请期待~