《React技术揭秘》学习笔记(二):架构篇

600 阅读9分钟

0. 前言

这篇文章是《React技术揭秘》学习笔记系列文章的第二篇文章。在这篇文章中,我们继续跟随这本书的脚步,学习React16的Fiber架构的工作流程。前文指路:

1. render阶段

render阶段开始于preformSyncWorkOnRootperformConcurrentQorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。相关代码如下:

// 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.然后将已创建的Fiber节点连接成Fiber树。performUnitOfWork是Fiber架构的主要方法,它的工作可以分为两个部分:“递”和“归”。

1.1 “递”的过程

“递”的阶段会执行beginWork.首先,我们来根据原文中的一张流程图来看一看beginWork是如何工作的。

image.png

如图所示,beginWork这个方法会传入两个关键的参数:currentworkInProgress,分别对应当前组件对应的Fiber节点在上一次更新时的Fiber节点 (即workInProgress.alternate) 和当前组件对应的Fiber节点。除此之外,beginWork,还有一个参数,叫renderLanes,该参数与优先级相关,和本节内容无关,因此我们选择性的将它忽略掉。

顺着流程图往下看,可以看到beginWork的第一步是根据current是否为空来判断当前是mount阶段还是update阶段。我们知道,如果是首屏渲染,则执行mount阶段,否则执行update阶段。如果当前是第一次创建Fiber节点,则一定不会有上一次更新,对应的current也一定会为null。因此,我们可以通过判断current === null来选择mount阶段和update阶段。

1.1.1 mount阶段

mount阶段,根据workInProgress.tag判断需要创建的Fiber节点类型,并且创建相应的Fiber节点。我们可以从这里看到tag的所有类型。对于我们常见的组件类型,如FuctionComponentClassComponentHostComponent,最终会进入reconcileChildren方法。

1.1.2 update阶段

顺着另一条线路来看看update阶段。在该阶段,首先通过对传入的两个Fiber节点的typeprops进行比较,判断该节点是否可以复用。若不可以复用,则把他当成新的Fiber节点看待,走类似mount阶段的流程,最后生成带effecTag的新的Fiber节点;若可以复用,则检查子树是否需要更新。若子树需要更新,则执行cloneChildFibers并且返回子节点;否则直接返回null.

1.1.3 reconcileChildren

从该函数名就能看出这是Reconciler模块的核心部分。它对于从不同路径来的Fiber节点做了不同的事情:

  • 对于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 === null?来区分mountupdate的。并且我们发现在流程图中的mountChildFibersreconcileChildFibers这两个方法的实际执行者都是reconcileChildren.

但无论走哪个逻辑,最终它都会生成新的子Fiber节点,并赋值给workInprogress.child,作为本次beginWork的返回值,并且作为下次perfomUnitOfWork执行时workInProgress的传参。

1.1.4 effectTag

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

1.2 “归”的过程

归的过程会执行compleWork。类似beginWork,completeWork也是针对不同fiber.tag调用不同的处理逻辑。我们首先来看流程图:

image.png

同样,completeWork传入三个参数:currentworkInProgressrenderLanes。它首先根据workInprogress.tag判断组件的类型,并且把它们分开处理。我们在这里以HostComponent为例来说明。

beginWork一样,它也是通过判断current === null?来判断mount阶段和update阶段。

1.2.1 mount阶段

mout时的主要逻辑有三个:

  • 为Fiber节点生成对应的DOM节点
  • 将子孙DOM节点插入到刚生成的DOM节点中
  • 处理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);
}

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

1.2.2 update阶段

update阶段,Fiber节点已经存在对应的DOM节点,所以不需要生成DOM节点。需要做的就是处理props

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

1.2.3 effectList

one more question:作为DOM操作的依据,commit阶段需要找到所有有effectTag的Fiber节点并依次执行effectTag对应操作。如果再遍历一次Fiber树是及其低效的操作,那么怎么办呢?

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

effectList中第一个Fiber节点白存在fiber.firstEffect,最后一个元素保存在fiber.lastEffect,在归的阶段,所有有effectTag的Fiber节点都会被追加到effectList中,最终形成以rootFiber.firstEffect为起点的单向链表。这样,在commit阶段只需要遍历effectList九能执行所有的effect了。

2. commit阶段

render阶段的工作完成后,调用commitRoot(root)进入到commit阶段。如上一节所述,在rootFiber.firstEffect上保存了一条需要执行副作用的Fiber节点的单向链表,这些Fiber节点的updateQueue中保存了变化的props。这些副作用对应的DOM操作在commit阶段执行。除此之外,一些生命周期的钩子、Hook需要在commit阶段执行。

