Bootstrap

【React源码 - Fiber架构之Reconciler】

前言

React16架构可以分为三层也是最核心的三个功能分别是:

  • Scheduler(调度器)—调度任务的优先级,高优任务优先进入Reconciler(16新增)
  • Reconciler(协调器)—负责找出变化的组件
  • Renderer(渲染器)— 负责将变化的组件渲染到页面上

本文主要是分析一下Reconciler协调器的理念以及流程。简单就是一句话:Reconciler协调器中主要功能就是使用循环实现可中断递归,并进行Fiber节点的对比,然后打上标记,然后通知Renderer更新

背景

在React中可以通过this.setState、useState、this.forceUpdate、ReactDOM.render等API触发更新,在Reconciler中,mount的组件会调用mountComponent ,update的组件会调用updateComponent 。这两个方法都会递归更新子组件。15版本的时候,是使用递归的方式来遍历Diff对比虚拟DOM的差异然后通知Renderer来进行渲染的,主要是如下功能:

  1. 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
  2. 将虚拟DOM和上次更新时的虚拟DOM对比,找出本次更新中变化的虚拟DOM
  3. 通知Renderer将变化的虚拟DOM渲染到页面上

由于递归一旦开始就不可中断,如果diff的层级较多的时候,就会出现递归时间超过16ms,导致页面卡顿。以及在ReReconciler和Renderer是交替工作的。如下图:当第一个li在页面上已经变化后,第二个li再进入Reconciler。
在这里插入图片描述由于整个过程都是同步的,所以在用户看来所有DOM是同时更新的
为了解决这些问题,React决定重写这块架构(Fiber架构),用异步可中断的更新来代替同步更新。使用循环的方式来代替递归实现可中断,从代码可以看出,每次循环都会调用shouldYield判断当前是否有剩余时间。

/** @noinline */
function  workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

而且Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记(使用二进制),类似这样:

export const Placement = /* 插入      */ 0b0000000000010;                 
export const Update = /*    更新      */ 0b0000000000100;
export const PlacementAndUpdate = /*插入并更新*/ 0b0000000000110;
export const Deletion = /* 删除       */ 0b0000000001000;

整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。所以即使Scheduler和Reconciler是异步可中断的,但是用户也不会看到未完全更新数据,因为当处理完了之后,统一通知Renderer更新,而Renderer是同步不可中断更新的。
在这里插入图片描述

接下来解释一下待会儿会提到的一些术语:

  • Reconciler工作的阶段被称为render阶段。因为在该阶段会调用组件的render方法。
  • Renderer工作的阶段被称为commit阶段。就像你完成一个需求的编码后执行git commit提交代码。commit阶段会把render阶段提交的信息渲染在页面上。
  • render与commit阶段统称为work,即React在工作中。相对应的,如果任务正在Scheduler内调度,就不属于work。

双缓存树

都知道React在Fiber架构中使用了双缓存,所以开始之前我们先要了解一下什么是双缓存树。

当我们用canvas绘制动画,每一帧绘制前都会调用ctx.clearRect清除上一帧的画面。如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。这种在内存中构建并直接替换的技术叫做双缓存 。

简单来说,双缓存树就是使用两个树来避免更新时的闪烁问题的解决策略,一棵Current Tree(视图中的),一个WorkInProgress Tree(内存中的),这两颗树是可以通过修改alternate指向来互相转化的,当需要更新时,直接用缓存树替换视图树。React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。

双缓存Fiber树

在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。current Fiber树中的Fiber节点被称为current fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React应用的根节点通过使current指针在不同Fiber树的rootFiber间切换来完成current Fiber树指向的切换。即当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。

接下来我们以具体例子讲解mount时、update时的构建/替换流程。

Mount时

以下面代码为例:

function  App() {
  const [num, add] = useState(0);
  return (
    <p onClick={() => add(num + 1)}>{num}</p>
  )
}

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

mount阶段

