本章我们会讲解 Fiber 节点是如何被创建并构建 Fiber 树的。

render 阶段

render 阶段 开始于 performSyncWorkOnRootperformConcurrentWorkOnRoot 方法的调用。这取决于本次更新是同步更新还是异步更新

我们现在还不需要学习这两个方法,只需要知道在这两个方法中会调用如下两个方法:

// performSyncWorkOnRoot 会调用该方法

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// performConcurrentWorkOnRoot 会调用该方法
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

可以看到,他们唯一的区别是是否调用 shouldYield 。如果当前浏览器帧没有剩余时间,shouldYield中止循环,直到浏览器有空闲时间后再继续遍历。

workInProgress 代表当前已创建的 workInProgress fiber。

performUnitOfWork 方法 会创建下一个 Fiber 节点,并赋值给 workInProgress,同时把 workInProgress 与已创建的 Fiber 节点连接起来构成 Fiber 树。

Fiber Reconciler 是从 Stack Reconciler 重构 而来,通过遍历的方式实现可中断的递归,所以 performUnitOfWork 的工作可以分为两部分:

“递”阶段

首先从 rootFiber 开始向下深度优先遍历,为遍历到的每个 Fiber 节点调用 beginWork() 方法。

beginWork() 会根据传入的 Fiber 节点创建子 Fiber 节点,并将这两个 Fiber 节点连接起来。

当遍历到叶子节点 (即没有子组件的组件) 时就会进入“归”阶段。

“归”阶段

在“归”阶段会调用 completeWork() 处理 Fiber 节点。

当某个 Fiber 节点执行完 completeWork(),如果其存在兄弟 Fiber 节点 (即 fiber.sibling !== null) ,会进入其兄弟 Fiber 的“递”阶段。

如果不存在兄弟 Fiber,会进入父级 Fiber 的“归”阶段。

阶段会交错执行直到到 rootFiber。至此,render 阶段的工作就结束了。

例子

参照第一篇的例子:

function App() {
  return (
    <div>
      i am
      <span>Lu Min</span>
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById("root"));

对应的 fiber 树结构:

fiber-tree.svg

render 阶段 会依次执行:

1. rootFiber beginWork
2. App Fiber beginWork
3. div Fiber beginWork
4. "i am" Fiber beginWork
5. "i am" Fiber completeWork
6. span Fiber beginWork
7. span Fiber completeWork
8. div Fiber completeWork
9. App Fiber completeWork
10. rootFiber completeWork

之所以没有 “Lu Min” Fiber 的 beginWork/completeWork,是因为作为一种性能优化手段,针对只有单一文本子节点的 Fiber,React 会特殊处理。

如果将 performUnitOfWork 转化为递归版本,大体代码如下:

function performUnitOfWork(fiber) {
  // 执行beginWork
  if (fiber.child) {
    performUnitOfWork(fiber.child);
  }

  // 执行completeWork
  if (fiber.sibling) {
    performUnitOfWork(fiber.sibling);
  }
}

我们了解到 render 阶段 的工作可以分为阶段和阶段。其中阶段会执行 beginWork()阶段会执行 completeWork阶段的 beginWork() 究竟做了什么呢?

beginWork

可以从源码这里看到 beginWork 的定义,整个方法大概有 500 行代码。

上面我们已经知道,beginWork 的工作是传入当前 Fiber 节点,创建子 Fiber 节点,我们从传参来看看具体是如何做的。

从传参看方法执行

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

其中传参:

  • current :当前组件对应的 Fiber 节点在上一次更新时的 Fiber 节点,即 workInProgress.alternate;
  • workInProgress :当前组件对应的 Fiber 节点;
  • renderLanes :优先级相关,在讲解 Scheduler 时再讲解。

组件 mount 时,由于是首次渲染,是不存在当前组件对应的 Fiber节点在上一次更新时的 Fiber 节点,即 mount 时current === null。

组件 update 时,由于之前已经 mount 过,所以 current !== null。

所以我们可以通过 current === null ? 来区分组件是处于 mount 还是 update。

基于此原因,beginWork 的工作可以分为两部分:

  • update 时 :如果 current 存在,在满足一定条件时可以复用 current 节点,这样就能克隆 current.child 作为 workInProgress.child,而不需要新建 workInProgress.child。

  • mount 时 :除 fiberRootNode 以外,current === null。会根据 fiber.tag 不同,创建不同类型的子 Fiber 节点。

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {

  // update时:如果current存在可能存在优化路径,可以复用current (即上一次更新的Fiber节点) 
  if (current !== null) {
    // ...省略

    // 复用 current
    return bailoutOnAlreadyFinishedWork(
      current,
      workInProgress,
      renderLanes,
    );
  } else {
    didReceiveUpdate = false;
  }

  // mount 时:根据 tag 不同,创建不同的子 Fiber 节点
  switch (workInProgress.tag) {
    case IndeterminateComponent: 
      // ...省略
    case LazyComponent: 
      // ...省略
    case FunctionComponent: 
      // ...省略
    case ClassComponent: 
      // ...省略
    case HostRoot:
      // ...省略
    case HostComponent:
      // ...省略
    case HostText:
      // ...省略
    // ...省略其他类型
  }
}

update 时

我们可以看到,didReceiveUpdate === false(即可以直接复用前一次更新的子 Fiber,不需要新建子 Fiber) ,需满足如下情况:

  1. oldProps === newProps && workInProgress.type === current.type,即 props 与 fiber.type 不变;
  2. !includesSomeLane(renderLanes, updateLanes),即当前 Fiber 节点优先级不够,会在讲解 Scheduler 时介绍。
if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (
      oldProps !== newProps ||
      hasLegacyContextChanged() ||
      (__DEV__ ? workInProgress.type !== current.type : false)
    ) {
      didReceiveUpdate = true;
    } else if (!includesSomeLane(renderLanes, updateLanes)) {
      didReceiveUpdate = false;
      switch (workInProgress.tag) {
        // 省略处理
      }
      return bailoutOnAlreadyFinishedWork(
        current,
        workInProgress,
        renderLanes,
      );
    } else {
      didReceiveUpdate = false;
    }
  } else {
    didReceiveUpdate = false;
  }

