从源码学 API 系列之 useLayoutEffect()

303 阅读14分钟

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

阅读本文之前,请建议阅读 《全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一

API 签名

useLayoutEffect(setup, dependencies?)

API 功能

useLayoutEffect is a version of useEffect that fires before the browser repaints the screen.

新官网如是说道。因为 useEffect hook 的 create 和 destroy 函数调用是发生在界面被重绘之后,而useLayoutEffect hook 的 create 和 destroy 函数调用是发生在界面被重绘之前。所以,react 官方文档将 useLayoutEffect 称之为「浏览器重绘前」版本的 useEffect

其实,这句话还是过于笼统的。不过,这符合 react 官方文档的一贯的风格 - 对实现细节和原理只字不提。不过,没关系。我们在下面的《useLayoutEffect() 的应用》小节进行具体的讨论。

API 关切

我们的 useLayoutEffect() 在哪里被调用?

虽然在《全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一》(后文中简称为「useEffect() 篇」)一文中针对的是 useEffect() 这个 hook 函数做的讨论。但是,其实在该文章中的该章节中,我们已经回答了一个关于所有 hook 函数的终结问题之一,即:「我们编写的 hook 函数是在 react 内部的哪里被调用的?」。

答案是:“用户编写的 hook 函数是在其所在的函数组件所关联的 fiber 节点进行 begin work, 进入 reconciliation 流程之前调用的”。

既然上面说的是所有的 hook 函数,那么,这里当然是包括了 useLayoutEffect() 这个 hook 函数了。

调用之后发生了什么?

在《全网最新,最全面,也是最深入剖析 useEffect() 原理的文章, 没有之一》一文中「effect 链表并不是只存储 useEffect hook 函数的 effect 对象」小节中其实隐式地指出了一个 react 实现,即,所有的「effect 类型的 hook 函数」是共用同一个架构的。这句话可以拆解为两点:

  • 它们所产生的 hook 对象储存在 fiber 节点的同一条 hook 链表
  • 它们所产生的 effect 对象储存在 fiber 节点的同一条 effect 链表

我们从所有的 effect 类的 hook 函数的调用栈架构图可以窥见一斑:

mount 阶段

flowchart TD
    useEffect-->mountEffect
    mountEffect-->mountEffectImpl
    mountEffectImpl-->pushEffect
    useLayoutEffect -->mountLayoutEffect-->mountEffectImpl
    useInsertionEffect-->mountInsertionEffect
    mountInsertionEffect-->mountEffectImpl
    useSyncExternalStore-->mountSyncExternalStore-->mountEffect
    useImperativeHandle-->mountImperativeHandle-->mountEffectImpl

update 阶段

flowchart TD
    useEffect-->updateEffect-->updateEffectImpl-->pushEffect
    useLayoutEffect -->updateLayoutEffect-->updateEffectImpl
    useInsertionEffect-->updateInsertionEffect-->updateEffectImpl
    useSyncExternalStore-->updateSyncExternalStore-->updateEffect
    useImperativeHandle-->updateImperativeHandle-->updateEffectImpl

从上面的调用栈架构图中,我们可以看到,其实所有的 effect 类型的 hook 函数

  1. 在 mount 阶段都会进入mountEffectImpl() --> pushEffect()调用链;
  2. 在 update 阶段都会进入updateEffectImpl() --> pushEffect()调用链。

抛开 mountEffectImpl()updateEffectImpl() 在实现细节上的些许不同,其实两者所实现的功能是大致一样的。我们可以它将所提供的功能简单概括为:

  • 创建 hook 对象,追加到当前 hook 链表的尾部;

pushEffect() 函数所提供的功能也可以简单概括为:

  • 创建 effect 对象,追加到当前 effect 链表的尾部;

综上所述,在 react 内部中,当我们的 hook 函数被调用之后,依次发生了三件事情:

  1. 创建 hook 对象,追加到当前 hook 链表的尾部;
  2. 创建 effect 对象,追加到当前 effect 链表的尾部;
  3. 把当前所创建的 hook 对象跟当前创建的 effect 对象关联起来 - effect 对象存储在 hook 对象的 memoizedState 属性上

