react-commit阶段概览

loading 2024年01月18日 131次浏览

如前一篇文章所说,commitRoot方法是commit阶段工作的起点。fiberRootNode会作为传参。

rootFiber.firstEffect上保存了一条需要执行副作用的Fiber节点的单向链表effectList,这些Fiber节点的updateQueue中保存了变化的props。

这些副作用对应的DOM操作在commit阶段执行。

除此之外,一些生命周期钩子(比如componentDidXXX)、hooks(比如useEffect)需要在commit阶段执行。

commit阶段的主要工作(即Renderer的工作流程)分为三部分:

  • before mutation阶段(执行DOM操作前)

  • mutation阶段(执行DOM操作)

  • layout阶段(执行DOM操作后)

1. beforeMutation

before mutation阶段的代码很短,整个过程就是遍历effectList并调用commitBeforeMutationEffects函数。

commitBeforeMutationEffects大体代码逻辑:

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

整体可以分为三部分:

  • 处理DOM节点渲染/删除后的 autoFocus、blur 逻辑。

  • 调用getSnapshotBeforeUpdate生命周期钩子。

  • 调度useEffect。

这里重点看第二第三点

1.1 调用getSnapshotBeforeUpdate

commitBeforeMutationEffectOnFiber方法内会调用getSnapshotBeforeUpdate

从Reactv16开始,componentWillXXX钩子前增加了UNSAFE_前缀。

究其原因,是因为Stack Reconciler重构为Fiber Reconciler后,render阶段的任务可能中断/重新开始,对应的组件在render阶段的生命周期钩子(即componentWillXXX)可能触发多次。

为此,React提供了替代的生命周期钩子getSnapshotBeforeUpdate

我们可以看见,getSnapshotBeforeUpdate是在commit阶段内的before mutation阶段调用的,由于commit阶段是同步的,所以不会遇到多次调用的问题。

1.2 调度useEffect

在这几行代码内,scheduleCallback方法由Scheduler模块提供,用于以某个优先级异步调度一个回调函数,也就是触发useEffect的方法flushPassiveEffects

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

那么useEffect如何被异步调度,又为什么要异步(而不是同步)调度?

1.2.1 flushPassiveEffects怎么工作

首先会获取effectList,在completeWork一节我们讲到,effectList中保存了需要执行副作用的Fiber节点。其中副作用包括:

  • 插入(Placement)
  • 更新(Update)
  • 删除(Deletion)

除此外,当一个FunctionComponent含有useEffectuseLayoutEffect,他对应的Fiber节点也会被赋值effectTag

原博客中这块有点复杂,直接看总结:

整个useEffect异步调用分为三步:

  • before mutation阶段在scheduleCallback中调度flushPassiveEffects
  • layout阶段之后将effectList赋值给rootWithPendingPassiveEffects
  • scheduleCallback触发flushPassiveEffects,该函数内部遍历rootWithPendingPassiveEffects

1.2.2 为什么要异步调用useEffect

componentDidMountcomponentDidUpdate不同的是,在浏览器完成布局与绘制之后,传给useEffect的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。

可见,useEffect异步执行的原因主要是防止同步执行时阻塞浏览器渲染

2. Mutation

类似before mutation阶段,mutation阶段也是遍历effectList,执行函数。这里执行的是commitMutationEffects

commitMutationEffects会遍历effectList,对每个Fiber节点执行如下三个操作:

  • 根据ContentReset effectTag重置文字节点
  • 更新ref
  • 根据effectTag分别处理,其中effectTag包括(Placement | Update | Deletion | Hydrating)

这里重点讨论下第三点(不讨论Hydrating,因为涉及到服务端渲染)。

2.1 Placement

effectTag意味着该Fiber节点对应的DOM节点需要插入到页面中。

调用的方法为commitPlacement

  1. 获取父级DOM节点。其中finishedWork为传入的Fiber节点。
const parentFiber = getHostParentFiber(finishedWork);
// 父级DOM节点
const parentStateNode = parentFiber.stateNode;
  1. 获取Fiber节点的DOM兄弟节点
const before = getHostSibling(finishedWork);
  1. 根据DOM兄弟节点是否存在决定调用parentNode.insertBeforeparentNode.appendChild执行DOM插入操作。
// parentStateNode是否是rootFiber
if (isContainer) {
  insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
  insertOrAppendPlacementNode(finishedWork, before, parent);
}

值得注意的是,getHostSibling(获取兄弟DOM节点)的执行很耗时,当在同一个父Fiber节点下依次执行多个插入操作,getHostSibling算法的复杂度为指数级。

这是由于Fiber节点不只包括HostComponent,所以Fiber树和渲染的DOM树节点并不是一一对应的。要从Fiber节点找到DOM节点很可能跨层级遍历。

2.2 Update

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

这里我们主要关注FunctionComponentHostComponent

2.2.1 FunctionComponent

fiber.tagFunctionComponent,会调用commitHookEffectListUnmount。该方法会遍历effectList,执行所有useLayoutEffect销毁函数

2.2.2 HostComponent

fiber.tagHostComponent,会调用commitUpdate

最终会在updateDOMProperties中将render阶段completeWork中为Fiber节点赋值的updateQueue对应的内容渲染在页面上。

2.3 Deletion

意味着该Fiber节点对应的DOM节点需要从页面中删除。调用的方法为commitDeletion

该方法会执行如下操作:

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

3. layout

该阶段之所以称为layout,因为该阶段的代码都是在DOM修改完成(mutation阶段完成)后执行的。

由于 JS 的同步执行阻塞了主线程,所以此时 JS 已经可以获取到新的DOM,但是浏览器对新的DOM并没有完成渲染。
该阶段触发的生命周期钩子和hook可以直接访问到已经改变后的DOM。

与前两个阶段类似,layout阶段也是遍历effectList,执行函数。

具体执行的函数是commitLayoutEffects,这个函数一共做了两件事:

  • commitLayoutEffectOnFiber(调用生命周期钩子和hook相关操作)

  • commitAttachRef(赋值 ref)

3.1 commitLayoutEffectOnFiber

该方法同样是根据fiber.tag对不同类型的节点分别处理。

  1. ClassComponent
    他会通过current === null?区分是mount还是update,调用componentDidMountcomponentDidUpdate

    触发状态更新的this.setState如果赋值了第二个参数回调函数,也会在此时调用。

  2. FunctionComponent
    调用useLayoutEffect的回调函数,调度useEffect的销毁与回调函数。

    在2.2中Update时介绍过,mutation阶段会执行useLayoutEffect的销毁函数。

    结合这里我们可以发现,useLayoutEffect从上一次更新的销毁函数调用到本次更新的回调函数调用是同步执行的。

    useEffect则需要先调度,在Layout阶段完成后再异步执行。
    这就是useLayoutEffectuseEffect的区别。

3.2 commitAttachRef

获取DOM实例,更新ref。

4. Fiber树切换

至此,整个layout阶段就结束了。

在结束前,我们关注下这行代码:

root.current = finishedWork;

我们知道,workInProgress Fiber树在commit阶段完成渲染后会变为current Fiber树。这行代码的作用就是切换fiberRootNode指向的current Fiber树。

那么这行代码在什么时候执行呢?在mutation阶段结束后,layout阶段开始前。

我们知道componentWillUnmount会在mutation阶段执行。此时current Fiber树还指向前一次更新的Fiber树,在生命周期钩子内获取的DOM还是更新前的。

componentDidMountcomponentDidUpdate会在layout阶段执行。此时current Fiber树已经指向更新后的Fiber树,在生命周期钩子内获取的DOM就是更新后的。