React 源码解析系列 - React 的 render 阶段(二):beginWork

2,371 阅读21分钟

系列文章目录(同步更新)

本系列文章均为讨论 React v17.0.0-alpha 的源码

下面来介绍 React Render 的“递”阶段 —— beginWork ,在《React 源码解析系列 - React 的 render 阶段(一):基本流程介绍》中我们可知 beginWork 的主要作用是创建本次循环(performUnitOfWork)主体(unitOfWork)的子 Fiber 节点,其流程如下:

beginWork 的流程图

从上图可知,beginWork 的工作路径有四条:

  • mount (首屏渲染)时创建新的子 Fiber 节点,并返回该新建节点;
  • update时若不满足复用条件,则与 mount 时一样创建新的子 Fiber 节点,并 diff 出相应的 effectTag 挂在子 Fiber 节点上,并返回该新建节点;
  • update时若满足复用条件,且判断仍需继续处理其子节点的后代,则返回复用后的子 Fiber 节点
  • update时若满足复用条件,且判断不需继续处理其子节点的后代,则直接返回 null 值;

归纳一下:

  • 前两者是主要的工作路径;
  • 第三条工作路径 —— “复用节点”实际上在第二条工作路径 —— reconcileChildFibers(update) 时也会有类似的实现,或者说是不同层次的“复用节点”;
  • 而第四条工作路径 —— “直接返回 null 值”这就是属于“深度遍历”过程中,名为“剪枝”的优化策略,可以减少不必要的渲染,提高性能。

beginWork 的入参

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...省略函数体
}

beginWork 有3个参数,但目前我们只关注前两个:

  • current:与本次循环主体(unitOfWork)对应的 current 树上的节点,即 workInProgress.alternate 。
  • workInProgress :本次循环主体(unitOfWork),也即待处理的 Fiber 节点。

判断是 mount 还是 update

从 beginWork 的流程图中可知,第一个流程分支是判断当前为 mount(首屏渲染) 还是 update ;其判断的依据是:入参 current 是否为null,这是因为 mount(首屏渲染) 时, FiberRootNode 的 current 指针指向null,后续还有很多地方都需要根据这个判断来做不同的处理。

主要工作路径

switch (workInProgress.tag) {
  case IndeterminateComponent: 
    // ...省略
  case LazyComponent: 
    // ...省略
  case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
  case ClassComponent: 
    // ...省略
  case HostRoot:
    // ...省略
  case HostComponent:
    // ...省略
  case HostText:
    // ...省略
  // ...省略其他类型
}

mount(首屏渲染) 时会根据不同的 workInProgress.tag(组件类型)来进入到不同的子节点创建逻辑,我们关注最常见的组件类型:FunctionComponent(函数组件) / ClassComponent(类组件) / HostComponent(对标 HTML 标签),最终这些逻辑都会进入 reconcileChildren 方法。

reconcileChildren

