likes
comments
collection
share

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

作者站长头像
站长
· 阅读数 21

Note: 本文是基于 reacct@18.2.0 进行研究的成果。

本文也是《从源码学 API 系列》的 useEffect() 篇。更多文章,请查看我的《从源码学 API 系列》专栏。

⚠️:由于本文内容高度干硬,为了避免噎着,阅读本文的时候,请备好水杯在旁边。

API 简介

函数签名

useEffect(setup, dependencies?)

功能

useEffect is a React Hook that lets you synchronize a component with an external system.

react 新官网如是介绍 useEffect 的用途。「让你的组件跟外部系统保持同步」,这显然是第一个比较高层次的抽象概括,对于新手理解起来不是那么友好。

作为 react 老兵,我对它的理解是:useEffect 本质上是一个组件生命周期函数调度方法。通过它,我们可以把一个副作用类型的任务延迟到每一次组件更新(,将变更后的状态 commit 到屏幕上)之后去执行。

下面我们从源码视角来理解这个 hook 时代 react 至关重要的 API。

术语/数据结构

从源码角度去理解 useEffect 需要我们对某些术语和内部的数据结构有些具象的理解。下面,我们一一过一下这些术语和数据结构。

create/setup 函数

假如有以下的代码:

import {useEffect} from "react";

function App(){

    useEffect(function log(){
        console.log('App component Updated!')
    });
    
    return <div>hello world</div>
}

显然,我们可以看出,上面代码中的 log()函数在官方文档中称之为 setup 函数。但是实际上,在 react 的源码中,它是被称之为 create 函数。我们可以从 useEffect 的源码中窥见一斑:

// react-reconciler/src/ReactFiberHooks.old.js
function mountEffect(create, deps) {
    return mountEffectImpl(
      PassiveEffect | PassiveStaticEffect,
      HookPassive,
      create,
      deps,
    );
}
    
function updateEffect(create, deps) {
    return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
  • mount 阶段
  • update 阶段

上面给出的源码中,mountEffect 就是 useEffect mount 阶段的实现,而 updateEffect 就是 update 阶段的实现。无论是 mountEffect 还是 updateEffect, 从它们的函数声明中,我们都可以看到,我们用户传入到 useEffect函数的第一参数被称之为「create」。

本文中,我们使用的是源码角度的术语,即「create 函数」。

destroy/cleanup 函数

如果 useEffect 的 create 函数返回一个函数类型的值,那么这个值在 react 的新官网中称之为 cleanup 函数,而在源码中,它被称之为 destroy 函数。

 function commitHookEffectListMount(flags, finishedWork) {
    // ......
      do {
        if ((effect.tag & flags) === flags) {
          const create = effect.create;
          // create 的调用返回值就是 destroy 函数
          effect.destroy = create(); 
        }

        effect = effect.next;
      } while (effect !== firstEffect);
    }
  }

跟上面一样,本文中我们还是沿用源码中的术语,即「destroy 函数

effect 对象

我们用户传递给 useEffect 的 create 函数,以及 create 函数调用之后返回的 destroy 函数都是被收纳到 effect 对象(简称「effect」)上。它的数据结构如下:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

从上面的数据结构图中,我们可以看出,effect 其实是一条单向的链表 - effect.next -> effect。其实不止如此,effect 在源码内部是一条「循环」的单向链表,即单向链表的尾首是相连的。具体的实现代码是在 pushEffect() 函数内部:

function pushEffect(tag, create, destroy, deps) {
    const effect = {
      tag,
      create,
      destroy,
      deps,
      // Circular
      next: null,
    };

    // ......
    const firstEffect = lastEffect.next;
    lastEffect.next = effect;
    effect.next = firstEffect;
    // ......

    return effect;
}

假如,我们有下面的应用代码:

示例代码1

import {useEffect} from "react";

function App(){

    // effect1
    useEffect(function log1(){
        console.log('App component Updated1!')
    });
    
    // effect2
    useEffect(function log2(){
        console.log('App component Updated2')
    });
    
    // effect3
    useEffect(function log3(){
        console.log('App component Updated3!')
    });
    
    return <div>hello world</div>
}

上面代码中,我们从上到下依次写了三个 useEffect。那么,react 在hook函数的 mount/update 阶段就会调用 pushEffect 函数,目的是:

  1. 先创建 effect 对象
  2. 然后将它追加到当前的循环单向 effect 链表中

因此上面示例代码,最终会产生这样的一条 effect 链表:

next
next
next
effect1
effect2
effect3

综上所述,effect 即是一个 「js 对象」又是一条「循环单向链表」。

hook 函数

  • useState()
  • useEffect()
  • useXxx...

hook 对象

关于这个概念,上面提到的文章里面也提到过。现在我们简单复习一下它的数据结构:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

不同类型的 hook 函数所对应的 hook 对象都是遵循这个数据结构 - 即字段是一样的。唯一不同的是每个字段的取值不一样。现在,我们聚焦到 memoizedState 这个字段上来。相比于 useState 所对应的hook 对象的 memoizedState 值的类型是由用户所决定的,useEffect所对应的 hook 对象的 memoizedState 值就是上面所提到的 effect。关于这一点,在这里的源码是有体现的:

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
    // ......
    hook.memoizedState = pushEffect(
      HasEffect | hookFlags,
      create,
      undefined,
      nextDeps
    );
  }

  function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
    // ......
    hook.memoizedState = pushEffect(
      HasEffect | hookFlags,
      create,
      destroy,
      nextDeps
    );
  }