上面的阐述针对的是所有的 effect 类型的 hook 函数。故,也包括了本文中我们所讨论的 useLayoutEffect() 函数。

useLayoutEffect()useEffect 在本节主题上的不同

不过,在本文中,我们值得强调一下 useLayoutEffect() 函数跟 useEffect hook 函数 的不同。两者的不同之处在于两点:

  • 它们给「自己所创建的 effect 对象」所贴的 effect flag 是不同的;
  • 它们给「自己所关联的 fiber 节点」所追加的 fiber flag 的不同的。

所贴的 hook flag 不同

对于 useLayoutEffect() hook 函数而言,它给自己所创建的 effect 对象所贴的 effect flag 是 Layout,而 useEffect() hook 函数给自己所创建的 effect 对象所贴的 effect flag 是 Passive

而对于另外一个 flag HasEffect, 两者所关联的逻辑则完全是相同的,属于相同点。

所追加的 fiber flag 不同

对于 useLayoutEffect() hook 函数而言,它给自己所关联的 fiber 节点所追加的 fiber flag 是:

  • mount 阶段是 Passive | PassiveStatic
  • update 阶段是 Passive

下面的源码可为佐证:

function mountLayoutEffect(create, deps) {
    let fiberFlags = Update | LayoutStatic;

    return mountEffectImpl(fiberFlags, Layout, create, deps);
}

useEffect() hook 函数给自己所关联的 fiber 节点所追加的 fiber flag 是:

  • mount 阶段是 Update | LayoutStatic
  • update 阶段是 Update

下面的源码可为佐证:

 function updateLayoutEffect(create, deps) {
    return updateEffectImpl(Update, Layout, create, deps);
}

关于上面所提到的 effect flag 的语义,可以到源码这里查看:react@18.2.0/packages/react-reconciler/src/ReactHookEffectTags.js

关于上面所提到的 fiber flag 的语义,可以到源码这里查看:react@18.2.0/packages/react-reconciler/src/ReactFiberFlags.js

其实上面提到的两个不同点适用于所有的 effect 类型的 hook 函数之间的比较。也就说,在它们所共用的同一套框架之上,彼此的差异点就是「给 effect 对象所贴的 effect flag」和 「给所关联的 fiber 节点所追加的 fiber flag」是不同的。

在哪里/怎样调用 useLayoutEffect() hook 的 destroy 和 create 函数?

useEffect() hook 不同的是,useLayoutEffect() hook 的 destroy 和 create 函数的调用并不是发生在同一个 commit 子阶段的。

对于 destroy 函数而言,它的调用是发生在「mutation 子阶段」;而对于 create 函数而言,它的调用是发生在 「layout 子阶段」

mutation 子阶段调用 destroy 函数

众所周知,mutation 子阶段主要的目的是实施真正的 DOM 操作:

  • DOM 节点的插入;
  • DOM 节点的删除;
  • DOM 节点的更新。

其实,除了这个主要任务之外,react 还会有别的任务。而调用所有的 fiber 节点上的 useLayoutEffect() hook 的 destroy 函数就是众多其他任务中的一个。首先,我们先捋一捋它的调用链:

flowchart TD
A["commitMutationEffects()"]--> B["commitMutationEffectsOnFiber()"]
B--> C["commitHookEffectListUnmount()"]
C--> D["safelyCallDestroy()"]
D--> E["destroy()"]

读过我的《useEffect() 篇》文章的人知道,useEffect() hook 函数的 destroy 函数也会走到上面这个调用链的中末端,即:commitHookEffectListUnmount() --> safelyCallDestroy() --> destroy()。其实,如果你深入源码,你会发现,所有的 effect 类型的 hook 的 destroy 函数都是经由这个调用链去调用的。它是所有的 effect 类型的 hook 所处的共用架构的一部分。故,useLayoutEffect() hook 的 destroy 函数也不例外。

