Bootstrap

React 的源码与原理解读(十五):Hooks解读之四 useLayoutEffect&useEffect

写在专栏开头(叠甲)

  1. 作者并不是前端技术专家,也只是一名喜欢学习新东西的前端技术小白,想要学习源码只是为了应付急转直下的前端行情和找工作的需要,这篇专栏是作者学习的过程中自己的思考和体会,也有很多参考其他教程的部分,如果存在错误或者问题,欢迎向作者指出,作者保证内容 100% 正确,请不要将本专栏作为参考答案。

  2. 本专栏的阅读需要你具有一定的 React 基础、 JavaScript 基础和前端工程化的基础,作者并不会讲解很多基础的知识点,例如:babel 是什么,jsx 的语法是什么,需要时请自行查阅相关资料。

  3. 本专栏很多部分参考了大量其他教程,若有雷同,那是作者抄袭他们的,所以本教程完全开源,你可以当成作者对各类教程进行了整合、总结并加入了自己的理解。

本一节的内容

这个章节主要讲解React 的 useLayoutEffect 和 useEffect 这两个 api,它们也是我们常用的 hooks,这两个 api 常被我们用来作为函数组件的生命周期来使用,用于处理我们副作用相关的内容,这篇我们就来看看他们的原理,以及在源码中运行作用的方式

useEffect 的定义

useEffect 是我们最常见的几个 hooks 之一,给函数组件增加了操作副作用的能力。

其第一个参数是一个副作用函数,React 会在每次渲染后调用副作用函数 ,副作用函数还可以通过返回一个函数来指定如何清除副作用。

其第二个参数是一个依赖项数组,只有其中有一项发生变化的情况才会触发当前的 userEffect ,否则不触发,如果我们设置第二个参数为空,那么相当于我们会监听所有的数据变化。

useEffect(effect, deps?);

useEffect 的使用

作为生命周期使用

useEffect 可以看做 react 中 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个生命周期函数的组合。当我们传递的 deps 数组中没有数据时,我们的 useEffect 只会在首次渲染的时候触发,因为任何数值的改变不会触发这个钩子,也就类似于 componentDidMount 这个生命周期,注意他们类似但是不完全相同,componentDidMount 理论上和我们的 useLayoutEffect 钩子完全一致,这个之后会说:

useEffect(()=>{
      console.log('这是初始化的hooks')
},[])

之前提到了,副作用函数还可以通过返回一个函数来指定如何清除副作用。如果在上述情况在使用,也就相当于这个返回的函数会在组件卸载前执,也就是相当于 componentWillUnmount 生命周期:

useEffect(()=>{
      console.log('这是初始化的hooks')
      return ()=>{
            console.log('这是卸载的hooks')
      }
 },[])

当我们设置第二个参数为空时,在初次渲染执行一次后,会监听所有数据的更新,数据更新都会触发useEffect , 也就是说类似于componentDidMountcomponentDidUpdate 这两个生命周期,但是也有所不同,原理和上面的一致,我们稍后会说::

useEffect(()=>{
      console.log('这是更新的hooks')
})

监控数据的变化

如果我们为第二个参数设置了监听的元素,那么在初次渲染执行一次后,只会监听相应元素变化才会触发回调函数。这相当于 vue 的 watch 的用法:

useEffect(()=>{
    console.log('num changed')
}, [num])

useLayoutEffect

useLayoutEffectuseEffect 的定义完全相同,都是其传入一个副作用函数和一个依赖项数组,他的区别在于:

  • useEffect 是异步的,useLayoutEffect 是同步的
  • useEffect 的执行时机是浏览器完成渲染之后,而 useLayoutEffect 的执行时机是浏览器把内容真正渲染到界面之前

也就说,useLayoutEffect 和我们的 componentDidMountcomponentDidUpdate 应该是一致的,而 useEffect 在调用时机和处理方式上有所不同,这个我们之后会结合源码来说