回翻一下上面的 pushEffect 函数的源码,你会发现,它的返回值就是 effect。因此,「useEffect所对应的 hook 对象的 memoizedState 值就是上面所提到的 effect」此话为真。

useEffect 什么时候/在哪里被调用?

我们的 useEffect hook 函数是 function component 中被调用的,要想搞清楚这个问题,那么我们就是要搞清楚「我们用户定义的 function component 是在哪里被调用的」。

note: function component 本质上就是一个真正的 js 函数,此观点在后文就不再复述了

react 应用无论是 mount 阶段还是 update 阶段,都是要走一遍:

  1. render 阶段
  2. commit 阶段

render 阶段的目标之一是 在 reconciliation 流程中构建一棵全新的 fiber 树,然后对比新旧两棵 fiber 树去找出需要更新的 fiber 节点,给这些 fiber 节点打上 effect flag。

构建全新 fiber 树是一个自上而下的过程。这里的公式是:workInProgress + nextChildren = nextWorkInProgress

在特定时刻,给定一个 workInProgress,我们会计算出用于创建下一个子 fiber 节点的原始物料 - nextChildren。然后,把这个nextChildren输入到 reconciliation 流程中。reconciliation 流程会返回一个新的 fiber 节点给我们。最后,我们就把这个新的 fiber 节点跟 workInProgress 用父子关系链接起来。

这个上面这个过程其实就是针对当前 workInProgress 执行 begin work 的过程,具体流程图如下:

function component 类型
class component 类型
host component 类型
输入 nextChildren
输入 nextChildren
输入 nextChildren
构建并返回
进入下一轮的 begin work
开始
对 workInProgress 开始 begin work
根据 workInProgress 不同的 fiber 类型来计算 nextChildren 的值
nextChildren = workInProgress.type()
nextChildren = workInProgress.type.render()
nextChildren = workInProgress.pendingProps.children
reconciliation 流程
child fiber
child.return = workInProgressworkInProgress.child = child
workInProgress = child

因为 hook 函数只能存在 function component 中,所以,当前计算 nextChildren 的「公式」就是:nextChildren = workInProgress.type()。熟悉 fiber 节点创建过程的都知道,fiber 节点的 type 属性值是来自于 react element 的 type 属性值。而 react element 的 type 属性值对于 function component 来说就是它本身。也就是说 workInProgress.type 就是我们用户定义的 function component 函数。

综上所述,我们的 hook 函数是在 render 阶段中的,对 「function component 所对应 fiber」 进行 begin work 的子阶段被调用的。

note: 「对 function component 所对应 fiber 进行 begin work 」这句话的意思是当前的 workInProgress fiber 指向的是 function component 所对应 fiber。再换句话说,在调用所有的 hook 函数的时候,function component 所对应 fiber 已经存在。它是在上一轮的 begin work 子阶段被创建出来的。

调用 useEffect 后发生了什么?

在上面,我们也已经提到,每个 hook 函数都会依次经历两个阶段:

  1. (组件的)mount 阶段
  2. (组件的)update 阶段

下面,我们分别阐述一下这两个阶段中,调用 useEffect 到底发生了什么。

mount 阶段

在这个阶段,useEffect 的调用栈火焰图如下:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

上面火焰图中所涉及的所有的函数的源码均在这个文件里面react@18.2.0/packages/react-reconciler/src/ReactFiberHooks.old.js

这调用过程中发生的事情,用流程图表示如下:

开始
1. 创建全新的 hook 对象
2. 把刚创建的 hook 对象追加到当前 fiber 节点的 hook 链表尾部
3. 创建全新的 effect 对象
4. 把刚创建的 effect 对象追加到当前 fiber 节点的 effect 链表尾部
5. 把刚创建的 effect 作为 hook 对象的 memoizedState 属性值存储起来
结束

下面,我们沿着这个流程来看看所涉及的源码。

第一和第二步

这两步骤发生在 mountWorkInProgressHook 函数里面:

function mountWorkInProgressHook() {

  // 1. 创建全新的 hook 对象
  const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };
 
  // 2. 把刚创建的 hook 对象追加到当前 fiber 节点的 hook 链表尾部
  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

值得解释一下的是,currentlyRenderingFiberworkInProgressHook 都是在组件函数作用域之上的全局变量。上面提到过,在调用当前组件函数之前,currentlyRenderingFiber 已经被创建出来,它是在上一次的 begin work 所创建的。workInProgressHook 初始值为 null。只有调用过一次 hook 函数后,它才不为 null

第三和第四步

这两步骤发生在 mountWorkInProgressHook 函数里面:

function pushEffect(tag, create, destroy, deps) {
 // 3. 创建全新的 effect 对象
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: null,
  };
  
  // 4. 把刚创建的 effect 对象追加到当前 fiber 节点的 effect 链表尾部
  let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = componentUpdateQueue;
    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;
}

在第一个 (effect 类型)hook 函数在被调用之前,currentlyRenderingFiber.updateQueue 的值为 null。所以,第一次 hook 函数调用就负责链表头部的创建。此后的其他 hook 函数的调用就简单追加到链表的尾部,同时完成首尾相连的工作即可。

第五步

mountWorkInProgressHook()会返回刚创建的 hook 对象,而 pushEffect() 会返回刚创建的 effect 对象。最后,用 memoizedState 指针(hook 对象属性)将两者链接起来。

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

小结

走完上面这个流程后,react 会在内存创建一条数据结构链。我还是拿「示例代码1」来进一步说明。示例中的 <App> 组件函数一旦被调用后,这条内存中的数据结构链是这样的:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