首次执行ReactDOM.render会创建fiberRootNode(源码中叫fiberRoot)和rootFiber。其中fiberRootNode是整个应用的根节点,rootFiber是所在组件树的根节点。(之所以要区分fiberRootNode与rootFiber,是因为在应用中我们可以多次调用ReactDOM.render渲染不同的组件树,他们会拥有不同的rootFiber。但是整个应用的根节点只有一个,那就是fiberRootNode。)

ReactDOM.render(<A/>, dom) // rootFiberA
ReactDOM.render(<B/>, dom) // rootFiberB
ReactDOM.render(<C/>, dom) // rootFiberC

fiberRootNode的current会指向当前页面上已渲染内容对应Fiber树,即current Fiber树。
在这里插入图片描述

fiberRootNode.current = rootFiber;

由于是首屏渲染,页面中还没有挂载任何DOM,所以fiberRootNode.current指向的rootFiber没有任何子Fiber节点(即current Fiber树为空)。

render阶段

接下来进入render阶段,根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树。(下图中右侧为内存中构建的树,左侧为页面显示的树)。在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber(即rootFiber.alternate)。
在这里插入图片描述
commit阶段

render阶段结束之后,以及有两颗树了,一个Current Fiber Tree在视图显示,一直WorkInProgress Fiber Tree在内存中构建然后,然后在commit阶段修改fiberRootNode的current指针指向workInProgress Fiber树使其变为current Fiber 树,试图更新。
在这里插入图片描述
update阶段

当节点发生更新,就会进入update阶段,再一次进入render阶段,和上面一样根据JSX生成FIber节点,进而构建FIber树(可复用Current FIber Tree的节点)
在这里插入图片描述
workInProgress Fiber 树在render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树。
在这里插入图片描述

Reconciler协调器

Reconciler是独立React之外的包,支持其他包引用,包名:react-reconciler,主要功能如下:

  • 输入: 暴露api函数(如: scheduleUpdateOnFiber), 供给其他包(如react包)调用.
  • 注册调度任务: 与调度中心(scheduler包)交互, 注册调度任务task, 等待任务回调.
  • 执行任务回调: 在内存中构造出fiber树, 同时与与渲染器(react-dom)交互, 在- 内存中创建出与fiber对应的DOM节点.
  • 输出: 与Renderer渲染器(react-dom)交互, 渲染DOM节点.

以上功能源码都集中在ReactFiberWorkLoop.js中. 现在将这些功能(从输入到输出)串联起来, 用下图表示:
在这里插入图片描述

  • workInProgress: 代表当前已创建的workInProgress fiber。
  • performUnitOfWork: 创建下一个Fiber节点并赋值给workInProgress,并将workInProgress与已创建的Fiber节点连接起来构成Fiber树。

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

“递”阶段

首先从rootFiber开始向下深度优先遍历(DFS)。为遍历到的每个Fiber节点调用beginWork方法 。该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。当遍历到叶子节点(即没有子组件的组件)时就会进入下面的“归”阶段。

“归”阶段

在“归”阶段会调用completeWork 处理Fiber节点。当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。“递”和“归”阶段会交错执行直到“归”到rootFiber。至此,render阶段的工作就结束了。然后进入commit阶段(Renderer渲染器)

以为下面代码为例

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

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

其对应的FIber树为:
在这里插入图片描述
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

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

这里可以看出,整个递归过程中,主要就是调用beginWork、completeWork函数,下面主要分析这两个函数的作用。

BeginWork

beginWork源码
beginWork函数主要是传入一个current fiber节点来生成子fiber节点的过程,从代码来看,接受三个参数:

  • current:当前组件对应的Fiber节点在上一次更新时的Fiber节点,即workInProgress.alternate
  • workInProgress:当前组件对应的Fiber节点
  • renderLanes:优先级相关,Scheduler调度器中设置
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...省略函数体
}