下面来看看 reconcileChildren 方法:

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes
) {
  if (current === null) {
    // 对于mount的组件
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    // 对于update的组件
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

从函数名 —— reconcileChildren 就能看出这是 Reconciler 模块的核心部分;这里我们看到会根据 mount(首屏渲染) 还是 update 来走不同的方法 —— mountChildFibers | reconcileChildFibers ,但不论走哪个逻辑,最终都会生成新的子 Fiber 节点并赋值给 workInProgress.child ,并作为下次循环(performUnitOfWork)执行时的循环主体(unitOfWork); 下面我们来看看这两个方法是什么。

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

从上面代码可以看出, mount 时执行的 reconcileChildFibers 和 update 时执行的 mountChildFibers 方式,实际上都是由 ChildReconciler 这个方法封装出来的,差别只在于传参不同。

ChildReconciler

下面来看 ChildReconciler

// shouldTrackSideEffects 表示是否追踪副作用
function ChildReconciler(shouldTrackSideEffects) {
    /* 内部函数集合 */
    function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
        if (!shouldTrackSideEffects) { // 如不需要追踪副作用则直接返回
            // Noop.
            return;
        }
	/* 在当前节点(returnFiber)上标记删除目标节点 */
        const deletions = returnFiber.deletions;
        if (deletions === null) {
            returnFiber.deletions = [childToDelete]; // 加入“待删除子节点”的数组中
            returnFiber.flags |= ChildDeletion; // 标记当前节点需要删除子节点
        } else {
            deletions.push(childToDelete);
        }
    }
    function placeSingleChild(newFiber: Fiber): Fiber {
	/* 标记插入新的Fiber节点(如果没有alternate,即对应的current Fiber 节点的话) */
        if (shouldTrackSideEffects && newFiber.alternate === null) {
            newFiber.flags |= Placement;
        }
        return newFiber;
    }
	
    // ...还有其它很多内部函数

    /* 主流程 */
    function reconcileChildFibers(
        returnFiber: Fiber,
        currentFirstChild: Fiber | null,
        newChild: any,
        lanes: Lanes,
    ): Fiber | null {// 省略}

    return reconcileChildFibers; // 返回主方法,其中已经通过闭包联系上一堆内部方法了
}

从上面的代码我们可以看出 ChildReconciler 实际上是通过闭包封装了一堆内部函数,其主要流程实际上就是 reconcileChildFibers 这个方法,而在 reconcileChildren 方法中的调用也正是调用的这个 reconcileChildFibers 方法;我们解读一下该方法的入参:

  • returnFiber:当前 Fiber 节点,即 workInProgress
  • currentFirstChild:current 树上对应的当前 Fiber 节点的第一个子 Fiber 节点,mount 时为 null
  • newChild:子节点(ReactElement)
  • lanes:优先级相关

然后我们回过头来看这 ChildReconciler 方法的入参 —— shouldTrackSideEffects ,这个参数的字面意思是“是否需要追踪副作用”,所谓的“副作用”,指的就是是否需要做 DOM 操作,需要的话就会在当前 Fiber 节点中打上 EffectTag ,即“追踪”副作用;而也仅有在 update 的时候,才需要“追踪副作用”,即把 current 这个 Fiber 节点与本次更新组件状态后的 ReactElement 做对比(diff),然后得出本次更新的 Fiber 节点,以及在该节点上打上 diff 的结果 —— EffectTag 。

子节点(ReactElement)

这里需要展开说明一下 子节点(ReactElement) 是怎么来的:

  • 针对组件中的 jsx 代码,babel 会在编译阶段将其转换成一个 React.createElement() 调用的代码段。
  • 如果是类组件,则执行其 render 成员方法,并得到 React.createElement() 执行的结果 —— 一个ReactElement 对象。
  • 如果是函数组件,则直接执行,同样得到一个 ReactElement 对象。
  • 如果是 HostComponent ,即一般的 HTML ,同样也是获得一个 ReactElement 对象。
  • React.createElement 的源代码请看这里

reconcileChildFibers

reconcileChildFibers 方法中,首先会判断 newChild 的类型,来进入到不同逻辑中。

主要有这些类型:

  • ReactElement
  • Portal
  • React.Lazy包裹后的元素
  • 数组
  • 纯文本(包括 number 和 string)
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | null {
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) { // 根据$$typeof属性来进一步区分类型
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
      case REACT_PORTAL_TYPE:
      // 省略
      case REACT_LAZY_TYPE:
      // 省略
    }
    /* 处理子节点是一个数组的情况 */
    if (isArray(newChild)) {
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }

    // 省略
  }
  /* 处理纯文本 */
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        lanes,
      ),
    );
  }

  // 省略
}

newChild 的数据结构

需要注意的是,newChild —— 也就是 ReactElement 的数据结构跟 Fiber 的有比较大的差异,举例,针对以下组件:

function App() {
  return (
    <div>
      <img />
      <p />
    </div>
  )
}

这里的 Fiber 树结构(链表)应该如下所示:

<App />
↓
<div />
↓
<img /> → <p />

而对应的 ReactElement 结构(可以支持数组)则如下:

<App />
↓
<div />
↓
[<img />, <p />]

$$typeof

从上面的代码中,我们看到除了直接用 newChild 的数据类型来判断走哪个代码分支外,还用了 newChild.$$typeof 来判断,这个 $$typeof 就是当前 ReactElement 的类型,它的值是一个 Symbol 值,并且是已经预先定义好的,我们可以看到在 ReactElement 的工厂函数中,已经对 $$typeof 复制为 REACT_ELEMENT_TYPE 了。

为什么需要有这 $$typeof 属性呢?是因为需要防止 XSS 攻击:当应用允许存储并回显一个 JSON 对象时,恶意用户可构建一个伪 ReactElement 对象,形如下面的例子,如果 React 不加分辨,则会直接将该伪 ReactElement 对象渲染到 DOM 树上。因此从 React 0.14 版本后,React 会为每个真正的 ReactElement 添加 $$typeof 属性,只有拥有该属性的 ReactElement 对象才会被 React 渲染;而由于该属性为 Symbol 类型,无法使用 JSON 来构造,因此便能堵住这一漏洞。