我们可得认真体会这张数据结构图。因为,它是理解后面内容的基础。

update 阶段

在这个阶段,useEffect 的调用栈火焰图如下:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

面火焰图中所涉及的所有的函数的源码也是在这个文件里面react@18.2.0/packages/react-reconciler/src/ReactFiberHooks.old.js

这调用过程中发生的事情,用流程图表示如下:

开始
1. 在上一个渲染周期的 hook 链中找到与当前位置编号对应的旧 hook 对象,对旧 hook 对象进行浅复制得到一个新的 hook 对象
2. 把刚创建的 hook 对象追加到当前 fiber 节点的 hook 链表尾部
3. 创建全新的 effect 对象
4. 比较当前的 deps 是否跟旧的 deps,是否相等?
5. 给 effect 对象打上 `hasEffect` 标签,表示当前的 effect 需要被执行
6. 把刚创建的 effect 对象追加到当前 fiber 节点的 effect 链表尾部
7. 把刚创建的 effect 作为 hook 对象的 memoizedState 属性值关联起来
结束

这里,我们重温一下 updateWorkInProgressHook 函数实现的功能即可:

  1. 在上一个渲染周期的 hook 链中找到与当前位置编号对应的旧 hook 对象;
  2. 然后对旧 hook 对象进行浅复制得到一个新的 hook 对象;
  3. 把刚创建的 hook 对象追加到当前 fiber 节点的 hook 链表尾部
  4. 返回刚创建的 hook 对象

不过,这里我们有必要指出,update 阶段调用 useEffect hook 函数跟在 mount 阶段的不同点。主要是三个不同点:

  • mount 阶段,hook 对象是全新的;而 update 阶段,hook 对象是对上一个渲染周期 hook 链中,与当前位置编号相同的那个 hook 对象进行浅拷贝得到的;

  • update 阶段,react 创建完 effect 对象后会有一个判断,判断是否需要给当前的 effect 对象贴上 hasEffect 标签。只有前后的 deps 比较结果为「不相等」的时候,才会被贴上这个标签。而 mount 阶段,effect 对象默认是有这个标签的。

    上面这里就就对应上了:

    • 在组件的 mount 阶段,useEffect 的 create 函数必须是被执行的;
    • 在组件的 update 阶段,useEffect 的 create 函数只有在依赖发生了变化的时候才会被执行

如果你关心前后 deps 的比较算法,那就可以看看 areHookInputsEqual 函数实现:

function areHookInputsEqual(nextDeps, prevDeps) {
    if (prevDeps === null) {
      return false;
    }

    for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
      if (objectIs(nextDeps[i], prevDeps[i])) {
        continue;
      }

      return false;
    }

    return true;
}

如果用户没有定义 shouldComponentUpdate() 方法,react 再来考虑当前组件是不是 PureComponent。如果是,则用浅比较的结果来决定当前组件是否需要更新。

这里实现浅比较所使用的核心算法也是 objectIs。它的源码路径是:react@18.2.0/packages/shared/objectIs.js。感兴趣的读者可以自行查阅,这里就不重复把源码粘贴出来了。

我们的 create 和 destroy 函数是怎样被调用?

到目前为止,我们知道我们的 useEffect hook 的 create 函数和 destroy 函数是在 render 阶段被存放在 effect 对象上的。那下一步我们不禁要问:「那它们是在哪里被调用的呢」?

在为这个问题寻找答案之前,我们先来澄清一下 destroy 函数存在性问题,即:在 hook 的 mount 阶段,我们创建 effect 对象的时候,destroy 函数是不存在的。因为,destroy 函数本来就是 create 函数的返回值。而此时,create 函数还没有被调用呢。所以,关于这一点,我们也不难理解。不过,我们还是拿源码来佐证一下:

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // ......
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined, // 这里就是 destroy 函数形参的位置。
    nextDeps,
  );
}

pushEffect 函数的第三个参数就是 destroy。从上面可以看出,mount 阶段,它是被显式赋值为 undefined的。

而我们知道,组件 mount 阶段过后, create 函数是一定会被调用的。所以,我们也可以推理得出,在组件的 update 阶段,effect 对象的 create 函数和 destroy 函数肯定是存在的(现在我们假设用户一定定义 destroy 函数 )。

好,言归正传。我也不卖关子。对于上面我们提出的问题,答案是:react 会在 commit 阶段去调用我们的create 函数和 destroy 函数。因为 commit 阶段又可以分为三个子阶段:

  • beforeMutation
  • mutation
  • layout

更具体点讲是在 commit 阶段的 mutation 子阶段之后去调用。

回到源码的视角,create 函数和 destroy 函数调用具体是发生在 commit 阶段的入口函数 commitRootImpl内部,而真正的调用入口函数为 flushPassiveEffects

为什么 useEffect 所产生的 effect 称之为「passiveEffect」? 我们可以借鉴 stackoverflow 上的这个说法。即,鉴于 useEffect 的作用是监听状态的变化,然后被动地执行的特征是类似于原生 web UI 事件中的 {passive: true} 的特性,react 也借鉴了这种概念。

