本系列是讲述从0开始实现一个react18的基本版本。由于React
源码通过Mono-repo 管理仓库,我们也是用pnpm
提供的workspaces
来管理我们的代码仓库,打包我们使用rollup
进行打包。
本章节主要是讲解我们如何将上一节得到的fiberNode树,渲染到页面中。React
中commit分为下面三个阶段,这个我们下一节讲解:
- beforeMutation阶段
- mutation阶段
- layout阶段
我们这节主要是从这几个问题上思考:
- 离屏DOM树的作用
- 如何打标签
flags
flags
分布在不同fiberNode
中,如何快速找到他们
上一节我们知道了react在调和的过程中,分别通过2个方法beginWork
以及completeWork
方法进行调和。他们分别对应:
- 递:
beginWork
是向下遍历 - 归:
completeWork
是向上遍历
他们分别对应不同的功能:
beginWork
: 标记fiberNode
的插入、删除、移动。
completeWork
: 创建或者标记元素更新。flags
冒泡
打标记Flags
React在调和的过程中,除了生成wip fiberNode
树之外,还需要标记副作用flags
。主要是包含如下几个flags
标记
export const NoFlags = 0b0000000; // 没有副作用
export const Placement = 0b0000001; // 插入
export const Update = 0b0000010; // 更新
export const ChildDeletion = 0b000100; // 删除
例如下面这个例子:A 组件包含一个B组件
<A><B/><A/>
当进入A组件的begionWork
时,对比B的current fiberNode
与 B的ReactElement
, 去生成对应的wip fiberNode
。
在此过程中,我们就需要标记这个wip fiberNode
未插入的flag
: Placement。
离屏Dom树
当开始初始化的时候,每一个元素都是新增的元素,每个都会被打上Placement
标签,React
内部通过离屏dom树减少执行Placement
的操作,例如下面这个结构:
<div>
<p>hcc</p>
<span>yx</span>
</div>
正常情况下,在mount
阶段,需要进行如下的流程:
- yx的
placement
- span的
placement
- hcc的
placement
- p的
placement
- div的
placement
要执行5次placement
的操作,在初始化的时候,可以通过离屏DOM树,只对div
执行1次Placement
操作
离屏Dom树插入时机
DOM
树都是从下向上进行构建,和我们completeWork
的执行步骤一样。所以构建离屏Dom树的时机是completeWork
。
在上一节中,我们了解到最后当最顶层的div
执行完completeWork
的时候,我们已经得到了一个dom的树结构。那是不是我们在最后的completeWork
, 直接打了placement
的div插入到dom中,就可以完成渲染操作。
如何打flags
React
中,从上面的离屏Dom树中,我们了解在初始化的时候,除了根元素,其他都不需要打flag
, 所以我们需要一个标记,标记什么时候需要插入,什么时候不需要插入。在React
的源码中,是通过shouldTrackEffects
上一小节中我们知道了可以通过离屏Dom
树去减少打标记,所以在初始化的时候,理论上只需要标记最上层的Dom元素。回到我们之前的例子生成的图:
beginwork
打标记
当从HostRootFiber
开始向下调和的时候,针对不同的tag
类型走不同的逻辑,最后都会走到reconcileChildren
去继续向下调和。
/**
* 对比子节点的current fiberNode 和 子节点的ReactElement 生成对应的子节点的fiberNode
* @param {FiberNode} wip
* @param {ReactElementType} children
*/
function reconcileChildren(wip: FiberNode, children?: ReactElementType) {
const current = wip.alternate;
if (current !== null) {
// update
wip.child = reconcilerChildFibers(wip, current?.child, children);
} else {
// mount
wip.child = mountChildFibers(wip, null, children);
}
}
根据上一节我们知道当HostRootFiber
传入的时候,它是由alternate
属性指向的,所以会走到update
的分支。执行reconcilerChildFibers
。
而其实mountChildFibers
和reconcilerChildFibers
的底层都是调用ChildReconciler
。
唯一的区别就是shouldTrackEffects
不同,mountChildFibers
因为有了离屏Dom树
减少placement
的标记,所以不需要打标记即shouldTrackEffects
为false
, 而更新阶段的reconcilerChildFibers
需要插入、删除标记 shouldTrackEffects
则为true
。
export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);
reconcilerChildFibers
这个函数主要是根据wip
和它的子元素的ReactElement
去生成对应子fiberNode
,其中current?.child
为上一次渲染的值。children
为子的ReactElement
对象。
wip.child = reconcilerChildFibers(wip, current?.child, children);
reconcilerChildFibers
的内部其实就是执行reconcileSingleElement
,用户处理父子的fiberNode
的银色关系。以下是一个抽象的代码:
/**
* 根据子ReactElement元素生成对应子fiberNode, 并return到父fiberNode
/*
function reconcileSingleElement(returnFiber, _currentFiber, element) {
const fiber = createFiberFromElement(element);
fiber.return = returnFiber;
return fiber;
}
placeSingleChild
函数的处理逻辑
placeSingleChild: 这个函数就是主要的打标记函数。主要是根据shouldTrackEffects
来判断是否打标记,接收reconcileSingleElement
刚刚生成的子fiberNode。
按照例子:因为是mount
阶段,除了hostFiberNode
, 其他节点都没有alternate
指向。所以对于hostFiberNode
的子元素div
的处理逻辑就走到update
这个分支:
function reconcileChildren(wip: FiberNode, children?: ReactElementType) {
const current = wip.alternate;
if (current !== null) {
// update
wip.child = reconcileChildFibers(wip, current?.child, children);
} else {
// mount
wip.child = mountChildFibers(wip, null, children);
}
}
传入的shouldTrackEffects
为true
,即经过这个步骤,我们就成功的给hostFiberNode
的子元素div
类型的fiberNode打了placement
标记。
// 给子fiberNode打标记: fiber对应wip的子fiber
function placeSingleChild(fiber) {
if (shouldTrackEffects && fiber.alternate === null) {
// 首屏渲染的情况
fiber.flags |= Placement;
}
return fiber;
}
而在初始化的时候,除了hostFiberNode
其他的节点都没有alternate
指向,所以会走到mountChildFibers
, 由于此时shouldTrackEffects
为false, placeSingleChild
就会跳过标记。直接返回,这样就做到了初始化渲染的只标记了一次hostFiberNode
的子元素。
completeWork
打标记
completeWork
主要是2个方面
- 元素更新的标记
- flags冒泡
更新的部分我们之后统一讲解,现在主要是completeWork
如何处理flags
冒泡, 以及为什么需要冒泡。
我们知道当更新流程经过Reconciler
后,我们会得到一个Wip Fiber Tree, 其中部分的fiberNode
会被标记Flag
,之后commit阶段根据不同的标记执行不同的Dom操作。那么如何高效的去查找散落在Wip Fiber Tree各处的“被标记的FiberNode”。
这就是flags
冒泡的作用,快速的定位到那些fiberNode
需要进行Dom操作。
冒泡是一个自下向上的一个过程,调和阶段completeWork
是从叶子节点开始整体自下而上,所有在completeWork
处理flags
冒泡最合适不过。
fiberNode.flags : 自身节点的操作标记
fiberNode: subtreeFlags: 收集子fiberNode的标记状态
在completeWork
完成基本操作后,每一步都会执行bubbleProperties
。
export const completeWork = (wip: FiberNode) => {
const newProps = wip.pendingProps;
const current = wip.alternate;
switch (wip.tag) {
case HostComponent:
// xxx
bubbleProperties(wip);
return null;
case HostText:
// xxx
bubbleProperties(wip);
return null;
case HostRoot:
// xxx
bubbleProperties(wip);
return null;
}
};
bubbleProperties
接收一个参数,当前正在调和的fiberNode
function bubbleProperties(wip: FiberNode) {
let subtreeFlags = NoFlags;
let child = wip.child;
while (child !== null) {
// 收集子fiberNode的子孙fiberNode中标记的flags
subtreeFlags |= child.subtreeFlags;
// 收集子fiberNode标记的fiberNode
subtreeFlags |= child.flags;
child.return = wip;
child = child.sibling;
}
// 附加到当前fiberNode的标记中
wip.subtreeFlags |= subtreeFlags;
}