mount 时

当不满足优化路径时,我们就进入第二部分,新建子 Fiber。

我们可以看到,根据 fiber.tag 不同,进入不同类型 Fiber 的创建逻辑,在源码ReactWorkTags.js中可以看到类型的常量定义。

// mount 时:根据 tag 不同,创建不同的 Fiber 节点
switch (workInProgress.tag) {
  case IndeterminateComponent: 
    // ...省略
  case LazyComponent: 
    // ...省略
  case FunctionComponent: 
    // ...省略
  case ClassComponent: 
    // ...省略
  case HostRoot:
    // ...省略
  case HostComponent:
    // ...省略
  case HostText:
    // ...省略
  // ...省略其他类型
}

对于我们常见的组件类型,如 (FunctionComponent/ClassComponent/HostComponent) ,最终会进入 reconcileChildren 方法。

reconcileChildren

从该函数名就能看出这是 Reconciler 模块的核心部分。那么他究竟做了什么呢?

  • 对于 mount 的组件,他会创建新的子 Fiber 节点;

  • 对于 update 的组件,他会将当前组件与该组件在上次更新时对应的 Fiber 节点比较 (也就是俗称的 Diff 算法 ) ,将比较的结果生成新 Fiber 节点。

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,
    );
  }
}

和 beginWork 一样,他也是通过 current === null ? 区分 mount 与 update。

不论走哪个逻辑,最终他会生成新的子 Fiber 节点并赋值workInProgress.child,作为本次 beginWork 返回值,并作为下次 performUnitOfWork 执行时 workInProgress 的传参。

mountChildFibersreconcileChildFibers 这两个方法的逻辑基本一致。唯一的区别是:reconcileChildFibers 会为生成的 Fiber 节点带上 effectTag 属性,而 mountChildFibers 不会。

effectTag

render 阶段 的工作是在内存中进行,当工作结束后会通知 Renderer 需要执行的 DOM 操作。要执行 DOM 操作的具体类型就保存在 fiber.effectTag 中。

在源码ReactSideEffectTags.js定义操作类型。

例如:

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

通过二进制表示 effectTag,可以方便的使用位操作为 fiber.effectTag 赋值多个 effect。

那么,如果要通知 Renderer 将 Fiber 节点对应的 DOM 节点插入页面中,需要满足两个条件:

  • fiber.stateNode 存在,即 Fiber 节点中保存了对应的 DOM 节点;

  • (fiber.effectTag & Placement) !== 0,即 Fiber 节点存在 Placement effectTag。

我们知道,mount 时,fiber.stateNode === null,且在 reconcileChildren 中调用的 mountChildFibers 不会为 Fiber 节点赋值 effectTag。那么首屏渲染如何完成呢?

针对第一个问题,fiber.stateNode 会在 completeWork 中创建,我们会在下一节介绍。

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

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

借用上一节的 Demo,第一个进入 beginWork 方法的 Fiber 节点就是 rootFiber,他的alternate 指向 current rootFiber (即他存在current) 。

为什么 rootFiber 节点存在 current (即 rootFiber.alternate) ?

由于存在 current,rootFiber 在 reconcileChildren 时会走 reconcileChildFibers 逻辑。

而之后通过 beginWork 创建的 Fiber 节点是不存在 current 的 (即 fiber.alternate === null) ,会走 mountChildFibers 逻辑。

completeWork

在我们了解组件在 render 阶段 会经历 beginWorkcompleteWork

上一节我们讲解了组件执行 beginWork 后会创建子 Fiber 节点,节点上可能存在 effectTag

这一节让我们看看 completeWork 会做什么工作。

你可以从这里看到 completeWork 方法定义。

流程概览

类似 beginWork,completeWork 也是针对不同 fiber.tag 调用不同的处理逻辑。

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case IndeterminateComponent:
    case LazyComponent:
    case SimpleMemoComponent:
    case FunctionComponent:
    case ForwardRef:
    case Fragment:
    case Mode:
    case Profiler:
    case ContextConsumer:
    case MemoComponent:
      return null;
    case ClassComponent: {
      // ...省略
      return null;
    }
    case HostRoot: {
      // ...省略
      updateHostContainer(workInProgress);
      return null;
    }
    case HostComponent: {
      // ...省略
      return null;
    }
  // ...省略