该函数在 commitRootImpl 出现了三次。也就是说,create 函数和 destroy 函数调用存在三个入口:

  1. 处理由于调用 flushSyncUpdateQueue() 所衍生的 effect:
  function commitRootImpl(
    root,
    recoverableErrors,
    transitions,
    renderPriorityLevel
  ) {
    do {
      flushPassiveEffects();
    } while (rootWithPendingPassiveEffects !== null);
    
    // .....
  }
  1. 在 beforeMutation 子阶段之前的对执行 effect 进行异步调度:
  function commitRootImpl(
    root,
    recoverableErrors,
    transitions,
    renderPriorityLevel
  ) {
     if (
      (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
      (finishedWork.flags & PassiveMask) !== NoFlags
    ) {
      if (!rootDoesHavePassiveEffects) {
        // .......
        scheduleCallback$2(NormalPriority, () => {
          flushPassiveEffects(); 
          return null;
        });
      }
    }
    
    if (subtreeHasEffects || rootHasEffect) {
       // beforeMutation 子阶段
       const shouldFireAfterActiveInstanceBlur = commitBeforeMutationEffects(
        root,
        finishedWork
      );
      
      // ......
    }
    // .....
  }
  1. 在 layout 子阶段完成之后的同步调用:
  function commitRootImpl(
    root,
    recoverableErrors,
    transitions,
    renderPriorityLevel
  ) {
     // ......
     
    if (subtreeHasEffects || rootHasEffect) {
       // ......
       // layout 子阶段
      commitLayoutEffects(finishedWork, root, lanes);
      // ......
    }
    
    // ......
    
   if (
      includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
      root.tag !== LegacyRoot
    ) {
      flushPassiveEffects();
    } 
    
    // .....
  }

note: 本人使用简单的 react 应用来测试,得到的结果是:1)在组件的 mount 阶段,react 会走第二个入口;2)在组件的 update 阶段,react 会走第三个入口

谈论哪种情况会进入哪个入口,暂时不在本文的讨论范围。不过,不管是从哪个入口进入,create 函数和 destroy 函数调用都是发生了 DOM 更新之后;不管是从哪个入口进入,它们都是走同一个调用栈:

flushPassiveEffects() 调用栈火焰图 全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

因为调用 destroy 和 调用 create 函数是分开的。所以,我们需要将两者分开来讨论。但是由于两者的调用逻辑几乎是一样的。所以,在这里,我们以调用 destroy 函数为例即可,create 函数的调用原理跟这个是一样的。

通过源码的阅读和调试,我将「调用 destroy 函数的过程」划分为两个步骤:

  1. 遍历 fiber 树 - 深度优先遍历 fiber 树,找到身上有 effect 的 fiber 节点
  2. 遍历 effect 链表 - 遍历当前的 effect 链表,根据当前 effect 是否满足特定的条件(是否包含特定标签)来确定是否要调用 destroy 函数。

1. 遍历 fiber 树

首先,我们需要明白,一个 react 应用对应的 fiber 树是巨大的。在这棵巨大的 fiber 树上,掺杂着各种类型的 fiber 节点:

  • hostRoot
  • hostComponent
  • functionComponent
  • classComponent
  • ......

我们也知道,所有的 (useEffect) hook 函数只能用 function component 里面使用。而 fiber 上可能存在多个 function component 类型的 fiber 节点使用了 hook 函数。所以,聪明的你也猜到了,这一步中,遍历 fiber 树的目的就是要找到消费了 hook 函数的 fiber 节点。在这一步之前,react 其实是有做一些前置工作的。那就是:在 render 阶段,react 会向上追溯当前消费了 hook 函数的 fiber 节点的所有的祖先 fiber 节点,一一给它们的 subtreeFlags 属性值加入一个 Passive 标签。这种机制类似于浏览器的事件冒泡。浏览器的事件起源于特定的 DOM 节点,但是它会在冒泡阶段向上传播到 document 这个根节点。

回到 useEffect 这个主题。上面所提到的前置工作中,有一点特别需要注意的点是:如果「消费了 hook 函数的那个 fiber 节点」的子树之下没有其他消费了 hook 函数的 fibe 节点,它自己的 subtreeFlags 属性值是不会被贴上一个 Passive 标签的。这也不难理解,因为这恰恰是符合 subtreeFlags 属性名的语义的。

下面举个例子。假设我们现在有这样的一颗 fiber 树:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

然后,假设只有我们的 <Counter /> 组件里面消费到 useEffect 这个 hook 函数。那么,在 render 阶段,经过上面所提到的前置工作后,我们会得到这样的一颗 fiber 树:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

好了,做完上面的前置工作后,react 会在 commit 阶段完成之后的 passive 阶段去遍历 fiber 树。这个遍历工作是从 fiber 树的根节点 - hostRootFiber 开始的。

源码中的 commitPassiveUnmountOnFiber()recursivelyTraversePassiveUnmountEffects()commitHookPassiveUnmountEffects() 这三个函数共同完成了这个过程。而这三个函数是有分工的:

  • commitPassiveUnmountOnFiber() - 是遍历流程的入口
  • recursivelyTraversePassiveUnmountEffects() - 负责实现了以深度优先的算法的「递」与「归」
  • commitHookPassiveUnmountEffects() - 在「归」之前,尝试去调用当前 fiber 节点 effect 链表上的所有的 destroy 函数。

如果我们只关注 effect 调用流程,把这三个函数浓缩到一块,写成一个函数,那么结果是这样的:

function my_commitPassiveUnmountOnFiber(parentFiber){
    
    
    if (parentFiber.subtreeFlags & PassiveMask) {
         const child = parentFiber.child;
         
         while (child !== null) {
            my_commitPassiveUnmountOnFiber(child);
            child = child.sibling;
         }
    }
     
     const isfunctionComponentKind = [FunctionComponent, ForwardRef, SimpleMemoComponent].includes(parentFiber.tag);
     const hasPassiveEffect = parentFiber.flags & Passive
     
     if(isfunctionComponentKind && hasPassiveEffect){
          commitHookPassiveUnmountEffects(
            parentFiber,
            parentFiber.return,
            Passive | HasEffect
          );
     }
  }