/* 恶意的json对象 */
var xssJsonObject = {
  type: 'div',
  props: {
    dangerouslySetInnerHTML: {
      __html: '/* 恶意脚本 */'
    },
  },
  // ...
};

创建单个 Fiber 子节点 —— reconcileSingleElement

接着,我们以 ReactElement 类型的处理逻辑为示例继续往下走,会调用 reconcileSingleElement 方法。注意,既然走了这个分支,也就意味着 newChild 这个 ReactElement 并没有兄弟节点;因此在更新时,一旦创建/复用了 Fiber 节点,那么就可以把当前 Fiber 节点下的其它 current 子节点(上次 render 时存在的 Fiber 子节点)给标记为删除,方便 commit 阶段删除这些多余的 DOM 节点。

尝试复用 current 树上对应的子 Fiber 节点

在该方法中,首先会有这么一个 while 循环:

    while (child !== null) { // 这里的child就是对应当前Fiber节点的current节点的第一个子Fiber节点
      if (child.key === key) { // 比较key值是否有变化,这是复用Fiber节点的先决条件
        switch (child.tag) {
          case Fragment: {
            // 省略
          }
          case Block: {
            // 省略
          }
          default: {
            if (
              child.elementType === element.type ||
              (__DEV__
                ? isCompatibleFamilyForHotReloading(child, element)
                : false)
            ) {
              deleteRemainingChildren(returnFiber, child.sibling); // 已找到可复用Fiber子节点且确认只有一个子节点,因此标记删除掉该child节点的所有sibling节点
              const existing = useFiber(child, element.props); // 复用child节点
              existing.ref = coerceRef(returnFiber, child, element); // 处理ref
              existing.return = returnFiber; // 重置新Fiber节点的return指针,指向当前Fiber节点
              return existing;
            }
            break;
          }
        }
        /* 考虑到唯一符合key值的Fiber子节点也不能复用,因此标记删除掉所有Fiber子节点并退出循环 */
        deleteRemainingChildren(returnFiber, child);
        break; // 退出循环
      } else {
        deleteChild(returnFiber, child); // key不一样,不能复用,标记删除
      }
      child = child.sibling; // 指针指向下一个sibling节点,尝试进行复用
    }

上面这段代码的作用是找出上次更新中, current 树对应 Fiber 节点中所有不可复用的子节点,并在 当前 Fiber 节点(returnFiber)中标记需要删除的 effectTag ;判断的标准大致是 key 属性(child.key === key)和 elementType 属性(child.elementType === element.type)。

复用的流程基本如下:

  1. deleteRemainingChildren(returnFiber, child.sibling),这是因为走到 reconcileSingleElement 这个方法中意味着当前 Fiber 节点只有一个 Fiber 子节点,因此找到可复用的子节点后,可以标记删除掉剩下的(sibling) Fiber 子节点。
  2. const existing = useFiber(child, element.props);,调用 useFiber 方法来复用 Fiber 子节点。
  3. existing.return = returnFiber;,建立 Fiber 子节点(existing)与当前 Fiber 节点(returnFiber)的父子关系(return属性)。

复用子节点 —— useFiber

复用 Fiber 子节点所调用的是 useFiber 方法,我们回顾下是怎么调用这个方法的:const existing = useFiber(child, element.props);

这里的 child 指的是确定可以复用的子 Fiber 节点,而 element.props 则是本次更新时 ReactElement 获得的 props 值(该值也被称为 pendingProps)。

然后我们再看 useFiber 这个方法本身:

function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
    const clone = createWorkInProgress(fiber, pendingProps);
    clone.index = 0; // 重置一下:当前子节点必然为第一个子节点(因为只有1个子节点)
    clone.sibling = null; // 重置一下:当前子节点没有sibling(因为只有1个子节点)
    return clone;
}

可以看出这个方法主要就是调用了 createWorkInProgress 方法。

createWorkInProgress

我们接下来看看 createWorkInProgress 方法干了什么:

export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  /*
    如果current.alternate为空(这里先不要理解成是workInProgress),
    则复用current节点,再根据本次更新的props来new一个FiberNode对象
  */
  if (workInProgress === null) {
    // createFiber是Fiber节点(FiberNode)的工厂方法
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode, // mode属性表示渲染模式,一个二进制值
    );
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode; // DOM 节点

    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    // 如果current.alternate不为空,则重置workInProgress的pendingProps/type/effectTag等属性
  }
  // 复制current的子节点、上次更新时的props和state
  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  // 复制current的指针
  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.ref = current.ref;

  return workInProgress;
}

这里需要关注的重点是:

  • 如果 current.alternate 不为空,那此时 current.alternate 应该是上上次更新时的树节点,我们可以留意到这种场景下,并没有创建新的 Fiber 节点,而是直接复用了这个 current.alternate 节点(只是对它的一些属性进行重置),这就可以看出“双缓存”的本质,并非是“每创建一棵新的 Fiber 树就把上上次更新时的 Fiber 树抛弃掉”,而是”在创建本次更新的 Fiber 树时,尽量复用上上次更新时的 Fiber 树,保证任一时刻最多只有两棵 Fiber 树节点”;而所谓的 current 和 workInProgress ,其实都是相对的,只是取决于此时的 FiberRootNode 的 current 属性指向哪棵 Fiber 树而已。
  • FiberNode 上的 node 属性表示渲染模式,是一个二进制值,具体定义在这里

无法复用 Fiber 子节点,创建一个新的

如果没有可复用的子节点的话,会进入创建新的子节点的逻辑:

if (element.type === REACT_FRAGMENT_TYPE) {
    // ...创建Fragment类型的子节点,忽略
} else {
    const created = createFiberFromElement(element, returnFiber.mode, lanes); // 根据当前子节点的ReactElement来创建新的Fiber节点
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
}

创建全新子节点 —— createFiberFromElement

创建全新子节点所调用的方法是 createFiberFromElement

export function createFiberFromElement(
  element: ReactElement,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  let owner = null;
  const type = element.type;
  const key = element.key;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(
    type,
    key,
    pendingProps,
    owner,
    mode,
    lanes,
  );
  return fiber;
}

可以看出,createFiberFromElement 方法主要就是执行了 createFiberFromTypeAndProps 这个方法,而该方法主要是解析确定下新节点的 tag、type 属性,并调用 createFiber 方法 new 了一个新节点对象。

创建多个 Fiber 子节点 —— reconcileChildrenArray

当一个节点有多个子节点(如:<div><span>2</span><strong>3</strong></div>),那么此时 newChild 就是一个数组,此时便会进入到 reconcileChildrenArray 的方法中

回顾下在 reconcileChildFibers 方法中是如何调用该方法的:

if (isArray(newChild)) {
    return reconcileChildrenArray(
        returnFiber, // 当前的Fiber节点
        currentFirstChild, // current树中对应的子Fiber节点
        newChild, // 本次更新的子ReactElement
        lanes, // 优先级相关
    );
}

与 reconcileSingleElement 方法类似,reconcileChildrenArray 实际上也是尝试复用 current 树上的对应子节点,如遇到无法复用的子节点,则创建新节点;但不同点在于, reconcileChildrenArray 需要处理的子节点实际上是一个数组,因此需要进行新数组(本次更新中创建的 ReactElement )与原数组(current 树上对应的子 Fiber 节点)间的对比,其大概思路如下:

  1. 根据 index 遍历新老数组元素,一一对比新老数组,对比的依据是 key 属性是否相同;
  2. 若 key 属性相同,则复用节点并继续进行遍历,直到遇到不能复用的情况(或老数组中的所有节点都已经被复用)则结束遍历。
  3. 如果老数组所有节点都已经被复用,但新数组尚有未处理的部分,则依据新数组该未处理部分来创建新的 Fiber 节点。
  4. 如果老数组有节点尚未被遍历(即在第一次遍历中碰到不能复用的情况而中途退出),那么将这部分放进一个 map 里,然后继续遍历新数组,看看有没有能从 map 里找到能复用的;若能复用的,则进行复用,否则创建新 Fiber 节点;对于未被复用的旧节点,则全部标记删除(deleteChild)。

需要注意的是,虽然 reconcileChildrenArray 把整个数组(newChild)的 Fiber 节点都创建出来了,但其最终 return 的实际上是数组中的第一个 Fiber 节点,换句话说:在下次 performUnitOfWork 中的循环主体(unitOfWork)实际上是这个数组中的第一个 Fiber 节点;而当这“第一个 Fiber 节点”执行到 completeWork 阶段时,会取出它的 sibling —— 也就是这个数组中的第二个 Fiber 节点来作为下次 performUnitOfWork 中的循环主体(unitOfWork)。

