从0实现React18系列五-update流程

1,570 阅读7分钟

本系列是讲述从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阶段:

  • 实现相对于mountStateupdateState

下面我们分别一一地实现单节点的update更新流程

beginWork流程

对于单一节点的向下调和流程,主要在childFibers文件中,分2种,一种是文本节点的处理reconcileSingleTextNode, 一种是标签节点的处理reconcileSingleElement

复用fiberNode

update阶段的话,主要有一点是要思考如何复用之前mount阶段已经创建的fiberNode

我们先以reconcileSingleElement为例子讲解。

当新的ReactElementtype 和 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;
        }
      }
    }
  }
}
  1. 首先我们需要判断currentFiber是否存在,当存在的时候,说明是进入了update阶段。
  2. 根据currentFiberelementtag 和 type判断,如果相同才可以复用。
  3. 通过双缓存树(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删除

  1. key不同
  2. 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种,

  1. 第一种是文本元素的更新,主要是新旧文本内容的不一样。
  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流程

通过beginWorkcompleteWork之后,我们得到了相应的标记。在commitWork阶段,我们就需要根据相应标记去处理不同的逻辑。本节主要讲解更新删除阶段的处理。

更新update

在之前的章节中,我们讲解了commitWorkmount阶段,我们现在根据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函数的是对每一个要删除的子节点进行处理。它的主要功能有几点:

  1. 对于不同类型的fiberNode, 当节点删除的时候,自身和所有子节点都需要执行的不同的卸载逻辑。例如:函数组件的useEffect的return函数执行,ref的解绑,class组件的componentUnmount等逻辑处理。
  2. 由于fiberNode和dom节点不是一一对应的,所以要找到fiberNode对应的dom节点,然后再执行删除dom节点的操作。
  3. 最后将删除的节点的childreturn指向删掉。

基于上面的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点。

  1. DFS深度遍历子节点
  2. 找到当前要删除的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>
}
  1. div的fiberNode的父节的标记ChildDeletion以及存放到deletions中。
  2. 当执行到commitWork阶段的时候,遍历deletions数组。
  3. 执行的div对应的HostComponent, 然后执行commitDeletion
  4. commitDeletion中执行commitNestedComponent向下DFS遍历。
  5. 在遍历的过程中,每一个节点都是执行一个回调函数,基于不同的类型执行不同的删除操作,以及记录我们要删除的Dom节点对应的fiberNode。
  6. 所以首先是div执行onCommitUnmount, 由于它是HostComponent,所以将rootHostNode赋值给了div
  7. 向下递归到Child节点,由于它存在子节点,继续递归到child-div节点,继续遍历到hello world节点。它不存在子节点。
  8. 然后找到Child的兄弟节点,以此执行,先子后兄。直到回到div节点。

未命名文件 (1).jpg

下一节预告

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