将上面的代码转换为流程图,结果如下:

赋值给
继续深度优先遍历
继续深度优先遍历
开始
hostRootFiber
parentFiber
以当前 parentFiber 为根节点的子树上是否有身上贴有 `Passive` 标签的 fiber 节点?
当前 parentFiber 节点自身是否被打上 `Passive` 标签,并且是 function-component-like 类型?
当前 fiber 节点是否是叶子节点
parentFiber = parentFiber.child
对当前 parentFiber 节点调用 `commitHookPassiveUnmountEffects()`
结束
当前 parentFiber 是否有兄弟 fiber ?
parentFiber = parentFiber.sibling

其实熟悉 react 内部原理的人知道,这种「先深度,后广度」的遍历算法跟 render 阶段的 「work-loop」的遍历算法是一模一样的。唯一的一个区别点有两点:

  • 它深度优先遍历不一定会遍历到当前子树路径的叶子节点。它会因为不满足「以当前 parentFiber 为根节点的子树上是否有身上贴有 Passive 标签的 fiber 节点?」这个条件提前终止了;
  • 跟 render 阶段的 「work-loop」的遍历算法中,归去之前必定会执行 complete work 不同,当前的遍历算法会先检查是否满足条件,如果不满足,则不执行 “complete work” 。显然,这里的 “complete work” 是指 commitHookPassiveUnmountEffects() 的调用。

2. 遍历 effect 链表

上面,我们已经介绍了 react 是如何了遍历整棵 fiber 树,找到那些所有需要执行 effect 的 fiber 节点。上一步骤末尾,我们也指出了,执行 effect 的 fiber 节点的函数是 commitHookPassiveUnmountEffects()。而 commitHookPassiveUnmountEffects()会直接调用commitHookEffectListUnmount()。顾名思义,该函数就是 react 遍历 effect 链表去调用 destroy 函数的所在。

effect 链表并不是只存储 useEffect hook 函数的 effect 对象

这里值得指出的是,一个 fiber 的 effect 链表其实包含了所有的 effect 类型的 hook 函数所产生的 effect 对象,并不是特意为 useEffect hook 函数而准备的。

那这里所提到的 「effect 类型的」hook 函数有哪些呢?从 react@18.2.0 的源码来看,它包括了5 种类型的 hook 函数:

  • useEffect()
  • useLayoutEffect()
  • useInsertionEffect()
  • useSyncExternalStore()
  • useImperativeHandle()

提出这个结论的依据是: 这些 hook 函数的调用栈最终都是指向了 pushEffect() 函数。如下图:

mount 阶段

useEffect
mountEffect
mountEffectImpl
pushEffect
useLayoutEffect
mountLayoutEffect
useInsertionEffect
mountInsertionEffect
useSyncExternalStore
mountSyncExternalStore
useImperativeHandle
mountImperativeHandle

update 阶段

useEffect
updateEffect
updateEffectImpl
pushEffect
useLayoutEffect
updateLayoutEffect
useInsertionEffect
updateInsertionEffect
useSyncExternalStore
updateSyncExternalStore
useImperativeHandle
updateImperativeHandle

pushEffect() 函数就是负责创建新的 effect 对象,把它追加到当前 fiber 节点 effect 链表的尾部。可见,如果一个 function component 都消费了上面这些 hook 函数的话,那么它所对应的 fiber 节点的 effect 链表上并不是单纯包含 useEffect hook 函数所产生的 effect。

回到正题。上面我们指出了 commitHookEffectListUnmount函数就是 react 遍历 effect 链表去调用 destroy 函数的所在,下面来看看它的源码:

  function commitHookEffectListUnmount(
    flags,
    finishedWork,
    nearestMountedAncestor
  ) {
    const updateQueue = finishedWork.updateQueue;
    const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

    if (lastEffect !== null) {
      const firstEffect = lastEffect.next;
      let effect = firstEffect;

      do {
        if ((effect.tag & flags) === flags) {
          // Unmount
          const destroy = effect.destroy;
          effect.destroy = undefined;

          if (destroy !== undefined) {
            safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
          }
        }

        effect = effect.next;
      } while (effect !== firstEffect);
    }
  }
  
function safelyCallDestroy(current, nearestMountedAncestor, destroy) {
    try {
      destroy();
    } catch (error) {
      captureCommitPhaseError(current, nearestMountedAncestor, error);
    }
}

有了上面的铺垫,我们很快就能看明白这段代码的意思。react 通过 do{...}while() 循环来遍历 effect 链表。我们在上面也提到过,effect 链表其实是单向的循环链表。所以,当前即将需要遍历的 effect 对象又指会了第一个 effect的时候,意味着我们已经遍历完了整条链表,可以退出循环了。

我们暂时把判断条件(effect.tag & flags) === flags的理解放一放。继续往循环体的代码看看。可以看到,只要用户 useEffect hook 的 create 函数有返回值,react 就会帮我们把它当做 destroy 函数去调用。

至此,我们算是看到 react 真正去调用我们的 useEffect hook 函数的 destroy 函数了。

小结

上面只是介绍了 useEffect hook 的 destroy 函数调用流程。而 useEffect hook 的 create 函数调用流程的原理是一样的。它整个流程结束的前一站是 commitHookEffectListMount() 函数。看到这个函数名跟 commitHookEffectListUnmount 函数名长得很像。区别只是在于 xxxMountxxxUnmount 的区别。从这种差异和对比性,我们就知道,这里肯定是调用 useEffect hook 的 create 函数的地方:

