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 中,这不是真相。
意外发现
从 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 属性,结果也是如此的):
猜想
所以,到这里,我产生了一个大胆的猜想:“是不是在进入 commit 阶段之前,已经有有一颗完整的 DOM 树存在于 fiber 树上?” 也就是说, DOM 节点的创建并不发生在 commit 阶段,而是发生在 render 阶段。
验证猜想
在深入 react 源码之前,其实我们可以快速地验证一下的。验证的思路是:既然在进入 commit 阶段之前,我们已经可以拿到一个棵完整的 DOM 树,那么我们可以写点代码去自己把它挂载到页面上。实施步骤如下:
- 把 react 源码中的 commit 阶段入口函数
finishConcurrentRender()注释掉; - html 文档里面准备好
div#root2这个 DOM 容器; - 用下面的代码代替
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); })();
经过上面的操作,保存代码,刷新界面,我们将看到下面的界面:
上面截图中,我们把一颗完整的 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(”(注意这里是特意少写了右括号),结果如下:
在搜索结果中,只有两个搜索结果是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 节点」流程。这三个条件是:
if (current !== null && workInProgress.stateNode != null){...}- 因为,我们当前研究的是 react 应用的 mount 阶段,所以currentfiber是为null的。所以,react 不会进入这个分支语句里面;if (!newProps){...}- 即使一个 host component 啥prop(注意 jsx 的 children 最终也是会被转为prop) 都没有,它的props值都会是空的字面量对象{}。所以,极少数 react 会进入这个分支语句;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」 没啥好讲的,在 DOM 这个宿主环境里,它就是对
document.createElement()的封装,参数type就是所需创建的 DOM 节点类型,比如div,p,a和img等。最终返回所创建 DOM 节点元素。也许你很好奇,这里为什么称之为“instance(实例)”呢?其实,你应该想到 react 是一个跨平台的架构,就 react core 而言,它应该是平台无关的。假如我们此处称之为“domElement”,那么当我们切换看代码的上下文,比如在 react 的另外的一个 renderer 中 - react native,那么这个命名就不准确了。
-
「语句2」 -
appendAllChildren()才是重点。就是在这个函数里面,react 实现了 DOM 树的上下层级关系的构建。当前,instance就是父 DOM 节点,而它的所有子 DOM 节点都储存在以workInProgress为根节点的 fiber 子树中。所以, appendAllChildren 函数的职责就是遍历当前 fiber 子树,找到子树上的所有的直属子 DOM 节点(注意,这里强调的是「直属」!),依次把它们 append 到当前的instanceDOM 容器里面来。 -
「语句3」就是负责把新构建好的 DOM 子树的根节点挂载到 fiber 的
stateNode属性上。之前我也说过,不同类型的 fiber 节点,它的stateNode属性值的语义是不一样的。 从这里,我们也可以看出,对于 HostComponent 的 fiber 节点来说,它的stateNode属性值挂载的就是 DOM 节点。
细节深究
在上面的「语句2」中,有两个实现细节的原理值得我们拿放大镜来看看。那就是:
- react 是如何在「以
workInProgress为根节点的 fiber 子树」去找到它所有的直属子 DOM 节点的呢? - 对于特定的
workInProgressfiber 进行 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,基于以下的两种情况,我们不能只是往下遍历一层:
workInProgressfiber 的直属子 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 节点,又是怎样渲染出最终的界面效果的呢?”
这是文章一开始抛出的疑问。细心之人可能会发现,到文章快结束了,我们其实还没有谈论这句话的后半句:「......,又是怎样渲染出最终的界面效果的呢?」。
是的,鉴于篇幅所限,本文到此为止。我会另外撰写一篇文章进行介绍这一点,敬请期待~