我们重点关注页面渲染所必须的 HostComponen t (即原生 DOM 组件对应的 Fiber 节点) ,其他类型 Fiber 的处理留在具体功能实现时讲解。

同时针对 HostComponent,判断 update 时我们还需要考虑 workInProgress.stateNode != null ? (即该 Fiber 节点是否存在对应的 DOM 节点) 。

case HostComponent: {
  popHostContext(workInProgress);
  const rootContainerInstance = getRootHostContainer();
  const type = workInProgress.type;

  if (current !== null && workInProgress.stateNode != null) {
    // update的情况
    // ...省略
  } else {
    // mount的情况
    // ...省略
  }
  return null;
}

update 时

当 update 时,Fiber 节点已经存在对应 DOM 节点,所以不需要生成 DOM 节点。需要做的主要是处理 props,比如:

  • onClick、onChange 等回调函数的注册;
  • 处理style prop;
  • 处理DANGEROUSLY_SET_INNER_HTML prop;
  • 处理children prop。

我们去掉一些当前不需要关注的功能 (比如 ref) 。可以看到最主要的逻辑是调用 updateHostComponent 方法。

if (current !== null && workInProgress.stateNode != null) {
  // update 的情况
  updateHostComponent(
    current,
    workInProgress,
    type,
    newProps,
    rootContainerInstance,
  );
}

你可以从这里看到 updateHostComponent 方法 定义。

在 updateHostComponent 内部,被处理完的 props 会被赋值给 workInProgress.updateQueue,并最终会在 commit 阶段被渲染在页面上。

workInProgress.updateQueue = (updatePayload: any);

其中 updatePayload 为数组形式,他的奇数索引的值为变化的 prop key,偶数索引的值为变化的 prop value。

mount 时

同样,我们省略了不相关的逻辑。可以看到,mount 时的主要逻辑包括三个:

  • 为 Fiber 节点生成对应的 DOM 节点;
  • 将子孙 DOM 节点插入刚生成的 DOM 节点中;
  • 与 update 逻辑中的 updateHostComponent 类似的处理 props 的过程。
// mount的情况

// ...省略服务端渲染相关逻辑

const currentHostContext = getHostContext();
// 为fiber创建对应DOM节点
const instance = createInstance(
    type,
    newProps,
    rootContainerInstance,
    currentHostContext,
    workInProgress,
  );
// 将子孙DOM节点插入刚生成的DOM节点中
appendAllChildren(instance, workInProgress, false, false);
// DOM节点赋值给fiber.stateNode
workInProgress.stateNode = instance;

// 与update逻辑中的updateHostComponent类似的处理props的过程
if (
  finalizeInitialChildren(
    instance,
    type,
    newProps,
    rootContainerInstance,
    currentHostContext,
  )
) {
  markUpdate(workInProgress);
}

还记得我们讲到:mount 时只会在 rootFiber 存在 Placement effectTag。那么 commit 阶段 是如何通过一次插入 DOM 操作 (对应一个Placement effectTag) 将整棵 DOM 树插入页面的呢?

原因就在于 completeWork 中的 appendAllChildren 方法

由于 completeWork 属于“归”阶段调用的函数,每次调用 appendAllChildren 时都会将已生成的子孙 DOM 节点插入当前生成的 DOM 节点下。那么当“归”到 rootFiber 时,我们已经有一个构建好的离屏 DOM 树。

effectList

至此 render 阶段 的绝大部分工作就完成了。

还有一个问题:作为 DOM 操作的依据,commit 阶段 需要找到所有有 effectTag 的 Fiber 节点并依次执行 effectTag 对应操作。难道需要在 commit 阶段 再遍历一次 Fiber 树寻找 effectTag !== null的Fiber 节点么?

这显然是很低效的。

为了解决这个问题,在 completeWork 的上层函数 completeUnitOfWork 中,每个执行完 completeWork 且存在 effectTag 的 Fiber 节点会被保存在一条被称为 effectList单向链表中。

effectList 中第一个 Fiber 节点保存在 fiber.firstEffect ,最后一个元素保存在 fiber.lastEffect。

类似 appendAllChildren,在“归”阶段,所有有 effectTag 的 Fiber 节点都会被追加在 effectList 中,最终形成一条以rootFiber.firstEffect 为起点的单向链表

                       nextEffect         nextEffect
rootFiber.firstEffect -----------> fiber -----------> fiber

这样,在 commit 阶段 只需要遍历 effectList 就能执行所有 effect 了。

你可以在这里看到这段代码逻辑。

借用 React 团队成员 Dan Abramov 的话:effectList 相较于 Fiber 树,就像圣诞树上挂的那一串彩灯

流程结尾

至此,render 阶段 全部工作完成。在 performSyncWorkOnRoot 函数中 fiberRootNode 被传递给 commitRoot 方法,开启 commit 阶段 工作流程。

commitRoot(root);