比如下面的例子:我们在 useLayoutEffectuseEffect 中都设置一个逻辑,让我们的 state 在一秒后从 “hello world” 变成 “world hello”,如果我们使用 useEffect ,因为他的执行时机是浏览器完成渲染后,并且是异步的,所以会先显示"hello world",任何变成 “world hello”;反之 useLayoutEffect 是同步的,并且在渲染之前就执行了,所以我们只会看到 “world hello”

function App() {
  const [state, setState] = useState("hello world")

  useEffect(() => {
    let i = 0;
    // 这里是一个 1秒的停顿
    setState("world hello");
  }, []);

  useLayoutEffect(() => {
     let i = 0;
     // 这里是一个 1秒的停顿
     setState("world hello");
  }, []);

  return (
    <>
      <div>{state}</div>
    </>
  );
}
export default App;

useLayoutEffect 和 useEffect 的使用场景

根据上述的描述,useLayoutEffect 和 useEffect 这两个钩子的区别决定了他们有不一样的使用场景,我们简单来描述一下:

  • 如果我们的副作用会影响到渲染,也就说可能会让我们页面展示的内容发生变化,那么我们尽量使用 useLayoutEffect,因为他不会让用户看到脏数据
  • 但是如果你的副作用需要很长时间来处理,就需要使用 useEffect ,因为他是异步的,不会阻塞渲染的逻辑, useLayoutEffect因为是同步的,它消耗的时间会计算在渲染界面展示给用户的时间中

有一个特殊的情况是 SSR 场景,因为 useLayoutEffect 是在浏览器渲染完成之前执行的,所以它是不会在服务端执行的,所以就有可能导致 SSR 渲染出来的内容和实际的首屏内容并不一致,此时会有一个 warning ,所以在 SSR 场景下应该尽量使用 useEffect ,除非你确定它不会影响两次渲染的结果

useEffect 和 useLayoutEffect 的源码

因为这两个钩子的源码及其类似,所以我们就一起讲解了,主要以 useEffect 为主,辅之以讲解 useLayoutEffect 的区别,最后在两个钩子执行时我们再具体分开来将他们的执行过程的差异:

mount 阶段

我们先来看 mount,去掉 dev 部分后,两个 hook 其实就是调用了一个 mountEffectImpl 函数,但是其中有几个参数我们要讲一讲:

我们传入 mountEffectImpl 参数中包含了很多的 来自 ReactFiberFlags 的标志位,这些标志位有以下的作用:

  • PassiveEffect 、PassiveStaticEffect 、 UpdateEffect 以及 LayoutStaticEffect 是用于标记副作用类型的,他们会挂载到 fiber 上,这些标记不一定是由 hooks 创建的 ,也可能是由比如 class 组件的生命周期创建的
  • HookPassive 和 HookLayout 则是标记 hooks 的,他们会放在我们的 hooks 的数据结构中
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
    return mountEffectImpl(
      PassiveEffect | PassiveStaticEffect,
      HookPassive,
      create,
      deps,
    );
}

function mountLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  let fiberFlags: Flags = UpdateEffect;
  if (enableSuspenseLayoutEffectSemantics) {
    fiberFlags |= LayoutStaticEffect;
  }
  return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}

我们继续来看这个关键的 mountEffectImpl 函数,他首先把我们的设定的 fiberFlags 设定到了当前的 Fiber 上,后续我们会通过这个标志判定要不要处理 effect。然后使用 pushEffect ,它会先创建一个 effect 数据结构,接着将 effect 添加到函数组件 fiber 的更新队列 updateQueue 之上,最后返回这个创建的 effect 作为 hook.memoizedState。

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps; 
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    next: (null: any),
  };
  // 获取更新队列
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  // 没有的话先创建更新队列
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

update 阶段

update 阶段和 mount 阶段大同小异,我们直接来看:

首先在 update 阶段,两个 hook 都调用了 updateEffectImpl 这个函数,这个函数中,我们获取了上次的缓存的 effect,拿出本次的 deps 和这次的进行对比,根据对比的情况来加入不同的内容到我们的 Effect 队列中:

  • 如果两次的依赖项没有变化,或者依赖项是空,我们本次不会执行这个 hook,推入一个没有 HookHasEffect 标记的 effect ,并且直接退出函数
  • 如果有变化,或者 nextDeps 不存在,那么我们先在 fiber 上打标记,然后推入一个包含 HookHasEffect 标记的 effect,说明这个 effect 包含副作用需要被执行
function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}

function updateLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook(); // 获取当前的hook
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState; // 上次渲染时使用的hook
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps; // 上次渲染时的依赖项
      // 判断依赖项是否发生变化
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
    // 若nextDeps为null
  }

  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

Effect 的处理

根据上面的描述,我们知道了,其实 useEffect 做的只是把副作用加入到了我们 Fiber 的 updateQueue 属性上,然后在 fiber 上做了记号,说明这个 fiber 上有副作用,那么这些 effect 的执行是在哪里呢,我们需要把视线回到之前的教程,关于 commit 阶段这篇中,我们提到了 commit 的三个阶段,其中就包含了副作用的处理,当时我们跳过了这个部分,现在我们终于可以把整个坑补上了:

我们直接从 commitRootImpl 函数开始看,将我们之前没讲过的内容全部理一遍:

flushPassiveEffects

首先是开头的部分,我们要调用一次 flushPassiveEffects ,这个函数的作用执行了所有的 hook,关于这个函数我们马上会详细来说。这一步是为了保证在开始我们的 commit 之前,我们没有未执行的的 effect 了 ,其中rootWithPendingPassiveEffects 是一个很重要变量,我们之后会讲,其中我们使用 do… while 的逻辑是因为,在其中的任务被调度的过程中很可能被高优先级的任务打断,我们必须确保运行之前我们的 rootWithPendingPassiveEffects 是空的,才能保证下面的逻辑没用问题:

function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<mixed>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
  do {
    flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);
  //.....
}

我们来详细看看这个 flushPassiveEffects 函数:它首先获取了 rootWithPendingPassiveEffects 这个全局变量,之后经过一系列的优先级操作后,它调用了 flushPassiveEffectsImpl,这是它的核心逻辑

我们只看重要的部分,也就是 commitPassiveUnmountEffectscommitPassiveMountEffects 两个函数,他们分别执行 useEffect 在上一次执行的销毁函数以及本次的回调函数

export function flushPassiveEffects(): boolean {
  if (rootWithPendingPassiveEffects !== null) {
    // .... 省略优先级和DEV操作
    try {
      // 省略
      return flushPassiveEffectsImpl();
    } finally {
	  //省略
    }
  }
  return false;
}


function flushPassiveEffectsImpl() {
  if (rootWithPendingPassiveEffects === null) {
    return false;
  }
  // 重置这个 rootWithPendingPassiveEffects
  rootWithPendingPassiveEffects = null;
  // .... 省略优先级和DEV操作
  //  调用useEffect在上一次render时的销毁函数;
  commitPassiveUnmountEffects(root.current);
  //  调用useEffect在本次render时的回调函数;
  commitPassiveMountEffects(root, root.current, lanes, transitions);
  // ... 省略
  return true;
}

我们先完整的看一个部分的执行,commitPassiveUnmountEffects 依次调用了 commitPassiveUnmountEffects_begincommitPassiveUnmountEffects_completecommitPassiveUnmountOnFibercommitHookEffectListUnmount