在本小节的这个主题下,useLayoutEffect() hook 的特别之处在于,它的 destroy 函数的调用是发生在 mutation 子阶段。具体来讲,就是发生在 commitMutationEffectsOnFiber() 这个函数里面:

  function commitMutationEffectsOnFiber(finishedWork, root, lanes) {
    const current = finishedWork.alternate;
    const flags = finishedWork.flags; 

    switch (finishedWork.tag) {
    case FunctionComponent:
      case ForwardRef:
      case MemoComponent:
      case SimpleMemoComponent: {
        recursivelyTraverseMutationEffects(root, finishedWork);
        commitReconciliationEffects(finishedWork);

        if (flags & Update) {
          // ......
          {
            try {
              commitHookEffectListUnmount(
                Layout | HasEffect,
                finishedWork,
                finishedWork.return
              );
            } catch (error) {
              captureCommitPhaseError(finishedWork, finishedWork.return, error);
            }
          }
        }

        return;
      }
     // ......
    }
  }
  

上面给出的是该函数的架构。这个架构其实就是一个用「深度优先」递归算法去遍历整棵 fiber 树的一个架构。这个遍历架构跟 react 去调用 useEffect hook 的 destroy 和 create 函数所采用的遍历架构是完全一样的。所以,在这里,我就不再重复深入这里的细节了。感兴趣的,可以翻看我上面提到的文章。下面简单提提这个架构的三板斧:

  • 总入口 - commitMutationEffectsOnFiber()
  • 递出去 - recursivelyTraverseMutationEffects()
  • 归回来 - commitXXX()

递归递归,归回去的时候会做各种 commmit 的工作,这正是上面 commitXXX() 所想表达的含义。不信,你可以到源码中看看 HostComponent 的那个 case 分支。这个分支要做的就是各种 DOM 操作:

  • safelyDetachRef()
  • resetTextContent()
  • commitUpdate()

这相当于在表达:mutation 子阶段,如果「归」是发生在 HostComponent 类型的 fiber 节点的话,我们要做的就是 commit 各种 DOM 操作。

我们在题外话扯远了。言归正传。从上面给出的源码片段,我们可以看到:mutation 子阶段,如果「归」是发生在 functionComponent-like 类型的 fiber 节点的话,我们要做的就是调用 useLayoutEffect() hook 的 destroy 函数 。

接下来,我们来到了 effect 类型的 hook 调用 destroy 函数共用架构的入口: commitHookEffectListUnmount()。读过我的《useEffect() 篇》文章的人知道,useEffect() hook 的 destroy 函数的调用链也是有这么一环。只不过相比于 useEffect() hook 的调用传参:

  commitHookEffectListUnmount(
    Passive | HasEffect,
    finishedWork,
    finishedWork.return
  );

useLayoutEffect() hook 的调用传参是:

  commitHookEffectListUnmount(
    Layout | HasEffect,
    finishedWork,
    finishedWork.return
  );

看到这两者的不同了吗?也就是说,useEffect() hook 和 useLayoutEffect() hook 的 destroy 函数都是同处在同一条 effect 链上。react 会一遍又一遍地去遍历这一条 effect 链,然后去调用 destroy 函数。在不同的遍历次序中,react 如何保证调用的是特定类型的 hook 的 destroy 函数 呢?答案就是这里的commitHookEffectListUnmount() 的第一个参数的effect flag,它在传达这样的指令:effect 链上,只有 effect flag 为 xxx(比如:Layout | HasEffect) 的 effect 才是我的目标 effect。

在运行时, commitHookEffectListUnmount() 会跳过非目标 effect,调用目标 effect 上的 destroy 函数。到这里,我们就彻底弄清楚 useLayoutEffect() hook 的 destroy 函数是怎么被调用的了。

最后强调一下,useLayoutEffect() hook 的 destroy 函数调用虽然是发生在 mutation 子阶段,但是它还是在 DOM 操作完成之后才发生的。为什么呢?因为 react 的「深度优先」递归遍历算法保证了总是先 commit 了 fiber 树的叶子节点(即 host component),再 commit 上层的抽象层的 节点(即function component 或者 class component)。而(useLayoutEffect()) hook 只会存在于 function component ,所以,它在 mutation 子阶段的 commit 工作也是永远发生在真实的 DOM 操作完成之后。

