React源码解读(3)—— commit阶段

389 阅读5分钟

前言

建议没有看过前面系列文章从前面看起,这是本系列的第三篇文章,这篇文章文章主要讲commit阶段,这个阶段的主要工作是处理副作用。所谓副作用就是不确定操作,比如:插入,替换,删除DOM,还有例如useEffect()hook的回调函数都会被作为副作用

commitWork

流程概述

commitRoot方法是commit阶段工作的起点。fiberRootNode会作为传参。

commitRoot(root);

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

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

除此之外,一些生命周期钩子(比如componentDidXXX)、hook(比如useEffect)需要在commit阶段执行。· commit阶段的主要工作(即Renderer的工作流程)分为三部分:

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

berfore mutation阶段

before muation阶段主要是遍历effectList并调用commitBeforeMutationEffects函数处理。

// 保存之前的优先级,以同步优先级执行,执行完毕后恢复之前优先级
const previousLanePriority = getCurrentUpdateLanePriority();
setCurrentUpdateLanePriority(SyncLanePriority);

// 将当前上下文标记为CommitContext,作为commit阶段的标志
const prevExecutionContext = executionContext;
executionContext |= CommitContext;

// 处理focus状态
focusedInstanceHandle = prepareForCommit(root.containerInfo);
shouldFireAfterActiveInstanceBlur = false;

// beforeMutation阶段的主函数
commitBeforeMutationEffects(finishedWork);

focusedInstanceHandle = null;

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

整体可以分为三部分:

  1. 处理DOM节点渲染/删除后的 autoFocusblur 逻辑。
  2. 调用getSnapshotBeforeUpdate生命周期钩子。
  3. 调度useEffect(异步)

调度useEffect

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

在此处,被异步调度的回调函数就是触发useEffect的方法flushPassiveEffects

我们接下来讨论useEffect如何被异步调度,以及为什么要异步(而不是同步)调度 在flushPassiveEffects方法内部会从全局变量rootWithPendingPassiveEffects获取effectList

在上一篇文章中我们讲到,effectList中保存了需要执行副作用的Fiber节点。其中副作用包括

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

除此外,当一个FunctionComponent含有useEffectuseLayoutEffect,他对应的Fiber节点也会被赋值effectTag。 在flushPassiveEffects方法内部会遍历rootWithPendingPassiveEffects(即effectList)执行effect回调函数。

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

mutation 阶段

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

nextEffect = firstEffect;
do {
  try {
      commitMutationEffects(root, renderPriorityLevel);
    } catch (error) {
      invariant(nextEffect !== null, 'Should be working on an effect.');
      captureCommitPhaseError(nextEffect, error);
      nextEffect = nextEffect.nextEffect;
    }
} while (nextEffect !== null);

commitMutationEffects

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

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

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

我们关注步骤三中的Placement | Update | DeletionHydrating作为服务端渲染相关,我们先不关注

Placement effect

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

调用的方法为commitPlacementcommitPlacement方法所做的工作主要为三步

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

Update effect

Fiber节点含有Update effectTag,意味着该Fiber节点需要更新。调用的方法为commitWork,他会根据Fiber.tag分别处理。 所谓“销毁函数”,见如下例子:

useLayoutEffect(() => {
  // ...一些副作用逻辑

  return () => {
    // ...这就是销毁函数
  }
})

HostComponent mutation

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

for (let i = 0; i < updatePayload.length; i += 2) {
  const propKey = updatePayload[i];
  const propValue = updatePayload[i + 1];

  // 处理 style
  if (propKey === STYLE) {
    setValueForStyles(domElement, propValue);
  // 处理 DANGEROUSLY_SET_INNER_HTML
  } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
    setInnerHTML(domElement, propValue);
  // 处理 children
  } else if (propKey === CHILDREN) {
    setTextContent(domElement, propValue);
  } else {
  // 处理剩余 props
    setValueForProperty(domElement, propKey, propValue, isCustomComponentTag);
  }
}

Deletion effect

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

该方法会执行如下操作:

  1. 递归调用Fiber节点及其子孙Fiber节点fiber.tagClassComponentcomponentWillUnmount生命周期钩子,从页面移除Fiber节点对应DOM节点
  2. 解绑ref
  3. 调用useEffect的回调函数

layout阶段

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

具体执行的函数是commitLayoutEffects

root.current = finishedWork;

nextEffect = firstEffect;
do {
  try {
    commitLayoutEffects(root, lanes);
  } catch (error) {
    invariant(nextEffect !== null, "Should be working on an effect.");
    captureCommitPhaseError(nextEffect, error);
    nextEffect = nextEffect.nextEffect;
  }
} while (nextEffect !== null);

nextEffect = null;

commitLayoutEffects

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

commitLayoutEffects一共做了两件事:

  1. commitLayoutEffectOnFiber(调用生命周期钩子hook相关操作)
  2. commitAttachRef(赋值 ref)

commitLayoutEffectOnFiber

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

  • 对于ClassComponent,他会通过current === null?区分是mount还是update,调用componentDidMountcomponentDidUpdate

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

this.setState({ xxx: 1 }, () => {
  console.log("i am update~");
});
  • 对于FunctionComponent及相关类型,他会调用useLayoutEffect hook回调函数,调度useEffect销毁回调函数

相关类型指特殊处理后的FunctionComponent,比如ForwardRefReact.memo包裹的FunctionComponent

  switch (finishedWork.tag) {
    // 以下都是FunctionComponent及相关类型
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
    case Block: {
      // 执行useLayoutEffect的回调函数
      commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
      // 调度useEffect的销毁函数与回调函数
      schedulePassiveEffects(finishedWork);
      return;
    }

mutation阶段会执行useLayoutEffect hook销毁函数

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

useEffect则需要先调度,在Layout阶段完成后再异步执行。

这就是useLayoutEffectuseEffect的区别。

  • 对于HostRoot,即rootFiber,如果赋值了第三个参数回调函数,也会在此时调用。
ReactDOM.render(<App />, document.querySelector("#root"), function() {
  console.log("i am mount~");
});

commitAttachRef

commitLayoutEffects会做的第二件事是commitAttachRef

function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    const instance = finishedWork.stateNode;

    // 获取DOM实例
    let instanceToUse;
    switch (finishedWork.tag) {
      case HostComponent:
        instanceToUse = getPublicInstance(instance);
        break;
      default:
        instanceToUse = instance;
    }

    if (typeof ref === "function") {
      // 如果ref是函数形式,调用回调函数
      ref(instanceToUse);
    } else {
      // 如果ref是ref实例形式,赋值ref.current
      ref.current = instanceToUse;
    }
  }
}

代码逻辑很简单:获取DOM实例,更新ref

总结

从这篇文章中我们梳理了一遍commit流程,我们要紧紧抓住before mutationmutation,layout阶段这几个重点,在后面应该会更新diffHook的文章,可能要在国庆后了,因为马上要国庆了,所以我可能不想带电脑回家,所以文章也要国庆之后更新了,希望自己能保持一个持续学习的过程了

React源码系列