优化的工作路径 —— bailout

上文花了非常多的篇幅来一路深入介绍 beginWork 的主要工作路径,下面我们还是回到 beginWork 处:

if (current !== null) {
    const oldProps = current.memoizedProps
    const newProps = workInProgress.pendingProps

    if (
        oldProps !== newProps ||
        hasLegacyContextChanged() // 判断context是否有变化
    ) {
        /* 该didReceiveUpdate变量代表本次更新中本Fiber节点是否有变化 */
        didReceiveUpdate = true
    } else if (!includesSomeLane(renderLanes, updateLanes)) {
        didReceiveUpdate = false
        switch (
            workInProgress.tag
        ) {
            // 省略
        }
        return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
    } else {
        didReceiveUpdate = false
    }
} else {
    didReceiveUpdate = false
}

当前代码段的作用是判断当前的 Fiber 节点有无收到外部参数更新,其判断的依据是:

  • props(对比current.memoizedPropsworkInProgress.pendingProps);
  • 老版本(React v16 以前)的 context;

判断后的结果存放在didReceiveUpdate这个变量中,在同一文件中的其它代码段(比如updateFunctionComponent)会根据此变量来做性能优化。

除此以外,该代码段还包含一个非常重要的性能优化手段 —— bailout

bailout 的意义

一个页面往往包含了一棵庞大的 Fiber 树,如果仅仅因为某个不起眼的按钮触发的更新就引起整棵 Fiber 树的重新 render (即遍历整棵 Fiber 树),那么从性能上来说就是难以接受的;因此, React 引入了树遍历算法中的常用优化手段 —— “剪枝”,在 React 中又被称作 bailout

通过 bailout ,某些与本次更新毫无关系的 Fiber 树路径将被直接省略掉;当然,“省略”并不是直接将这部分 Fiber 节点丢弃,而是直接复用被“省略”的 Fiber 子树的根节点;这种“复用”方式,是会保留被“省略”的 Fiber 子树的所有 Fiber 节点的。

bailout 的条件

众所周知,“剪枝”这样的性能优化手段并不是盲目进行的,必须在保证结果正确的前提下进行,因此 bailout 需要满足以下条件:

  • 无外部参数更新(props 和老版本的 context)
  • 从 lanes 上看,本次 render 的任务中是否包含当前 Fiber 节点的更新任务(在 concurrent 模式下,不同的更新任务有不同的优先级,因此存在当前 Fiber 节点有更新任务,但未被纳入本次 render 中的情况;当然,更常见的是当前 Fiber 节点根本没有更新任务):!includesSomeLane(renderLanes, updateLanes)
  • 当前 Fiber 节点的后代节点中,没有需要在本次 render 过程中处理的更新任务(下文会介绍判断的具体代码段)。

“更新任务”是什么?

这里的“更新任务”,可以简单理解成是组件 state 的变化,通常是由setState触发的。

bailoutOnAlreadyFinishedWork

接下来我们来看 bailout 的入口方法 —— bailoutOnAlreadyFinishedWork 方法:

function bailoutOnAlreadyFinishedWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes): Fiber | null {
  if (current !== null) {
    // Reuse previous dependencies
    workInProgress.dependencies = current.dependencies; // Fiber节点依赖的context和events
  }

  // 省略

  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
    // 判断子节点中是否需要检查更新
    return null; // 剪枝:不需要关注子节点(ReactElement)了
  } else {
    cloneChildFibers(current, workInProgress);
    return workInProgress.child;
  }
}

在此方法中,又出现了一个新的判断 —— !includesSomeLane(renderLanes, workInProgress.childLanes),这是在判断当前 Fiber 节点的后代节点中,有没有需要在本次 render 过程中处理的更新任务;如果没有的话,那么以当前 Fiber 节点为根节点的 Fiber 子树,就满足“剪枝”的要求了。

当满足“剪枝”要求时,直接return null,那么当前节点下的所有后代节点,都不会进入到 render 阶段(直接复用上次 render 的结果),后续会进入到当前 Fiber 节点的 completeWork 阶段。

若后代节点中仍有本次 render 过程需要处理的更新任务,则克隆 current 树上对应的子 Fiber 节点并返回,作为下次 performUnitOfWork 的主体。