layout 子阶段调用 create 函数

react 会在 layout 子阶段调用 create 函数。之所以称之为「layout」子阶段,是因为在这个子阶段,react 主要是 commit 跟布局相关的 effect。这里又分为两种类型的 layout effect:

  • hook 类型 - 这里就是指 useLayoutEffect() hook 的 create 函数;
  • class 类型 - 这里是指 class component 的两个生命周期函数:
    • componentDidMount()
    • componentDidUpdate()

useLayoutEffect() hook 的 create 函数的调用栈如下:

flowchart TD
A["commitLayoutEffects()"]--> B["commitLayoutEffectOnFiber()"]
B--> C["commitHookLayoutEffects()"]
C--> D["commitHookEffectListMount()"]
D--> E["create()"]

读过我的《useEffect() 篇》文章的人知道, useEffect() hook 的 create 函数也是最后会进入 commitHookEffectListMount()。这是所有 effect 类 hook 的 create 函数所共用的遍历架构。在这里就不赘述了。

在上面的这个调用栈中,useLayoutEffect() hook 的 create 函数的真正调用入口为 commitHookLayoutEffects(),有源码为证:

  function commitLayoutEffectOnFiber(
    finishedRoot,
    current,
    finishedWork,
    committedLanes
  ) {
    const flags = finishedWork.flags;

    switch (finishedWork.tag) {
      case FunctionComponent:
      case ForwardRef:
      case SimpleMemoComponent: {
        recursivelyTraverseLayoutEffects(
          finishedRoot,
          finishedWork,
          committedLanes
        );

        if (flags & Update) {
          commitHookLayoutEffects(finishedWork, Layout | HasEffect);
        }

        break;
      }

     // ......
    }
  }

入口 commitHookLayoutEffects()之上的调用链其实是在实现 react 遍历整棵 fiber 树通用的递归遍历架构 - 「深度优先」递归遍历算法。这个架构在上一个小节《mutation 子阶段调用 destroy 函数》中已经做了概括性陈述。在 react 的源码中,这种架构的代码会重复出现在 commit 阶段的代码中。上面提到,它有三板斧:

  • 总入口
  • 递出去
  • 归回来

如果落在本小节所讨论的主题上,那么上面的三板斧所对应的源码代码是:

  • 总入口 - commitLayoutEffectOnFiber()
  • 递出去 - recursivelyTraverseLayoutEffects()
  • 归回来 - commitHookLayoutEffects()

小结

  1. react 是在哪里调用了 useLayoutEffect() hook 的 create 函数?

答: “是在 commit 阶段的 layout 子阶段去调用了该函数。”

  1. react 是怎样调用了 useLayoutEffect() hook 的 create 函数?

答: “

  • 首先,使用了「深度优先」递归遍历算法去遍历整棵 fiber 树;
  • 然后,在遍历「归」回来的时候再对 functionComponent-like 的组件进行检查,看看是否需要执行的 layout effect。如果有,则通过调用 commitHookLayoutEffects() 来最终调用 useLayoutEffect() hook 的 create 函数。”

怎么决定是否调用 useLayoutEffect() hook 的 destroy 和 create 函数?

关于这个问题的答案,其实在原理上跟 useEffect() 在该主题问题的答案是一模一样的。这里我可以重提一下。怎么决定?react 是按两步走的:

  1. render 阶段, 贴 hook flag(或者说 effect flag);
  2. commit 阶段, 遍历 effect 链表的时候,检查 hook flag。

useLayoutEffect()useEffect() 在该主题下的区别仅仅在于所贴的 hook flag 不一样:

  • useEffect() 贴的是 Passive flag;
  • useLayoutEffect() 贴的是 Layout flag。

当然,如果某个 effect 是需要被执行的,那么 react 都会给两者的 effect 多贴一个 HasEffect flag。在遍历 effect 链表的时候,react 就是根据是否有这个 flag 来决定是否要调用 destroy 和 create 函数的。如果有,则调用;反之则不然。