我们都明白了,这其实就是我们整个 fiber 树的从上到下的递归过程,和我们的 fiber 树的创建和更新的一样的,相信也不需要我多解释了,就是先向下递归,没有子节点了就遍历兄弟,直到完成整个遍历,这里我们需要处理两种情况:

  • 如果是元素有子节点被删除了(具有 ChildDeletion 标记),我们需要调用 commitPassiveUnmountEffectsInsideOfDeletedTree_begin 函数进行处理,其中调用了 commitPassiveUnmountInsideDeletedTreeOnFiber 函数进行逻辑的处理,commitPassiveUnmountEffectsInsideOfDeletedTree_complete 函数完成了循环递归遍历,而这个函数调用了 commitHookEffectListUnmount 这个函数来处理副作用的卸载
  • 与此同时,还有一种情况是没有元素删除,但是我们使用的 useEffect 需要触发,此时我们需要执行对上一次的副作用进行销毁,然后重新挂载,它依次调用了 commitPassiveUnmountEffects_begincommitPassiveUnmountEffects_completecommitPassiveUnmountOnFiber 这个流程,最后还是提交到了 commitHookEffectListUnmount 这个函数

上面的讲述告诉了我们, commitHookEffectListUnmount 这个函数在两个情况下会触发,一个是有元素要被删除了,一个是有元素的 useEffect 需要重新挂载(先销毁再创建)他们唯一的区别是 commitHookEffectListUnmount 的入参有所不同

export function commitPassiveUnmountEffects(firstChild: Fiber): void {
  nextEffect = firstChild;
  commitPassiveUnmountEffects_begin();
}
function commitPassiveUnmountEffects_begin() {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    const child = fiber.child;
	
    // 这部分是对应有子节点被删除的情况
    if ((nextEffect.flags & ChildDeletion) !== NoFlags) {
      const deletions = fiber.deletions;
      if (deletions !== null) {
        for (let i = 0; i < deletions.length; i++) {
          const fiberToDelete = deletions[i];
          nextEffect = fiberToDelete;
          commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
            fiberToDelete,
            fiber,
          );
        }
        // 孩子被删除了,那么他们的指针引用等都要清除
        if (deletedTreeCleanUpLevel >= 1) {
          const previousFiber = fiber.alternate;
          if (previousFiber !== null) {
            let detachedChild = previousFiber.child;
            if (detachedChild !== null) {
              previousFiber.child = null;
              do {
                const detachedSibling = detachedChild.sibling;
                detachedChild.sibling = null;
                detachedChild = detachedSibling;
              } while (detachedChild !== null);
            }
          }
        }
        nextEffect = fiber;
      }
    }
	// 子树还有 effect
    if ((fiber.subtreeFlags & PassiveMask) !== NoFlags && child !== null) {
      child.return = fiber;
      nextEffect = child;
    } else {
      // 结束遍历
      commitPassiveUnmountEffects_complete();
    }
  }
}

function commitPassiveUnmountEffects_complete() {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    if ((fiber.flags & Passive) !== NoFlags) {
      setCurrentDebugFiberInDEV(fiber);
      // 真的核心逻辑
      commitPassiveUnmountOnFiber(fiber);
      resetCurrentDebugFiberInDEV();
    }
    // 往兄弟节点走
    const sibling = fiber.sibling;
    if (sibling !== null) {
      sibling.return = fiber.return;
      nextEffect = sibling;
      return;
    }
    nextEffect = fiber.return;
  }
}

function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      if (
        enableProfilerTimer &&
        enableProfilerCommitHooks &&
        finishedWork.mode & ProfileMode
      ) {
        startPassiveEffectTimer();
        // 执行副作用的 destroy
        commitHookEffectListUnmount(
          HookPassive | HookHasEffect,
          finishedWork,
          finishedWork.return,
        );
        recordPassiveEffectDuration(finishedWork);
      } else {
        commitHookEffectListUnmount(
          HookPassive | HookHasEffect,
          finishedWork,
          finishedWork.return,
        );
      }
      break;
    }
  }
}

现在我们来看 commitHookEffectListUnmount 这个函数:

这个函数的获取了我们传入的 Fiber 上的 updateQueue 更新列表,如果它的 tag 和我们传入的参数一致(上文说了分为两种情况),我们获取其 destroy 函数,之后把 destroy 函数设置为空,然后执行这个销毁逻辑:

function commitHookEffectListUnmount(
  flags: HookFlags,
  finishedWork: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          //....
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
          //....
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

function safelyCallDestroy(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
  destroy: () => void,
) {
  try {
    destroy();
  } catch (error) {
    captureCommitPhaseError(current, nearestMountedAncestor, error);
  }
}

经过这个流程 ,我们整棵 fiber 树中有 ChildDeletionPassive 标记的 fiber 中的卸载方法就全都执行了。

之后是 commitPassiveMountEffects 的逻辑,它的执行大体一致,我们不再赘述,感兴趣的可以自己去看源码,最后它会来到 commitPassiveMountOnFiber 这个函数中,我们只看 function 组件的逻辑,它调用了 commitHookEffectListMount 这个函数

function commitPassiveMountOnFiber(
  finishedRoot: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      if (
        enableProfilerTimer &&
        enableProfilerCommitHooks &&
        finishedWork.mode & ProfileMode
      ) {
        startPassiveEffectTimer();
        try {
          commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork);
        } finally {
          recordPassiveEffectDuration(finishedWork);
        }
      } else {
        commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork);
      }
      break;
    }
  }
}

commitHookEffectListMount 主要的工作是获取我们 fiber 上 updateQueue 的 effect,把其 create 函数作为其 destroy 函数,这里我们要讲一讲这个逻辑是什么意思:

如果我们正常写一个 useEffect 的逻辑,那么 useEffect 里面的函数就是它的 create,那么我们执行 create() 之后,得到的就是它的返回值,如果我们没有返回一个销毁副作用的函数,得到的就是 undefined,那么就执行销毁逻辑的时候也就什么也不会执行了,正如我们 mount 我们的 useEffect 的时候,其初始化传入的 destroy 是 undefined 一样;

但是如果我们在 useEffect 中返回了一个销毁函数,那么我们执行 create() 之后,我们返回的值就是这个函数,这个函数就被绑定到 destroy 属性上了,对应的就是在我们的上文的销毁逻辑中执行

所以这个函数的逻辑其实就是执行我们 useEffect 的函数逻辑,然后挂载我们的销毁函数,让下一次执行的时候执行我们的销毁逻辑

function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // ....
        const create = effect.create;
        effect.destroy = create();
        // ....
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

// 例子
/*
useEffect(()=>{
      console.log('这是初始化的hooks')
      return ()=>{
            console.log('这是卸载的hooks')
      }
},[])
*/

经过上面的步骤,我们的已经清空了我们之前没用执行的 effect,现在我们可以正式开始我们的 commit 阶段了:

往后看代码,在 commitBeforeMutationEffects 阶段之前,我们看到,我们将 fiber 的 subtreeFlagsflags PassiveMask 做了一个与运算,也就是在这个地方,我们之前在 fiber 上做的标记发挥了作用,如果你还记得之前的内容,那么你应该知道, PassiveMask 标记的就是 useEffect 相关的副作用,那么这段处理的意思就是,如果我们当前的 fiber 或者孩子上有 useEffect 相关的副作用,我们使用 scheduleCallback 开启一个任务执行 flushPassiveEffects 函数,与此同时它把 rootDoesHavePassiveEffects 这个变量设置为了 true,记住这个点它很重要!

BeforeMutation

我们这里并没有执行 flushPassiveEffects,而是给他注册了任务,这个任务需要我们的 React 来调度,在整个 commit 过程中,因为其优先级的操作,我们的 flushPassiveEffects 是不会执行的,而它什么时候开始执行我们之后会提到:

  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;
      pendingPassiveEffectsRemainingLanes = remainingLanes;
      pendingPassiveTransitions = transitions;
      scheduleCallback(NormalSchedulerPriority, () => {
        flushPassiveEffects();
        return null;
      });
    }
  }
Mutation