function commitHookEffectListMount(flags, finishedWork) {
    const updateQueue = finishedWork.updateQueue;
    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);
    }
}

从这行代码 effect.destroy = create();,我们可看出了 create 函数跟 destroy 函数是怎么流转的:上一次 create 函数的调用结果就是下一次需要调用的 destroy 函数。

最后,一图胜千言,以一张带有注释的调用栈火焰图结束本小节:

flushPassiveEffects() 调用栈火焰图2 全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

还记得,我们上面提到了「我们暂时把判断条件(effect.tag & flags) === flags的理解放一放」这句话吗?如果想了解,react 是怎么通过这个判断条件去匹配到我们需要调用的 useEffect effect,再从而去调用里面的 destroy/create 函数的,可以进入下一小节一探究竟。

如何决定是否要调用 create 和 destroy 函数?

两个标签:PassiveHasEffect

下面,我们将会反复提到两个标志位: PassiveHasEffect, 它的源码在这里:

// react@18.2.0/packages/react-reconciler/src/ReactHookEffectTags.js
export type HookFlags = number;

export const NoFlags = /*   */ 0b0000;

// Represents whether effect should fire.
export const HasEffect = /* */ 0b0001;

// Represents the phase in which the effect (not the clean-up) fires.
export const Insertion = /*  */ 0b0010;
export const Layout = /*    */ 0b0100;
export const Passive = /*   */ 0b1000;

上面的注释也写得很清楚了。它们的含义是:

  • HasEffect - 如果某个 effect 身上包含 HasEffect 这个标签,则代表这个 effect 需要被执行的;
  • Passive - 而 Passive 则表示,如果要执行的话,当前的 effect 是在「passive」这个阶段去执行。

从源码的注释来看,整个界面更新流程似乎应该多一个阶段?比如:render -> commit -> passive ?

两个步骤

众所周知,react 并不是每一次在界面更新后都会去执行 effect 的 create 和 destroy 函数的。那 react 是如何决定是否要调用 create 和 destroy 函数的呢?我将这个逻辑过程分为两个步骤:

  1. 贴 hook flag - render 阶段,给 useEffect 所产生的 effect 贴上 hook flag 「 PassiveHasEffect」;
  2. 遍历 effect 链表的时候,检查 hook flag - 遍历 effect 链表上的每一个 effect,如果有这两个标签,则是我们的目标 effect,去执行它的 create 和 destroy 函数。

第一步:贴 hook flag

useEffect 所产生的 effect 对象会在 render 节点打上 Passive flag,代表着它应该在 commit 的 passive 子阶段(或者理解为「commit 阶段之后的阶段」)去执行。

无论是根据官方文档的介绍还是我们日常对 useEffect API 的使用反馈来看,我们都知道,只有 effect 所依赖的 deps 发生了变化,react 才会调用我们的 effect。这背后所对应的代码逻辑是:react 同样会在 render 阶段,根据前后 deps 比较的结果来跟 effect 打标签。如果不相等,则会打上表示这个 effect 需要被执行的 flag HasEffect。具体的打标签的动作是发生在 mount 阶段的mountEffectImpl() 和 update 阶段的 updateEffectImpl()函数里面。

mount 阶段的打标签

  function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
    // ......
    hook.memoizedState = pushEffect(
      HasEffect | hookFlags,
      // ......
    );
  }

而 hookFlag 实参是在调用 mountEffectImpl 它的时候传入的:

   function mountEffect(create, deps) {
    {
      return mountEffectImpl(Passive | PassiveStatic, Passive, create, deps);
    }
  }

可以看出,调用 mountEffectImpl() 的时候在 hookFlags 的位置是直接传递 Passive 这个标签进入的。也就是说, mount 阶段之后,挂载在 effect 链上的所有由 useEffect 所产生的 effect 对象的 tag 属性都包含了这两个 flag:PassiveHasEffect

update 阶段的打标签

基本上是跟 mountEffectImpl() 函数实现一样,只不过多了一个比较前后 deps 是否相等的逻辑:

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
   // ......
  if (currentHook !== null) {
    // ......
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 如果前后的 deps 的比较结果是相等的,则不打上 `HasEffect` 标签
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  // ......
  
  hook.memoizedState = pushEffect(
    HasEffect | hookFlags,
    // ......
  );
}

而 hookFlag 实参是在调用 updateEffectImpl 它的时候传入的:

function updateEffect(
  create,
  deps,
) {
  return updateEffectImpl(PassiveEffect, Passive, create, deps);
}

第二步:遍历 effect 链表的时候,检查 hook flag

上面是预备工作。react 会在 commit 阶段真正执行 effect 之前做一个检查:只有当前 effect 同时具有 PassiveHasEffect 这两个 hook flag 的时候,react 才会调用它的 create 函数或者 destroy 函数。具体的代码均出现在 commitHookEffectListUnmount()commitHookEffectListMount() 函数里面:

function commitHookEffectListUnmount(
flags,
finishedWork,
nearestMountedAncestor
) {
// ......
do {
    if ((effect.tag & flags) === flags) {
      // Unmount
      const destroy = effect.destroy;
      effect.destroy = undefined;

      if (destroy !== undefined) {
        safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
      }
    }

    effect = effect.next;
    } while (effect !== firstEffect);
}

function commitHookEffectListMount(flags, finishedWork) {
// ......
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);
    }
}

