目录
- 引言
- 对比Mount阶段
- beginWork流程
- 复用fiberNode
- 删除旧的和新建fiberNode
- completeWork流程
- commitWork流程
- 更新update
- commitUpdate
- 删除ChildDeletion
- commitNestedComponent
- 总结
引言
本系列是讲述从0开始实现一个react18的基本版本。由于React源码通过Mono-repo 管理仓库,我们也是用pnpm提供的workspaces来管理我们的代码仓库,打包我们使用rollup进行打包。
仓库地址
具体章节代码3个commit
本章我们主要讲解通过useState状态改变,引起的单节点update更新阶段的流程。
对比Mount阶段
对比我们之前讲解的mount阶段,update阶段也会经历大致的流程, 只是处理逻辑会有不同:
之前的章节我们主要讲了reconciler(调和) 阶段中mount阶段:
-
beginWork:向下调和创建fiberNode树,
-
completeWork:构建离屏DOM树以及打subtreeFlags标记。
-
commitWork:根据placement创建dom
-
useState: 对应调用mountState
这一节的update阶段如下:
begionWork阶段:
- 处理
ChildDeletion的删除的情况
- 处理节点移动的情况 (abc -> bca)
completeWork阶段:
- 基于
HostText的内容更新标记更新flags
- 基于
HostComponent属性变化标记更新flags
commitWork阶段:
- 基于
ChildDeletion, 遍历被删除的子树
- 基于
Update, 更新文本内容
useState阶段:
- 实现相对于
mountState的updateState
下面我们分别一一地实现单节点的update更新流程
beginWork流程
对于单一节点的向下调和流程,主要在childFibers文件中,分2种,一种是文本节点的处理reconcileSingleTextNode, 一种是标签节点的处理reconcileSingleElement。
复用fiberNode
在update阶段的话,主要有一点是要思考如何复用之前mount阶段已经创建的fiberNode。
我们先以reconcileSingleElement为例子讲解。
当新的ReactElement的type 和 key都和之前的对应的fiberNode都一样的时候,才能够进行复用。我们先看看reconcileSingleElement是复用的逻辑。
function reconcileSingleElement(
returnFiber: FiberNode,
currentFiber: FiberNode | null,
element: ReactElementType
) {
const key = element.key;
// update的情况 <单节点的处理 div -> p>
if (currentFiber !== null) {
// key相同
if (currentFiber.key === key) {
// 是react元素
if (element.$$typeof === REACT_ELEMENT_TYPE) {
// type相同
if (currentFiber.type === element.type) {
const existing = useFiber(currentFiber, element.props);
existing.return = returnFiber;
return existing;
}
}
}
}
}
- 首先我们需要判断
currentFiber是否存在,当存在的时候,说明是进入了update阶段。
- 根据
currentFiber和element的tag 和 type判断,如果相同才可以复用。
- 通过双缓存树(
useFiber)去复用fiberNode。
useFiber
复用的逻辑本质就是调用了useFiber, 本质上,它是通过双缓存书指针alternate,它接受已经渲染对应的fiberNode以及新的Props 巧妙的运用我们之前创建wip的逻辑,可以很好的复用fiberNode。
/**
* 双缓存树原理:基于当前的fiberNode创建一个新的fiberNode, 而不用去调用new FiberNode
* @param {FiberNode} fiber 正在展示的fiberNode
* @param {Props} pendingProps 新的Props
* @returns {FiberNode}
*/
function useFiber(fiber: FiberNode, pendingProps: Props): FiberNode {
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
对于reconcileSingleTextNode
删除旧的和新建fiberNode
当不能够复用fiberNode的时候,我们除了要像mount的时候新建fiberNode(已经有的逻辑),还需要删除旧的fiberNode。
我们先以reconcileSingleElement为例子讲解。
在beginWork阶段,我们只需要标记删除flags。以下2种情况我们需要额外的标记旧fiberNode删除
function deleteChild(returnFiber: FiberNode, childToDelete: FiberNode) {
if (!shouldTrackEffects) {
return;
}
const deletions = returnFiber.deletions;
if (deletions === null) {
// 当前父fiber还没有需要删除的子fiber
returnFiber.deletions = [childToDelete];
returnFiber.flags |= ChildDeletion;
} else {
deletions.push(childToDelete);
}
}
我们将需要删除的节点,通过数组形式赋值到父节点deletions中,并标记ChildDeletion有节点需要删除。
对于reconcileSingleTextNode, 当渲染视图中是HostText就可以直接复用。整体代码如下:
function reconcileSingleTextNode(
returnFiber: FiberNode,
currentFiber: FiberNode | null,
content: string | number
): FiberNode {
// update
if (currentFiber !== null) {
// 类型没有变,可以复用
if (currentFiber.tag === HostText) {
const existing = useFiber(currentFiber, { content });
existing.return = returnFiber;
return existing;
}
// 删掉之前的 (之前的div, 现在是hostText)
deleteChild(returnFiber, currentFiber);
}
const fiber = new FiberNode(HostText, { content }, null);
fiber.return = returnFiber;
return fiber;
}
completeWork流程
当在beginWork做好相应的删除和移动标记后,在completeWork主要是做更新的标记。
对于单一的节点来说,更新标记分为2种,
- 第一种是文本元素的更新,主要是新旧文本内容的不一样。
- 第二种是类似div的属性等更新。这个我们下一节进行讲解。
这里我们只对HostText中的类型进行讲解。
case HostText:
if (current !== null && wip.stateNode) {
//update
const oldText = current.memoizedProps.content;
const newText = newProps.content;
if (oldText !== newText) {
// 标记更新
markUpdate(wip);
}
} else {
// 1. 构建DOM
const instance = createTextInstance(newProps.content);
// 2. 将DOM插入到DOM树中
wip.stateNode = instance;
}
bubbleProperties(wip);
return null;
从上面我们可以看出,我们根据文本内容的不同,进行当前节点wip进行标记。
function markUpdate(fiber: FiberNode) {
fiber.flags |= Update;
}
commitWork流程
通过beginWork和completeWork之后,我们得到了相应的标记。在commitWork阶段,我们就需要根据相应标记去处理不同的逻辑。本节主要讲解更新和删除阶段的处理。
更新update
在之前的章节中,我们讲解了commitWork的mount阶段,我们现在根据update的flag进行逻辑处理。
// flags update
if ((flags & Update) !== NoFlags) {
commitUpdate(finishedWork);
finishedWork.flags &= ~Update;
}
commitUpdate
对于文本节点,commitUpdate主要是根据新的文本内容,更新之前的dom的文本内容。
export function commitUpdate(fiber: FiberNode) {
switch (fiber.tag) {
case HostText:
const text = fiber.memoizedProps.content;
return commitTextUpdate(fiber.stateNode, text);
}
}
export function commitTextUpdate(textInstance: TestInstance, content: string) {
textInstance.textContent = content;
}
删除ChildDeletion
在beginWork过程中,对于存在要删除的子节点,我们会保存在当前父节点的deletions, 所以在删除阶段,我们需要根据当前节点的deletions属性进行对要删除的节点进行不同的处理。
// flags childDeletion
if ((flags & ChildDeletion) !== NoFlags) {
const deletions = finishedWork.deletions;
if (deletions !== null) {
deletions.forEach((childToDelete) => {
commitDeletion(childToDelete);
});
}
finishedWork.flags &= ~ChildDeletion;
}
如果当前节点存在要删除的子节点的话,我们需要对每一个子节点进行commitDeletion的操作。
commitDeletion
commitDeletion函数的是对每一个要删除的子节点进行处理。它的主要功能有几点:
- 对于不同类型的
fiberNode, 当节点删除的时候,自身和所有子节点都需要执行的不同的卸载逻辑。例如:函数组件的useEffect的return函数执行,ref的解绑,class组件的componentUnmount等逻辑处理。
- 由于
fiberNode和dom节点不是一一对应的,所以要找到fiberNode对应的dom节点,然后再执行删除dom节点的操作。
- 最后将删除的节点的
child和return指向删掉。
基于上面的2点分析,我们很容易就想到,commitDeletion肯定会执行DFS向下遍历,进行不同子节点的删除逻辑处理。
/**
* rootHostNode 找到对应的DOM节点。
* commitNestedComponent DFS遍历节点的进行卸载相关的逻辑
* @param {FiberNode} childToDelete
*/
function commitDeletion(childToDelete: FiberNode) {
let rootHostNode: FiberNode | null = null;
// 递归子树
commitNestedComponent(childToDelete, (unmountFiber) => {
switch (unmountFiber.tag) {
case HostComponent:
if (rootHostNode === null) {
rootHostNode = unmountFiber;
}
// TODO: 解绑ref
return;
case HostText:
if (rootHostNode === null) {
rootHostNode = unmountFiber;
}
return;
case FunctionComponent:
// TODO: useEffect unmount 解绑ref
return;
default:
if (__DEV__) {
console.warn("未处理的unmount类型", unmountFiber);
}
break;
}
});
// 移除rootHostNode的DOM
if (rootHostNode !== null) {
const hostParent = getHostParent(childToDelete);
if (hostParent !== null) {
removeChild((rootHostNode as FiberNode).stateNode, hostParent);
}
}
childToDelete.return = null;
childToDelete.child = null;
}
commitNestedComponent
commitNestedComponent中主要是完成我们上面说的2点。
- DFS深度遍历子节点
- 找到当前要删除的
fiberNode对应的真正的DOM节点
接受2个参数。1. 当前的fiberNode, 2. 递归到不同的子节点的同时,需要执行的回调函数执行不同的卸载流程。
function commitNestedComponent(
root: FiberNode,
onCommitUnmount: (fiber: FiberNode) => void
) {
let node = root;
while (true) {
onCommitUnmount(node);
if (node.child !== null) {
// 向下遍历
node.child.return = node;
node = node.child;
continue;
}
if (node === root) {
// 终止条件
return;
}
while (node.sibling === null) {
if (node.return === null || node.return === root) {
return;
}
// 向上归
node = node.return;
}
node.sibling.return = node.return;
node = node.sibling;
}
}
这里可能比较绕,我们下面通过几个例子总结一下,这个过程的主要流程。
总结
如果按照如下的结构,要删除外层div元素,会经历如下的流程
<div>
<Child />
<span>hcc</span>
yx
</div>
function Child() {
return <div>hello world</div>
}
-
div的fiberNode的父节的标记ChildDeletion以及存放到deletions中。
- 当执行到
commitWork阶段的时候,遍历deletions数组。
- 执行的div对应的
HostComponent, 然后执行commitDeletion
- 在
commitDeletion中执行commitNestedComponent向下DFS遍历。
- 在遍历的过程中,每一个节点都是执行一个回调函数,基于不同的类型执行不同的删除操作,以及记录我们要删除的Dom节点对应的fiberNode。
- 所以首先是
div执行onCommitUnmount, 由于它是HostComponent,所以将rootHostNode赋值给了div
- 向下递归到
Child节点,由于它存在子节点,继续递归到child-div节点,继续遍历到hello world节点。它不存在子节点。
- 然后找到
Child的兄弟节点,以此执行,先子后兄。直到回到div节点。

下一节预告
下一节我们讲解通过useState改变状态后,如何更新节点以及函数组件hooks是如何保存数据的。