上面部分我们暂时放一放,先看之后的 commitMutationEffects 这个阶段,之前我们讲过,这个阶段主要是对 DOM 进行操作,比如插入删除和更新等等,而我们 hooks 的逻辑就在我们对应 FunctionComponent 的逻辑中,我们直接来看 commitMutationEffectsOnFiber 的逻辑(之前的递归 fiber 的过程可以去看之前的文章,有详细讲过)

我们首先看删除的逻辑,在删除的逻辑中,我们函数的调用流程是 recursivelyTraverseMutationEffects 其中对每个有副作用的孩子调用了 commitDeletionEffectscommitDeletionEffectsOnFiber,而这个函数对于函数组件的逻辑是:遍历副作用列表,对于带有 HookLayout 标识的副作用,调用了 safelyCallDestroy 函数,这个函数我们的已经提过了,它就是调用了我们对应的副作用的销毁函数:

    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      if (!offscreenSubtreeWasHidden) {
        const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any);
        if (updateQueue !== null) {
          const lastEffect = updateQueue.lastEffect;
          if (lastEffect !== null) {
            const firstEffect = lastEffect.next;

            let effect = firstEffect;
            do {
              const {destroy, tag} = effect;
              if (destroy !== undefined) {
                if ((tag & HookInsertion) !== NoHookEffect) {
                  safelyCallDestroy(
                    deletedFiber,
                    nearestMountedAncestor,
                    destroy,
                  );
                } else if ((tag & HookLayout) !== NoHookEffect) {
                   //注意看这个 tag 是HookLayout ,这是我们的 useLayoutEffect 的标识
                  if (enableSchedulingProfiler) {
                    markComponentLayoutEffectUnmountStarted(deletedFiber);
                  }

                  if (
                    enableProfilerTimer &&
                    enableProfilerCommitHooks &&
                    deletedFiber.mode & ProfileMode
                  ) {
                    startLayoutEffectTimer();
                    // 调用销毁函数
                    safelyCallDestroy(
                      deletedFiber,
                      nearestMountedAncestor,
                      destroy,
                    );
                    recordLayoutEffectDuration(deletedFiber);
                  } else {
                    safelyCallDestroy(
                      deletedFiber,
                      nearestMountedAncestor,
                      destroy,
                    );
                  }

                  if (enableSchedulingProfiler) {
                    markComponentLayoutEffectUnmountStopped();
                  }
                }
              }
              effect = effect.next;
            } while (effect !== firstEffect);
          }
        }
      }
      recursivelyTraverseDeletionEffects(
        finishedRoot,
        nearestMountedAncestor,
        deletedFiber,
      );
      return;
    }

插入的逻辑只会在原生组件和 fiber 根节点上操作,所以我们不需要来看,继续进入更新逻辑:首先明确,我们 useLayoutEffect 的标志是 HookLayout ,我们直接看代码,在源码逻辑中,我们使用了 commitHookEffectListUnmount 这个逻辑函数处理带有 HookLayout 标记的 effect,根据前文提到的,这个函数处理的就是我们函数的 destroy 逻辑,并且这个过程是一个同步执行的的过程。