当前 Fiber 节点如何得知后代节点是否有更新任务?

也许你会好奇,当前 Fiber 节点是如何得知后代节点是否有更新任务的,这里仅作简单介绍,后续还会写文详细解说:

  1. 某个组件触发了 setState 方法,这时会在该组件对应的 Fiber 节点上创建一个 Update 对象,挂载在该 Fiber 节点的 updateQueue 属性上,形成一条 Update 对象链表。
  2. 除了创建 Update 外,还会执行 markUpdateLaneFromFiberToRoot 这个方法来处理汇总更新任务信息到 lanes (即赛道模型)中:
function markUpdateLaneFromFiberToRoot(sourceFiber: Fiber, lane: Lane): FiberRoot | null {
  // Update the source fiber's lanes
  sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
  let alternate = sourceFiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
  // Walk the parent path to the root and update the child expiration time.
  let node = sourceFiber;
  let parent = sourceFiber.return;
  while (parent !== null) {
    parent.childLanes = mergeLanes(parent.childLanes, lane);
    alternate = parent.alternate;
    if (alternate !== null) {
      alternate.childLanes = mergeLanes(alternate.childLanes, lane);
    } else {
      // 省略
    }
    node = parent;
    parent = parent.return;
  }
  // 省略
}

这个 markUpdateLaneFromFiberToRoot 方法除了会把本次更新任务的信息汇总到当前 Fiber 节点的lanes属性中,还会一路往祖先 Fiber 节点方向遍历,并且将当前 Fiber 节点的更新任务信息汇总到各个祖先 Fiber 节点的childLanes属性上,直到根节点 rootFiber 为止。

复用 current 树上对应的子 Fiber 节点 —— cloneChildFibers

这里的“复用 current 树上对应的子 Fiber 节点”可能会造成一些迷惑,我们直接看 cloneChildFibers 代码:

export function cloneChildFibers(
  current: Fiber | null,
  workInProgress: Fiber,
): void {
  // 省略
  /* 判断子节点为空,则直接返回 */
  if (workInProgress.child === null) {
    return;
  }

  let currentChild = workInProgress.child; // 这里怎么会是拿workInProgress.child来充当currentChild呢?解释看下文
  let newChild = createWorkInProgress(currentChild, currentChild.pendingProps); // 复用currentChild
  workInProgress.child = newChild;

  newChild.return = workInProgress; // 让子Fiber节点与当前Fiber节点建立联系
  /* 遍历Fiber子节点的所有兄弟节点并进行节点复用 */
  while (currentChild.sibling !== null) {
    currentChild = currentChild.sibling;
    newChild = newChild.sibling = createWorkInProgress(
      currentChild,
      currentChild.pendingProps,
    );
    newChild.return = workInProgress;
  }
  newChild.sibling = null;
}

有个问题,这里我们看到明明是拿 workInProgress.child 去创建子节点的,怎么会说成是克隆 current 树上对应的子 Fiber 节点呢?而且按理说此时还没创建子 Fiber 节点, workInProgress.child 怎么会有值呢?

其实是这样的,当前节点是在父节点的 beginWork 阶段通过 createWorkInProgress 方法创建出来的,会执行 workInProgress.child = current.child,因此在本节点创建自己的子节点并覆盖 workInProgress.child 之前,workInProgress.child 其实指向的就是 current.child

下面以图例说明:

1360252090-614bf5d9ed2f1_fix732.png

总结 bailout

bailout (1).jpg

下面我们来举一个例子:

function App() {
    const [a] = useState(1);
    const [b] = useState(2);
    return (
        <Container a={a}>
            <Content b={b} />
        </Container>
    );
}

function Container(props) {
    const [count, setCount] = useState(0);
    return (
        <>
            <div>{props.children}</div>
            <button onClick={() => setCount(count => count + 1)}>{count}</button>
        </>
    );
}

function Content() {
    return <Text />;
}

function Text() {
    return <p>这是一个计数器:</p>;
}

当首屏渲染完成后,我们点击<button />按钮,会发生什么事情呢?

  1. <App />节点的外部参数和 state 都没有变化,但后代节点(<Container />)有 state 变化(更新任务),因此无法完成 bailout ,但会复用 Fiber 子节点(cloneChildFibers(current, workInProgress)),即<Container /><Content />
  2. <Container />节点的外部参数没有变化,但 state 有变化(即有更新任务),因此无法 bailout 。
  3. <Content />节点的外部参数、state 都没有变化,后代节点也没有 state 变化,因此可以 bailout 。
  4. 由于<Text />的父节点 —— <Content />节点已 bailout ,因此本次 render 阶段并不会处理<Text />节点,该节点已经在复用<Content />节点时被一并复用了。