在commit阶段,我们将两次遍历effectList,根据effectTag调用不同的函数对Fiber进行处理。

2.1 准备工作和收尾工作

  • before mutation之前: 这一阶段主要做一些变量赋值、状态重置的工作
  • layout之后的工作,主要包括三点内容:
    1. userEffect相关的处理
    2. 性能追踪相关
    3. commit阶段会触发的一些生命周期的钩子和Hook

2.2 before mutation阶段

在before mutation阶段,需要做的三件事情:

  • 处理DOM节点渲染/删除后的autoFocus,blur逻辑
  • 调用getSnapshotBeforeUpdate生命周期钩子
  • 调度useEffect 然后,我们来看代码的大致逻辑:
function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    const current = nextEffect.alternate;

    if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
      // ...focus blur相关
    }

    const effectTag = nextEffect.effectTag;

    // 调用getSnapshotBeforeUpdate
    if ((effectTag & Snapshot) !== NoEffect) {
      commitBeforeMutationEffectOnFiber(current, nextEffect);
    }

    // 调度useEffect
    if ((effectTag & Passive) !== NoEffect) {
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        scheduleCallback(NormalSchedulerPriority, () => {
          flushPassiveEffects();
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

2.3 mutation阶段

在mutation阶段,遍历effectList,执行的函数是commitMutationEffects.这个函数会对每个Fiber节点执行如下三个操作:

  • 根据ContentReset effectTag重置文字节点
  • 更新ref
  • 根据effectTag分别处理,其中,effectTag包括Placement | Update | Deletion | Hydrating. 具体的代码实现如下:
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
  // 遍历effectList
  while (nextEffect !== null) {

    const effectTag = nextEffect.effectTag;

    // 根据 ContentReset effectTag重置文字节点
    if (effectTag & ContentReset) {
      commitResetTextContent(nextEffect);
    }

    // 更新ref
    if (effectTag & Ref) {
      const current = nextEffect.alternate;
      if (current !== null) {
        commitDetachRef(current);
      }
    }

    // 根据 effectTag 分别处理
    const primaryEffectTag =
      effectTag & (Placement | Update | Deletion | Hydrating);
    switch (primaryEffectTag) {
      // 插入DOM
      case Placement: {
        commitPlacement(nextEffect);
        nextEffect.effectTag &= ~Placement;
        break;
      }
      // 插入DOM 并 更新DOM
      case PlacementAndUpdate: {
        // 插入
        commitPlacement(nextEffect);

        nextEffect.effectTag &= ~Placement;

        // 更新
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // SSR
      case Hydrating: {
        nextEffect.effectTag &= ~Hydrating;
        break;
      }
      // SSR
      case HydratingAndUpdate: {
        nextEffect.effectTag &= ~Hydrating;

        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // 更新DOM
      case Update: {
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // 删除DOM
      case Deletion: {
        commitDeletion(root, nextEffect, renderPriorityLevel);
        break;
      }
    }

    nextEffect = nextEffect.nextEffect;
  }
}

在这里,我们只关注PlacementUpdateDeletion这三个case中的内容。

2.3.1 Placement

从前面的代码中可以看出,Placement调用的方法是commitPlacement。它所做的工作分为三步:

  1. 获取父级DOM节点。
  2. 获取Fiber节点的DOM兄弟节点
  3. 根据兄弟节点是否存在决定调用parentNode.insertBeforeparentNode.appendChild执行DOM插入操作

2.3.2 Update

Fiber节点含有Update effectTag,意味着该Fiber节点需要更新。调用的方法为commitWork,他会根据Fiber.tag分别处理。

2.3.3 Deletion

Fiber节点含有Deletion effectTag,意味着该Fiber节点对应的DOM节点需要从页面中删除。调用的方法为commitDeletion。该方法会执行如下操作:

  • 递归调用Fiber节点及其子孙Fiber节点中fiber.tagClassComponentcomponentWillUnmont生命周期钩子,从页面中移除Fiber节点对应DOM节点
  • 解绑ref
  • 调用useEffect的销毁函数

2.4 layout阶段

在Layout阶段,遍历effectTag,执行的函数是commitLayoutEffects。这个函数一共做了两件事:

  • commitLayoutEffectOnFiber:调用生命周期钩子和Hook相关操作
  • commitAttachRef:赋值ref 具体实现的代码如下:
function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;

    // 调用生命周期钩子和hook
    if (effectTag & (Update | Callback)) {
      const current = nextEffect.alternate;
      commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
    }

    // 赋值ref
    if (effectTag & Ref) {
      commitAttachRef(nextEffect);
    }

    nextEffect = nextEffect.nextEffect;
  }
}