下面,我们文字来翻译一下判断条件 if ((effect.tag & flags) === flags)。这个条件的意思是:「当effect.tag上存储的标志位是 flags存储的标志位的「父集」的时候,条件判断的结果为 true 」。本文是讨论 useEffect,这个判断条件则可以收窄表达为:「当effect.tag上存储的标志位同时包含 PassiveHasEffect ,条件判断的结果为 true 」。

到这里,我们还剩下一个疑问:「commitHookEffectListMount() 或者 commitHookEffectListUnmount() 接收到的 flags 实参是来自哪里的呢?」。

针对,commitHookEffectListUnmount(),答案是在 commitPassiveUnmountOnFiber() 函数内部调用 commitHookPassiveUnmountEffects() 时候传递的:

function commitPassiveUnmountOnFiber(finishedWork) {
    switch (finishedWork.tag) {
      case FunctionComponent:
      case ForwardRef:
      case SimpleMemoComponent: {
        // ......

        if (finishedWork.flags & Passive) {
          commitHookPassiveUnmountEffects(
            finishedWork,
            finishedWork.return,
            Passive | HasEffect // 这这,看到了没?
          );
        }

        break;
      }
      // .......
}

针对,commitHookEffectListMount(),答案是在 commitPassiveMountOnFiber() 函数内部调用 commitHookPassiveUnmountEffects() 时候传递的:

  function commitPassiveMountOnFiber(
    finishedRoot,
    finishedWork,
    committedLanes,
    committedTransitions
  ) {
    const flags = finishedWork.flags;

    switch (finishedWork.tag) {
      case FunctionComponent:
      case ForwardRef:
      case SimpleMemoComponent: {
        // ......

        if (flags & Passive) {
          commitHookPassiveMountEffects(finishedWork, Passive | HasEffect); // 这这,看到了没?
        }

        break;
      }
      // .......
}

create 函数和 destroy 函数的调用先后顺序?

源码即真理。在上面的「我们的 create 和 destroy 函数是怎样被调用?」小结中,我们给出了一张火焰图:「flushPassiveEffects() 调用栈火焰图2」。这张火焰图中,我们可以得到如下信息:

  1. 先调用 commitPassiveUnmountEffects - 递归遍历 fiber 树上,找到所有需要执 effect 的 fiber 节点。在「归」的时候去遍历当前 fiber 的 effect 链表,选择那些需要 执行的 useEffect 类型的 effect,最后去调用它的 destroy 函数;
  2. 再调用 commitPassiveMountEffects - 再遍历递归遍历一遍 fiber 树上,找到所有需要执 effect 的 fiber 节点。在「归」的时候去遍历当前 fiber 的 effect 链表,选择那些需要 执行的 useEffect 类型的 effect,最后去调用它的 destroy 函数;

看得出,react 是分开两个批次来调用所有的 create 和 destroy 函数的 - 所有 function component 的所有 destroy 函数放在一批里面;所有 function component 的所有 create 函数放在另外一批里面。

1,先是调用所有 function component 里面的所有需要执行的 useEffect effect 的 destroy 函数; 2. 再去从头遍历一遍,调用所有 function component 里面的所有需要执行的 useEffect effect 的 create 函数;

有了上面的认知,我们可以把本节的大问题划分为几个小问题来一一回答。

同一个 useEffect hook 函数所产生的 create 函数和 destroy 函数的调用顺序

答:“从理论上说,永远是先执行 destroy 函数 再执行 create 函数。”

但是从结果来说,由于 react 应用的 mount 阶段中的 commit 阶段 create 函数还没有被调用,所以,根本就没有 destroy 函数。所以,mount 阶段中,只会执行 create 函数。

同一个组件内的不同 useEffect hook 函数所产生的 effect的调用顺序

同一批次里面,同一个组件内的不同 useEffect hook 函数所产生的 effect会在 render 阶段按照代码的书写顺序推进到当前组件所对应的 fiber 节点的 effect 链表里面。

然后,在调用的时候,react 也是按照 effect 入队顺序来调用的。所以,同一个组件内的不同 useEffect hook 函数所产生的 effect(destroy/create)是按照其所关联的 useEffect hook 函数的代码书写顺序来调用的。

跨组件层级组件内的 useEffect hook 函数所产生的 effect()的调用顺序

为了让问题变得更具体,我们给出示例代码:

import {useEffect} from "react";

function Child(){
    useEffect(function childEffect(){
        console.log('Child effect been called');
    });
    
    return null
}

function Sibling(){
    useEffect(function siblingEffect(){
        console.log('Sibling effect been called');
    });
    
    return null;
}

function App(){
    useEffect(function appEffect(){
        console.log('App effect been called');
    });
    
    return (
        <div>
             <Child />
             <Sibling />
        </div>
    )
}

估计大部分人都能够从日常开发的代码试验中知道答案。答案是:

1. Child effect been called
2. Sibling effect been called
3. App effect been called

但是,你又能不能从源码的角度去说出这个顺序背后的原因呢?

如果,你有认真消化和理解本文,相信你是可以的。这个调用顺序的背后是跟 react 所采用的递归遍历算法有关:「先深度优先,后广度优先」。示例 react 应用所对应的 fiber 树如下:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

用文字描述整个递归遍历过程是这样的:

  1. <App> 的递;
  2. <div> 的递;
  3. <Child> 的递;
  4. <Child> 的归;
  5. <Sibling> 的递;
  6. <Sibling> 的归;
  7. <App> 的归。

在归时候,react 会在当前 fiber 节点上调用 commitHookEffectListMount() 函数,所以,调用结果是:

  1. <Child> 身上调用 commitHookEffectListMount()
  2. <Sibling> 身上调用 commitHookEffectListMount()
  3. <App> 身上调用 commitHookEffectListMount()

effect 的 create 函数和 destroy 函数之间的流转

上面「术语/数据结构」小节,我们也提到了 destroy 函数是由调用 create 函数所产生的。具体这一动作是发生在 commitHookEffectListMount() 函数里面:

function commitHookEffectListMount(flags, finishedWork) {
    // ......
    effect.destroy = create();
    // ......
}

在这个函数里面,react 把 create 函数取出来,然后调用它,最后把调用结果赋值给 effect 的 destroy 属性。这里就产生了 destroy 函数。

上面也提到了,当前渲染周期的 destroy 函数是由上一次渲染周期的 create 函数所得。那么,上一次,我们是放在 effect 对象上的 destroy 函数 是怎么流转到下一次渲染周期的 effect 上的呢?

要想理解这个问题的答案,我们可能需要理解一个 react 的两颗 fiber 树的数据结构模型,即所谓的 「double buffering」。「这个技术是来自游戏开发领域成果」,react core team 成员 Andrew Clark 在 twitter 上如是说道:

全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

Andrew Clark。在源码的这里 react@18.2.0/packages/react-reconciler/src/ReactFiber.old.js,也有交代:

We use a double buffering pooling technique because we know that we'll only ever need at most two versions of a tree. We pool the "other" unused node that we're free to reuse. This is lazily created to avoid allocating extra objects for things that are never updated. It also allow us to reclaim the extra memory if needed.

总而言之,在 react 应用的 update 阶段的 render 阶段,react 要做的就是基于已经存在「current tree」和 「react element tree」去构建一棵 「work-in-progress tree」。特定于构建/填充 work-in-progress tree 身上的某个特定 fiber 节点的时候,react 使用是需要复用(这里的复用大部分来说是指「浅拷贝」)该 fiber 节点的上一次的构建成果的部分东西。对于 function component 所对应的 fiber 节点,上次构建好的 hook 链表就是复用的东西之一。render 阶段调用组件函数实际上导致了下面流程:

开始
update 阶段的 render 子阶段中,组件函数被调用
`useEffect` hook 函数被调用
`updateWorkInProgerssHook()` 被调用
浅拷贝上一次渲染周期该位置所对应旧 hook 对象
意味着旧 hook 对象的 `memoizedState` 是按引用传递
从上面所提的数据结构,我们知道:对于 `useEffect` hook 来说,它的 `memoizedState` 属性值就是 effect 对象
于是在这里,我们能取回上一次渲染周期结束之际放在 effect 身上的 destroy 函数
继续按引用传递,把这个 destroy 函数传给 `pushEffect()` 来创建新的 effect 对象
最后,在 commit 阶段结束后会调用 destroy 函数。而这个函数就是上一个渲染周期一路按引用传递传过来的 destroy 函数
结束

这里重点解释一下,上面流程图所提到的「浅拷贝上一次渲染周期该位置所对应旧 hook 对象」是怎么做到的。首先,在调用组件函数之前,当前 function component 所对应的 fiber 节点已经被创建好了。因为 react 的「double buffering」实现方式,创建好了的 fiber 节点是通过它的 alternate 属性来访问到上一个渲染周期的 fiber 节点(「上一个渲染周期」对应 react 源码中 “current” 的概念)。通过上一个渲染周期的 fiber 节点也就能访问到上一个渲染周期的 hook 链表。在 react 内部, updateWorkInProgerssHook() 函数作用域之上的作用域,react 有维护一个全局指针,用户调用一次 hook 函数,它就往下走一格。故而,我们也能够通过这个指针找到当前这个位置所对应的旧的 hook 对象。

总结

useEffect 什么时候/在哪里被调用?

在 render 阶段被调用。更准确地说,是在 render 阶段中,对 useEffect hook 函数所在的 fiber 节点进行 begin work 的时候。

调用 useEffect 后发生了什么?

先创建一个新的 effect 对象。然后,判断是不是 mount 阶段或者 update 阶段前后 deps 发生了变化,则给 effect 对象贴上 PassiveHasEffect 两个标签。

最后, 把这个 effect 对象追加到 effect 链表的尾部并赋值给新创建的 hook 对象的 memoizedState 属性。

我们的 create 和 destroy 函数是怎样被调用?

所有的 create 和 destroy 函数的都是在 commit 阶段 layout 子阶段结束之后被调用的:

  • 要么被调度调用
  • 要么同步调用

调用的时候,react 是分开两个批次来调用所有的 create 和 destroy 函数的:

  • 所有 function component 的所有 destroy 函数放在一批里面;
  • 所有 function component 的所有 create 函数放在另外一批里面。
  1. 先是递归遍历 fiber 树,调用所有 function component 里面的所有需要执行 useEffect effect 的 destroy 函数;
  2. 再同样的算法再遍历一次 fiber 树,调用所有 function component 里面的所有需要执行 useEffect effect 的 create 函数;

effect 的 create 函数和 destroy 函数之间的流转

在上一个渲染周期的 commit 阶段结束后会调用 create 函数,返回的的新的函数实例重新放回 effect 对象上。在下一个渲染周期的 render 阶段的对当前 function component 进行 begin work 的时候 ,通过防蚊链 workInProgress.alternate -> current fiber -> hook 链表 -> 旧 hook 对象 -> 旧 hook 对象的 memoizedState -> destroy 函数 来找到 destroy 函数,按照引入传递传递给 pushEffect(), 参与了新 effect 对象和新的 effect 链表的构建,从而在当前的渲染周期的 commit 阶段结束后被调用。如此的循环往复。