EffectTag

上文说到,在 update 的场景下,除了与 mount 时一样创建子 Fiber 节点外,还会与上次渲染的子节点进行 diff ,从而得出需要进行什么样的 DOM 操作,并将其“标记”在新建的子 Fiber 节点上,下面就来介绍一下这个“标记” —— EffectTag

EffectTag 是 Fiber Reconciler 相对于 Stack Reconciler 的一大革新,以往 Stack Reconciler 是每 diff 出一个节点就进行 commit 的(当然,由于 Stack Reconciler 是同步执行的,因此直到所有节点都 commit 完了才会轮到浏览器 GUI 线程进行渲染,这样就不会造成“仅部分更新”的问题),而 Fiber Recconciler 则在 diff 出来后,仅在目标节点打上 effectTag ,而不会走到 commit 阶段,待所有节点都完成 render 阶段后才统一进 commit 阶段,这样便实现了 reconciler(render 阶段)renderer(commit 阶段) 的解耦。

EffectTag 类型的定义

effectTag 实际上就是需要对节点需要执行的 DOM 操作(也可认为是副作用,即 sideEffect ),定义有以下这些类型(仅节选部分 EffectTag 类型):

// DOM需要插入到页面中
export const Placement = /*                */ 0b00000000000010;
// DOM需要更新
export const Update = /*                   */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /*       */ 0b00000000000110;
// DOM需要删除
export const Deletion = /*                 */ 0b00000000001000;

为什么需要使用二进制来表示 effectTag 呢?

这是因为同一个 Fiber 节点,可能需要执行多种类型的 DOM 操作,即需要打上多种类型的 effectTag,那么这时候只要将这些 effectTag 做“按位或”(|)运算,那么就可以汇总成当前 Fiber 节点拥有的所有 effectTag 类型了。

若要判断某个 Fiber 节点是否有某种类型的 effectTag ,其实也很简单,拿 fiber.effectTag 跟这个类型的 effectTag 所对应的二进制值来做“按位与”(&)运算,再根据运算结果是否为 NoEffect(0) 即可。

renderer 根据 EffectTag 来执行 DOM 操作

以 renderer “判断当前节点是否需要进行插入 DOM 操作”为例:

  • fiber.stateNode 存在,即Fiber节点中保存了对应的 DOM 节点
  • (fiber.effectTag & Placement) !== 0,即Fiber节点存在 Placement effectTag。

以上对于 update 操作都很好理解,但 mount 时在 reconcileChildren 中调用的 mountChildFibers 的要怎么办呢?

mount 时的 fiber.stateNode 为 null ,那不就不会执行插入 DOM 操作?

fiber.stateNode 会在节点的“归”阶段,即 completeWork 中进行创建。

mount 时每个节点上都会有 Placement EffectTag ?

假设 mountChildFibers 也会赋值 effectTag ,那么可以预见 mount 时整棵 Fiber 树所有节点都会有 Placement effectTag 。那么 commit 阶段在执行 DOM 操作时每个节点都会执行一次插入操作,这样大量的DOM操作是极低效的。

为了解决这个问题,在 mount 时只有 FiberRootNode 会赋值 Placement effectTag ,在 commit 阶段只会执行一次插入操作。

实操

我们回到 reconcileChildren 方法在下图所示位置打上断点,接着刷新页面,看看首屏渲染时会不会走到 reconcileChildFibers 这个位置:

3486362902-614d7b81502be_fix732.png

接着,我们就能够看到如下的断点结果:当前的 workInProgress 入参实际上就是 FiberRootNode ,也就是<App />组件挂载的 DOM 元素(ReactDOM.render(<App />, document.getElementById('root')));而当前的 current 入参是不为空的,因此才会走到这个一般只有 update 才会执行的代码段来;而当我们恢复代码执行后,首屏便已经渲染了,并没有再次停在断点位置,因此,在 mount(首屏渲染) 时,只有 FiberRootNode 会“跟踪副作用”(shouldTrackSideEffects === true),即打上 EffectTag 。

855695113-614d7aa14af4a_fix732.png