关于这两个 hook flag 有什么含义和上面所提到决定调用的更加具体的原理分析,可以翻看我的《useEffect() 篇》文章。在此,我就不赘述了。

useLayoutEffect() 的应用

在日常开发中,相比于 useEffect 的使用频率,useLayoutEffect() 可以说是一个比较冷门的 API。那我们不禁要问:“useLayoutEffect() 到底有什么用呢?”

关于这个问题,react 官方文档给出了一个使用用例: 在浏览器重绘之前测量 DOM 的布局信息

而我在我的文章 《【react】useEffect VS useLayoutEffect》的「实践上的问题」小节也给出了一个使用用例。

其实,上面给出的两个使用用例本质都是在解决一个问题:「界面更新时候的闪烁问题」。为什么使用 useLayoutEffect() 能解决这类的问题呢?首先,我们要知道为什么会造成闪烁。造成闪烁的原因是 react 在短时间更新了多帧显示内容不一样的界面。而 useLayoutEffect() hook 的 create 函数是在 layout 子阶段以同步的,批量的方式去执行的。也就是说,create 函数里面的所发起的多次状态更新请求只会产生一次的实质性的界面重绘。 通过抹除代表着中间状态的过渡帧,将多帧压缩为一帧来更新界面,这就是 useLayoutEffect() 能解决「界面更新时候的闪烁问题」原因之所在。

上面所提到的 「界面更新时候的闪烁问题」只是 useLayoutEffect() 能解决问题中的一个垂类。useLayoutEffect() 应该还能解决更多不同业务场景下的问题,这是一个有待探索和总结的话题。但是,我们得通过现象看本质。通过理解和掌握它的本质能力,我们就能在遇到一些十分冷门问题的时候想到 useLayoutEffect() 也许能帮上忙。

useLayoutEffect() 的本质能力是什么?答曰:

  • 访问更新后的 DOM 树 - 因为 useLayoutEffect() hook 的 create 函数是在 mutation 子阶段之后的 layout 子阶段执行的。所以,这就意味着我们可以去访问更新后的 DOM 树和做一些布局信息的测量。
  • 同步/批量执行状态更新,阻塞浏览器重绘 - react 将会以「同步阻塞,批量更新」的方式去对待 create 函数体里面所发起的过个状态更新请求。也就说,在 create 函数体里面所发起的过个状态更新请求只会产生一次界面重绘。

理解上面的本质能力,相信我们会利用 useLayoutEffect() 却解决更多冷门的,棘手问题。

总结

与其讲本文是在讲 useLayoutEffect() API 的相关关切,还不如说本文是在讲 effect 类型 hook 的通用架构。到目前为止,《从源码学 API 系列》学了两个 effect 类的 hook:

  • useLayoutEffect()
  • useEffect()

源码的角度异同点

从源码的角度来看, 这两者其实都是在同一个架构里面,拥有很多相同点:

  • 每一个 effect 类型 hook 都会关联一个 hook 对象 和 effect 对象;
  • 同一个 function component 内,所有的 hook 对象都共用同一条 hook 链表;
  • 同一个 function component 内,所有的 effect 对象都共用同一条 effect 链表;
  • 同一个调用机制 - 在 render 阶段创建 hook 对象和 effect 对象,在 commit 阶段去调用 hook 的 create 和 destroy 函数。

这两者的不同点在于:

  • useEffect() 的 destroy 和 create 函数是在同一个子阶段( layout 子阶段后的 passive 子阶段)调用的(调用入口要么是调度后的 flushPassiveEffectImpl(),要么是同步的flushPassiveEffectImpl());
  • useLayoutEffect() 的这两个函数却是在不同的子阶段执行。destroy 是在 mutation 子阶段执行的,而 create 是在 layout 子阶段。

应用角度的异同点

从应用的角度来看,更多这两者之间的异同点可以参阅我的文章《【react】useEffect VS useLayoutEffect》。