根据我们分析的删除和更新两个情况的描述,我们已经明确了,在 commitMutationEffects 这个阶段,我们做的事情就是调用我们的 fiber 上 useLayoutEffect 这个钩子的 destroy 逻辑(销毁函数),那么它的 create 逻辑呢?我们继续看

	case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      // 删除操作
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      // 插入操作
      commitReconciliationEffects(finishedWork);
      if (flags & Update) {
        // 省略....
        if (
          enableProfilerTimer &&
          enableProfilerCommitHooks &&
          finishedWork.mode & ProfileMode
        ) {
          try {
            startLayoutEffectTimer();
            // 这个是 useLayoutEffect 标识的 effect
            commitHookEffectListUnmount(
              HookLayout | HookHasEffect,
              finishedWork,
              finishedWork.return,
            );
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
          recordLayoutEffectDuration(finishedWork);
        } else {
          try {
            commitHookEffectListUnmount(
              HookLayout | HookHasEffect,
              finishedWork,
              finishedWork.return,
            );
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
        }
      }
      return;
    }
Layout

最后我们来到 commitLayoutEffects 这个阶段,它执行了 commitHookEffectListMount 这个函数来处理我们的 hooks,我们还是跳过整个 fiber 树的递归过程,直接来看处理函数 commitLayoutEffectOnFiber ,在 FunctionComponent 对于的逻辑中,每个分支都调用了 commitHookEffectListMount 这个函数传入了 HookLayout 这个标识,我们已经直到了这个函数是处理 create 逻辑和挂载 destroy 的

    switch (finishedWork.tag) {
      case FunctionComponent:
      case ForwardRef:
      case SimpleMemoComponent: {
        if (
          !enableSuspenseLayoutEffectSemantics ||
          !offscreenSubtreeWasHidden
        ) {
          if (
            enableProfilerTimer &&
            enableProfilerCommitHooks &&
            finishedWork.mode & ProfileMode
          ) {
            try {
              startLayoutEffectTimer();
              commitHookEffectListMount(
                HookLayout | HookHasEffect,
                finishedWork,
              );
            } finally {
              recordLayoutEffectDuration(finishedWork);
            }
          } else {
            commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
          }
        }
        break;
      }
useLayoutEffect总结

那么到此我们的 useLayoutEffect 函数的逻辑已经执行完毕了,我们总结一下:

  • commitMutationEffects 这个阶段,执行我们删除节点和更新节点的销毁逻辑,也就是 destroy 函数

  • commitLayoutEffects 这个阶段,执行我们节点的副作用函数 create 逻辑并且挂载我们的 destroy 函数

Layout之后

最后我们继续看我们 commitRoot 的代码,在我们的三个阶段执行完毕之后,有这样一段代码,如果我们的 rootDoesHavePassiveEffects 的 true ,那么我们把我们的 rootWithPendingPassiveEffects 设置成 root,我们回忆一下,rootDoesHavePassiveEffects 是我们在最开始阶段要大家记住的变量,它代表了我们有 useEffect 的hook,而 rootWithPendingPassiveEffects 是一进入我们的逻辑就进行判定的,如果我们的 rootWithPendingPassiveEffects 不是空的就会执行一次我们的 flushPassiveEffects 处理我们的副作用。

与此同时,我们可以观察到,我们的 flushPassiveEffects 中也进行了 rootWithPendingPassiveEffects 的判断,也就是说,只有我们的 rootWithPendingPassiveEffects 不是空才回去执行操作,也就是说,我们的 flushPassiveEffects 也必须等到我们的 commitLayoutEffects 结束之后给予了赋值才能正常执行

  if (rootDoesHavePassiveEffects) {
    rootDoesHavePassiveEffects = false;
    rootWithPendingPassiveEffects = root;
    pendingPassiveEffectsLanes = lanes;
  } else {
    //....
  }

/**
  do {
    flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);
**/

/**
export function flushPassiveEffects(): boolean {
  if (rootWithPendingPassiveEffects !== null) {
  }
  return false;
}
**/
useEffect总结

现在我们来总结一下我们的 useEffect 的运行逻辑的:

  • 首先我们在 commit 阶段一开始就执行了一次 flushPassiveEffects 函数,这个函数遍历了我们的 fiber 上的副作用列表,对每个标记的 HookPassive 的副作用(useEffect 创建的)调用它的 destroy 逻辑,然后在调他们的 create 逻辑并且挂载 destroy 函数,这个逻辑中把我们的 rootWithPendingPassiveEffects 变量设置为了 null,这一步是因为上一次的渲染可能因为我们开启的flushPassiveEffects 被高优先级的任务抢占或者其他情况而没执行,我们需要确保进入 commit 阶段前我们没有未执行的useEffect 副作用了,这个逻辑也需要通过 do… while… 逻辑保证我们能顺利执行完毕(执行过程中也可能被抢占)
  • 在进入 commitBeforeMutationEffects 之前,如果显示我们的节点上具有 HookPassive 标记(在 render 过程中会做好记号),说明我们有需要处理的 useEffect 副作用,我们把 rootDoesHavePassiveEffects 这个全局变量设置为 true ,之后我们开启一个调度,给我们的 flushPassiveEffects 一个低优先级的任务,然后开始我们的 commit 阶段的逻辑
  • commit 阶段的三个子阶段执行完毕后,我们根据 rootDoesHavePassiveEffects 判断是不是有需要处理的 useEffect 副作用, 如果有,那么我们把 FiberRoot 给予我们的 rootWithPendingPassiveEffects 变量,这个,之后随着 commit 阶段的结束,我们注册的任务开始了调度,此时我们的 rootWithPendingPassiveEffects 包含了 effectList,就可以正常执行我们的处理逻辑
  • 如果遍历正常结束,那么我们的 rootWithPendingPassiveEffects 会被置为 null,此时我们下一轮调度就不会走开头的 do… while… 逻辑清空 rootWithPendingPassiveEffects ,但是如果我们的 flushPassiveEffects 运行中被中断了,下一次运行时就仍会存在 rootWithPendingPassiveEffects ,此时就需要清空他们了,要注意,如果我们没有后续的需要调度任务了,那么我们的 flushPassiveEffects 肯定也不会被打断,就能顺利执行完毕,所以不用担心出现有 useEffect 没执行的情况

总结

关于 effect 相关的 hook 终于是讲完了,本来想顺便理一下 class 组件的生命周期的,发现内容有些多就暂时不做了,之后有时间会补上,我们整体来总结一下我们的 effect 的 hook:

  • useEffect useLayoutEffect 是我们最常见的几个 hooks ,给函数组件增加了操作副作用的能力。
  • useEffect 是异步的,useLayoutEffect 是同步的;useEffect 的执行时机是浏览器完成渲染之后,而 useLayoutEffect 的执行时机是浏览器把内容真正渲染到界面之前
  • 他们的创建和更新都依赖于 mountEffectImpl 这个函数,这个函数创建了一个 effect 数据结构,包括其创建、销毁的函数以及标识它的 tag ,这个 tag 让我们区分 useEffect useLayoutEffect ,之后把它放到了 fiber 的updateQueue 之上
  • 他们的更新就是比较了我们传入的 deps 数组,根据他有没有变化来判定我们的 hook 是不是需要调用,之后把 effect 数据结构推入,如果我们不需要调用这个 hook,我们推入的数据结构将不会有一个 HookHasEffect 标记,那么它之后也不会执行
  • 副作用相关的 hook 的调用时机在 commit 阶段:
    • useLayoutEffect Mutation 这个阶段,执行我们删除节点和更新节点的销毁逻辑,也就是 destroy 函数,在 Layout 这个阶段,执行我们节点的副作用函数 create 逻辑并且挂载我们的 destroy 函数
    • useEffect 则是异步调用的,在 commit 阶段一开始就会执行一次它的处理,清空遗留下来的 hook;在进入 BeforeMutation 之前,我们会开启一个调度,执行我们的useEffect hook;在 commit 阶段的三个子阶段执行完毕后,我们把 effectList 给予 rootWithPendingPassiveEffects 这个变量,我们之前注册的任务也得以开始调度,从rootWithPendingPassiveEffects 获取 effectList 然后执行他们的处理

至此我终于把前面所有的坑全部填上了,顺便发现之前的 commit 阶段的讲解有所纰漏,我也已经进行了改正,之后我打算再讲讲一个常用的 useContext 全局配置的相关内容,然后总结我们的整个 React 18 的流程正式结束这个系列教程,也感谢大家一路的阅读

;