React18之update流程从零实现详解
sunnyhuang519626 人气:0引言
本系列是讲述从0开始实现一个react18的基本版本。由于React
源码通过Mono-repo 管理仓库,我们也是用pnpm
提供的workspaces
来管理我们的代码仓库,打包我们使用rollup
进行打包。
本章我们主要讲解通过useState
状态改变,引起的单节点update
更新阶段的流程。
对比Mount阶段
对比我们之前讲解的mount
阶段,update
阶段也会经历大致的流程, 只是处理逻辑会有不同:
之前的章节我们主要讲了reconciler
(调和) 阶段中mount
阶段:
beginWork
:向下调和创建fiberNode
树,completeWork
:构建离屏DOM树以及打subtreeFlags
标记。commitWork
:根据placement
创建domuseState
: 对应调用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
删除
key
不同key
相同,type
不同
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是如何保存数据的。
加载全部内容