从双缓存机制一节我们知道,除rootFiber以外, 组件mount时,由于是首次渲染,是不存在当前组件对应的Fiber节点在上一次更新时的Fiber节点,即mount时current === null。所以我们可以根据current来判断当前是mount还是update。所以可以从这两个方面来分析:

  • 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)

  • oldProps === newProps && workInProgress.type === current.type,即props与fiber.type不变
  • !includesSomeLane(renderLanes, updateLanes),即当前Fiber节点优先级不够
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时
当不满足条件时就需要根据tag来新建子Fiber。Tag枚举

// 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
reconcileChildren函数主要做了一下事情:

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

可以看出,也是通过current来区分是mount还是update,但是不管那个阶段最后都会生成一个子fiber节点并赋值给workInProgress.child

值得一提的是,mountChildFibers与reconcileChildFibers这两个方法的逻辑基本一致。唯一的区别是:reconcileChildFibers会为生成的Fiber节点带上effectTag属性,而mountChildFibers不会。mount阶段只有rootfiber节点有effectTag属性,调用appendAllChildren时都会将已生成的子孙DOM节点插入当前生成的DOM节点下

effectTag
我们知道Reconciler主要就是对比构建Fiber树,并标记节点需要进行的操作,就是通过effectTag属性以二进制来保存需要做什么操作, 后面使用flags来替换了effect

function getFiberFlags(fiber: Fiber): number {
  // The name of this field changed from "effectTag" to "flags"
  return fiber.flags !== undefined ? fiber.flags : (fiber: any).effectTag;
}
// DOM需要插入到页面中
export const Placement = /*                */ 0b00000000000010;     
// DOM需要更新
export const Update = /*                   */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /*       */ 0b00000000000110;
// DOM需要删除
export const Deletion = /*                 */ 0b00000000001000;

beginWork流程图
在这里插入图片描述

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;
    }
  // ...省略

其中需要注意注页面渲染所必须的HostComponent(即原生DOM组件对应的Fiber节点)。它和beginWork一样,我们根据current === null ?判断是mount还是update。同时针对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;
}

mount时
mount时的主要逻辑包括三个:

  • 为Fiber节点生成对应的DOM节点
  • 将子孙DOM节点插入刚生成的DOM节点中
  • 与update逻辑中的updateHostComponent类似的处理props的过程
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);
}

update时
当节点进行update的时候,fiber节点已经存在对应的Dom节点了,所以主要处理的是props和一些回调

  • onClick、onChange等回调函数的注册
  • 处理style prop
  • 处理DANGEROUSLY_SET_INNER_HTML prop
  • 处理children prop
updateHostComponent = function(                
    current: Fiber,
    workInProgress: Fiber,
    type: Type,
    newProps: Props,
    rootContainerInstance: Container,
  ) {
    const oldProps = current.memoizedProps;
    if (oldProps === newProps) {
      return;
    }
    const instance: Instance = workInProgress.stateNode;
    const currentHostContext = getHostContext();
    const updatePayload = prepareUpdate(
      instance,
      type,
      oldProps,
      newProps,
      rootContainerInstance,
      currentHostContext,
    );
    workInProgress.updateQueue = (updatePayload: any);
    if (updatePayload) {
      markUpdate(workInProgress);
    }
  };

被处理完的props会保存在workInProgress.updateQueue(以key,value形式的数组,奇数下标为key,偶数下标为value),在commit阶段进行更新。

effectList
在completeWork的上层函数completeUnitOfWork中,每个执行完completeWork且存在effectTag的Fiber节点会被保存在一条被称为effectList的单向链表中,最后通过“归”阶段到rootfiber节点,形成以rootfiber为起点的单向链表。
在这里插入图片描述
completeWork流程图
在这里插入图片描述

至此,render阶段全部工作完成。在performSyncWorkOnRoot函数中fiberRootNode被传递给commitRoot方法,开启commit阶段工作流程。然后在commit阶段,会遍历整个effectList来更新对应的dom节点(fiber.stateNode保存的fiber对应的dom节点)

commitRoot(root);

参考文档

React技术揭秘
图解React

有兴趣的朋友可以关注一下公众号,主要分享前端相关的小知识,方便随时随地一起交流学习。
在这里插入图片描述

;