从0实现React18系列三-打标记

642 阅读6分钟

本系列是讲述从0开始实现一个react18的基本版本。由于React源码通过Mono-repo 管理仓库,我们也是用pnpm提供的workspaces来管理我们的代码仓库,打包我们使用rollup进行打包。

仓库地址

本章节主要是讲解我们如何将上一节得到的fiberNode树,渲染到页面中。React中commit分为下面三个阶段,这个我们下一节讲解:

  • beforeMutation阶段
  • mutation阶段
  • layout阶段

我们这节主要是从这几个问题上思考:

  1. 离屏DOM树的作用
  2. 如何打标签flags
  3. 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阶段,需要进行如下的流程:

  1. yx的placement
  2. span的placement
  3. hcc的placement
  4. p的placement
  5. 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元素。回到我们之前的例子生成的图:

fiber.jpg

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

而其实mountChildFibersreconcilerChildFibers的底层都是调用ChildReconciler

唯一的区别就是shouldTrackEffects不同,mountChildFibers因为有了离屏Dom树减少placement的标记,所以不需要打标记即shouldTrackEffectsfalse, 而更新阶段的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);
  }
}

传入的shouldTrackEffectstrue,即经过这个步骤,我们就成功的给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个方面

  1. 元素更新的标记
  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;
}