React useLayoutEffect 调用时机:源码解析 Mutation 相位完成后同步执行的阻塞路径

源码深潜:useLayoutEffect 的同步阻塞与 React 渲染的“幕后黑手”

各位老铁,各位前端界的“卷王”们,大家好!

欢迎来到今天的“源码深潜”特别栏目。今天我们不聊业务逻辑,不聊组件封装,我们直接把 React 的“裤衩”扒下来,看看它底裤里藏着什么秘密。

今天的主角是 useLayoutEffect

你可能在代码里用过它,甚至可能为了解决“屏幕闪烁”这个千古难题而不得不依赖它。但你真的知道它在 React 的生命周期里处于什么位置吗?它为什么能像个“同步阻塞”的恶霸一样,挡住浏览器的重绘?为什么它必须放在 useEffect 之前执行?

别急,今天我们就把 React 源码那层神秘的面纱撕开,带你走进那个叫做 Commit 阶段 的“后台办公室”,看看 useLayoutEffect 是如何一步步走上神坛,并在同步路径上大杀四方的。

准备好了吗?深呼吸,我们要开始“扒代码”了。


第一章:React 的“舞台剧”排练

要理解 useLayoutEffect,首先你得明白 React 渲染不是一蹴而就的。它就像一场精心排练的舞台剧,分为两幕。

第一幕:Render(渲染阶段)
这是“画师”的工作。React 调度器开始干活,计算差异,生成 Fiber 树。这一阶段是异步的,也是可中断的。如果用户疯狂点击按钮,React 就会暂停当前的渲染,去处理新的点击事件。这时候,DOM 还没动,一切都还在脑子里。

第二幕:Commit(提交阶段)
这是“搬演”的工作。Render 阶段结束,确认了哪些节点变了。React 开始真正地去操作 DOM。这一阶段是同步的,也是阻塞的。

useLayoutEffect,就是在这个第二幕里,也就是 Commit 阶段,上演它的戏码的。


第二章:Commit 阶段的“内幕交易”

在源码的世界里,Commit 阶段并不是一锅粥,它被精细地切分成了四个小步骤,就像流水线上的四个工位。useLayoutEffect 就站在第三个工位上,手里拿着麦克风。

让我们打开 ReactFiberWorkLoop.js(这是 React 渲染循环的核心文件),看看这个工位是怎么排班的。

1. Before Mutation Effects(变更前清理)

这时候,React 还没碰 DOM。它的任务是清理那些即将被卸载的组件留下的“烂摊子”,比如 useEffect 的清理函数。

2. Mutation Effects(变更阶段)—— 这是最脏活累活的阶段

这里才是真正修改 DOM 的地方。React 把 style 改了,把 innerHTML 换了,把 appendChild 执行了。DOM 节点已经变了,但此时浏览器还没来得及把变化画到屏幕上。

3. Layout Effects(布局阶段)—— 我们的主角登场了

就在 DOM 变更完成之后,在浏览器开始重绘(Paint)之前,React 会执行 useLayoutEffect注意这个时间点,非常关键。 这时候 DOM 是最新的,但屏幕还是旧的。

4. Passive Effects(被动阶段)

这是 useEffect 的领地。浏览器画完屏幕后,React 才会来调用 useEffect


第三章:源码解析—— useLayoutEffect 的同步之路

好了,理论讲完了,现在上干货。我们要看的是 React 是怎么决定在 Commit 阶段的哪个时刻去调用 useLayoutEffect 的。

在源码中,Commit 阶段的入口函数是 commitRoot。在这个函数里,有一个巨大的 while 循环,负责遍历所有受影响的 Fiber 节点。

// 简化的伪代码,来自 React 18 源码
function commitRoot(root) {
  // ... 前面的准备工作

  // 1. Before Mutation 阶段
  commitBeforeMutationEffects(root);

  // 2. Mutation 阶段
  commitMutationEffects(root);

  // 3. Layout 阶段 —— 重点来了!
  commitLayoutEffects(root);

  // 4. Passive 阶段
  commitPassiveEffects(root);

  // ... 收尾工作
}

接下来,我们深入 commitLayoutEffects。这就像是一个巡演经理,拿着一张清单(Fiber 树),挨个去检查哪些演员(组件)有戏要演。

3.1 遍历 Fiber 链表

React 维护了一个单向链表 nextEffect,所有的有副作用(比如 useEffectuseLayoutEffect)的节点都连在这个链表上。

// ReactFiberWorkLoop.js
function commitLayoutEffects(root, committedLanes) {
  // nextEffect 是当前正在处理的节点
  var nextEffect = root.firstEffect;

  // 这是一个死循环,直到链表空了为止
  while (nextEffect !== null) {
    // 4.1 调度具体的布局副作用执行
    commitLayoutEffectOnFiber(root, nextEffect, committedLanes);

    // 4.2 移动指针,处理下一个节点
    nextEffect = nextEffect.nextEffect;
  }
}

注意这里的 while 循环。在 React 的设计哲学里,Commit 阶段是同步的,这意味着这个循环会瞬间跑完。不会像 Render 阶段那样,跑一会儿就暂停让你去处理点击事件。这就是为什么 useLayoutEffect 里的代码是同步阻塞的。

3.2 核心函数:commitLayoutEffectOnFiber

这个函数是真正的“分诊台”。它拿到一个 Fiber 节点,先检查这个节点有没有标记 Layout 副作用。

function commitLayoutEffectOnFiber(fiber, lane) {
  var effectTag = fiber.effectTag;

  // 检查是否有 LayoutEffect 标记
  if (effectTag & LayoutMask) {
    // 1. 获取当前 fiber 的 DOM 节点
    var current = fiber.alternate;
    var next = fiber;

    // 2. 如果是卸载流程
    if (effectTag & Deletion) {
      commitDeletionEffectsOnFiber(next);
    } 
    // 3. 如果是挂载或更新流程
    else {
      // 获取 DOM 节点
      var domFiber = fiber;
      if (domFiber.tag === HostComponent) {
        var instance = domFiber.stateNode;
        if (instance !== null) {
          // 调用具体的 layoutEffect 逻辑
          commitLayoutEffectList(instance, domFiber, next);
        }
      }
    }
  }
}

这里的 commitLayoutEffectList 就像是把剧本递到了演员手里。

3.3 执行回调:invokeGuardedCallback

React 不会直接调用函数,它把函数包装在了一个 try-catch 块里。这就像是一个保镖,防止你写的 useLayoutEffect 代码崩了,把整个 React 渲染进程给搞挂了。

function commitLayoutEffectList(fiber, returnFiber, firstEffect) {
  var nextEffect = firstEffect;

  while (nextEffect !== null) {
    var effect = nextEffect;
    var create = effect.create;
    var destroy = effect.destroy;

    // invokeGuardedCallback 是核心,它同步执行回调
    // 这就是为什么它是阻塞的!
    invokeGuardedCallback(null, create, null, effect);

    // 如果有清理函数(比如组件卸载时),也同步执行
    if (destroy !== null) {
      invokeGuardedCallback(null, destroy, null);
    }

    nextEffect = nextEffect.nextEffect;
  }
}

这就是 useLayoutEffect 执行的完整路径:

  1. Commit 阶段开始
  2. Mutation 阶段 完成后,DOM 变更生效。
  3. Layout 阶段 开始遍历链表。
  4. 同步调用 commitLayoutEffectList
  5. 同步执行 useLayoutEffect 注册的回调函数。
  6. 同步执行 useEffect 的清理函数(如果是卸载)。
  7. Layout 阶段结束,浏览器准备重绘。

第四章:为什么它必须“同步阻塞”?

你可能会问:“React 大佬,既然 useLayoutEffect 是同步的,那能不能改成异步的?这样就不会卡顿用户操作了。”

答案很残酷:不能。

这就像你装修房子,useLayoutEffect 就是你装修工人。如果装修是异步的(比如“你先去喝杯茶,我回头再弄”),那结果就是:你走进房间,发现家具还没摆好,墙还没刷,甚至门都还没装上,你就得先住进去。这叫“视觉闪烁”。

4.1 防止“视觉闪烁”

想象一下,你有一个组件,它的初始状态是隐藏的(display: none),你需要计算它的高度来设置另一个元素的高度。

错误示范:用 useEffect

function MyComponent() {
  const [height, setHeight] = useState(0);

  useEffect(() => {
    // 此时浏览器已经画完屏幕了,用户可能已经看到高度为 0 的元素了
    const element = document.getElementById('my-element');
    setHeight(element.getBoundingClientRect().height);
  }, []);

  return <div style={{ height: `${height}px` }}>Hello</div>;
}

执行流程:

  1. React 渲染组件,height 是 0。
  2. React 修改 DOM,把 div 渲染出来(高度为 0)。
  3. 浏览器重绘屏幕。用户看到高度为 0 的 div
  4. useEffect 回调执行,计算高度为 100px。
  5. setHeight(100) 触发重新渲染。
  6. 用户看到 div 瞬间长高到了 100px。

结果: 屏幕闪烁了!用户感觉像是在抽搐。

正确示范:用 useLayoutEffect

function MyComponent() {
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    // 这时候 DOM 已经变了,但屏幕还没画!
    const element = document.getElementById('my-element');
    const h = element.getBoundingClientRect().height;

    // 同步更新 state
    setHeight(h);

    // 注意:这里不需要 return 一个函数,因为高度变了会自动重新渲染
  }, []);

  return <div style={{ height: `${height}px` }}>Hello</div>;
}

执行流程:

  1. React 渲染组件,height 是 0。
  2. React 修改 DOM,把 div 渲染出来(高度为 0)。
  3. useLayoutEffect 执行。同步计算高度 100px。
  4. setHeight(100) 触发重新渲染。
  5. React 再次渲染,这次 height 是 100px。
  6. 浏览器重绘屏幕。用户直接看到高度为 100px 的 div

结果: 完美!没有闪烁。因为 useLayoutEffect 在浏览器眼睛看到之前,先把账算清楚了。

4.2 同步执行的意义:基于最新 DOM 计算

useLayoutEffect 的核心用途是读取 DOM。它必须在浏览器重绘之前执行,这样它读取到的 DOM 值才是最新的。如果它是在 useEffect(异步)里执行,那它读到的 DOM 可能是上一帧的旧数据,导致计算错误。


第五章:性能的代价——CPU 的“同步等待”

虽然 useLayoutEffect 很好用,但它有个致命弱点:它阻塞浏览器。

因为它是同步的,所以在 useLayoutEffect 执行的那几毫秒里,浏览器的主线程是被占用的。如果用户在这几毫秒里疯狂点击按钮,React 可能会来不及响应,导致界面卡顿。

想象一下,你的 useLayoutEffect 里写了 100 行复杂的数学运算,或者调用了 5 次 document.querySelector,或者执行了一个死循环。

useLayoutEffect(() => {
  // 这里有个死循环
  while (true) {
    // CPU 100% 占用,屏幕卡死
  }
}, []);

浏览器会直接卡住,直到这个函数执行完。这就是为什么 React 官方文档里反复强调:useLayoutEffect 里的代码要快!

5.1 何时使用 useLayoutEffect

  • 读取 DOM 布局: 比如 getBoundingClientRect,计算宽高,获取滚动位置。
  • 强制重绘: 有时候你需要触发浏览器重绘来清除某些样式(虽然很少见,但确实有需求)。
  • 同步修正样式: 在渲染后立即修正样式,防止布局偏移(CLS)。

5.2 何时使用 useEffect

  • 不读取 DOM: 只需要处理副作用,不需要依赖 DOM 的最新状态。
  • 数据获取: 发起网络请求。
  • 订阅外部系统: 监听 window 大小变化(虽然现在有 ResizeObserver,但老代码里常见)。
  • 复杂的异步逻辑: 需要一定时间执行的任务。

第六章:源码细节——invokeGuardedCallback 的魔法

让我们再深入一点点,看看 React 是怎么保护你不写崩代码的。

commitLayoutEffectOnFiber 里,我们看到了 invokeGuardedCallback。这是一个非常精妙的封装。

function invokeGuardedCallback(fn, context, args) {
  try {
    fn.apply(context, args);
  } catch (error) {
    // 如果报错了,React 会把它存起来
    captureCommitPhaseError(error);
  }
}

为什么这么做?因为在 Commit 阶段,所有的操作都是同步且关键路径上的。如果 useLayoutEffect 里抛出了一个未捕获的错误,整个渲染过程就会崩溃。React 必须捕获这个错误,并把它交给错误边界(Error Boundary)去处理,而不是让整个页面白屏。

同时,因为它是同步的,所以错误发生的位置非常精确,非常容易调试。


第七章:实战演练——手写一个“防抖测量器”

为了巩固我们的知识,我们来写一个实际场景。

假设我们有一个弹窗,弹窗打开后,我们需要自动调整内部元素的位置,确保它不超出屏幕边界。

场景:
用户点击按钮 -> 弹窗挂载 -> 弹窗挂载 -> useLayoutEffect 检查位置 -> 调整位置 -> 浏览器重绘。

代码实现:

import React, { useState, useLayoutEffect, useRef } from 'react';

function Modal({ isOpen }) {
  const modalRef = useRef(null);
  const [position, setPosition] = useState({ top: 0, left: 0 });

  // 这个钩子只在挂载和更新时执行
  useLayoutEffect(() => {
    if (!isOpen || !modalRef.current) return;

    // 1. 获取弹窗元素
    const modal = modalRef.current;

    // 2. 获取浏览器视口尺寸
    const viewport = {
      width: window.innerWidth,
      height: window.innerHeight,
    };

    // 3. 获取弹窗尺寸和位置
    const rect = modal.getBoundingClientRect();

    // 4. 计算是否超出边界(同步计算!)
    let newTop = rect.top;
    let newLeft = rect.left;

    if (rect.right > viewport.width) {
      newLeft = viewport.width - rect.width - 20; // 右边距 20
    }
    if (rect.bottom > viewport.height) {
      newTop = viewport.height - rect.height - 20; // 底部距 20
    }

    // 5. 更新状态
    // 这里必须同步更新,因为 DOM 已经变了,我们需要基于新 DOM 更新状态
    // 如果用 useEffect,这里计算出来的位置可能就是错的,因为 useEffect 在重绘后执行
    setPosition({ top: newTop, left: newLeft });
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      style={{
        position: 'fixed',
        top: position.top,
        left: position.left,
        backgroundColor: 'white',
        padding: '20px',
        border: '1px solid black',
        zIndex: 1000,
      }}
    >
      这是一个自动调整位置的弹窗
    </div>
  );
}

export default Modal;

在这个例子中,如果你把 useLayoutEffect 换成 useEffect,你会发现弹窗的位置计算可能不准确,或者在某些极端情况下(比如弹窗初始位置就在屏幕外),你会看到弹窗瞬间跳变。


第八章:源码中的“副作用”链表

为了更深入理解,我们得聊聊 React 是怎么管理这些副作用的。在 Fiber 节点里,有一个 nextEffect 属性,它把所有需要处理的副作用节点连成了一个链表。

React 在 Commit 阶段会遍历这个链表。每个节点都有一个 effectTag,这是一个位掩码。

  • PerformedWork: 标记为已完成。
  • Deletion: 标记为删除。
  • Placement: 标记为插入。
  • Update: 标记为更新。
  • Snapshot: 标记为 Snapshot(用于 getSnapshotBeforeUpdate)。
  • Layout: 标记为 useLayoutEffect

commitLayoutEffects 循环中,React 会检查每个节点的 effectTag。如果是 Layout,就调用对应的回调。

// 源码片段
if (nextEffect.effectTag & LayoutMask) {
  // 调用 layoutEffect
  commitLayoutEffectOnFiber(nextFiber);
}

这种设计非常优雅。React 不需要去遍历整个 Fiber 树找副作用,只需要维护一个受影响节点的链表即可。这大大提高了 Commit 阶段的效率。


第九章:与 useEffect 的终极对决

让我们通过对比,彻底搞懂这两者的区别。这就像是在问:“你什么时候洗澡?”和“你什么时候刷牙?”

特性 useLayoutEffect useEffect
执行时机 Commit 阶段,Layout 阶段
DOM 变更后,重绘前。
Commit 阶段,Passive 阶段
重绘后,事件循环下一轮。
执行方式 同步 异步
阻塞性 阻塞浏览器重绘 不阻塞
主要用途 读取 DOM 布局,同步修正样式。 数据获取,订阅事件,不依赖 DOM 的副作用。
性能 较重,可能影响首屏性能。 较轻,对性能影响小。
调试难度 较高,因为会打断调试器。 较低。

比喻:

  • useLayoutEffect 是你在装修完房子后,站在门口检查有没有漏水、灯亮不亮。你必须在客人进来之前(重绘之前)检查完。
  • useEffect 是你装修完房子后,把钥匙交给保洁阿姨,然后去楼下买杯咖啡。等咖啡喝完了(下一帧),保洁阿姨打扫完了卫生,你再回来检查。

第十章:源码中的“调度”与“执行”分离

React 的核心思想之一就是“调度与执行分离”。

Render 阶段是调度,是计算,是异步。
Commit 阶段是执行,是操作,是同步。

useLayoutEffect 虽然是同步的,但它并不在 Render 阶段执行。它是在 Commit 阶段开始后才执行的。

为什么不在 Render 阶段执行?因为 Render 阶段是可中断的。如果在 Render 阶段执行 useLayoutEffect(比如计算布局),一旦中断,React 可能无法恢复之前的状态,导致 DOM 状态不一致。

为什么不在 Commit 阶段结束后(即 useEffect 阶段)执行?因为那样就会产生视觉闪烁,违背了 React “声明式 UI”的初衷——UI 应该平滑过渡,而不是突然跳变。

所以,useLayoutEffect 就像是夹在中间的那个“同步执行器”,确保了 DOM 的状态更新和视觉呈现之间没有时差。


第十一章:性能优化的实战心法

既然知道了 useLayoutEffect 的原理,我们在写代码时应该注意什么?

  1. 不要在里面做繁重计算: 不要在 useLayoutEffect 里做复杂的数学运算,不要做正则匹配,不要做大量的 DOM 查询。这些操作会阻塞浏览器,导致页面卡顿。
  2. 不要在里面修改 DOM: 虽然 useLayoutEffect 执行时 DOM 已经变了,但你应该通过修改 State 来驱动 UI 变化,而不是直接操作 DOM。保持 React 的单向数据流。
  3. 避免重复计算: 确保 useLayoutEffect 的依赖数组设置正确。如果依赖变化了才重新计算,否则就会在每次渲染时都执行,造成性能浪费。

错误示例:

// 这种写法很危险
useLayoutEffect(() => {
  // 每次 render 都会执行
  // 即使 isOpen 没变,或者 modalRef.current 没变
  const rect = modalRef.current.getBoundingClientRect();
  console.log(rect);
}, []);

正确示例:

// 这种写法才安全
useLayoutEffect(() => {
  if (!isOpen) return;
  const rect = modalRef.current.getBoundingClientRect();
  // ... 计算
}, [isOpen]); // 依赖 isOpen

第十二章:源码中的“清理函数”机制

useLayoutEffect 不仅支持 setup,也支持 cleanup。

当组件卸载时,或者依赖项变化导致 Effect 重新执行时,React 会先调用上一次的 cleanup 函数,再调用新的 setup 函数。

在源码中,commitLayoutEffectOnFiber 会检查 effectTag。如果是 Snapshot,它实际上是在调用 cleanup 函数。

function commitLayoutEffectOnFiber(fiber, lane) {
  // ...
  if (effectTag & LayoutMask) {
    // ...
    var destroy = effect.destroy; // 获取上一次的清理函数
    if (destroy !== null) {
      // 同步执行清理函数
      invokeGuardedCallback(null, destroy, null);
    }
    // ...
  }
}

这对于清理事件监听器、取消订阅、重置定时器非常重要。因为 useLayoutEffect 是同步的,所以它的清理函数也是同步执行的,确保了资源的及时释放。


第十三章:总结与升华

好了,各位老铁,我们的源码之旅即将结束。

我们回顾一下 useLayoutEffect 的核心机制:

  1. 位置: 它位于 Commit 阶段的 Layout 阶段,就在 Mutation 阶段(DOM 变更)之后,Passive 阶段(useEffect)之前。
  2. 执行方式: 它是 同步 执行的,阻塞浏览器进入下一帧(重绘)。
  3. 目的: 为了读取最新的 DOM 状态,并在浏览器绘制前同步更新 State,从而 防止视觉闪烁,确保布局计算的准确性。
  4. 代价: 它会占用主线程时间,如果代码写得太重,会影响页面性能。

React 团队设计这个 API 是经过深思熟虑的。它是一个“双刃剑”。它给了我们控制布局的强大能力,但也要求我们必须谨慎使用,保持代码的轻量和高效。

记住:

  • 如果你想 DOM -> 用 useLayoutEffect
  • 如果你想副作用 -> 用 useEffect
  • 如果你想起来不被浏览器看到 -> 用 useEffect
  • 如果你想盯着浏览器,确保它画出来的东西是对的 -> 用 useLayoutEffect

第十四章:源码终极挑战(彩蛋)

最后,为了证明我们真的看懂了源码,我们来挑战一个源码级别的细节。

ReactFiberWorkLoop.js 中,有一个函数叫 commitBeforeMutationEffects。在这个阶段,React 会处理 useEffect 的清理函数。

而在 commitLayoutEffects 阶段,React 会处理 useLayoutEffect 的执行。

但是,你有没有想过,为什么 commitBeforeMutationEffects 叫这个名字?为什么要叫“Before Mutation”?

因为在 Mutation 阶段(即修改 DOM 之后),React 会去计算一些快照,用于 getSnapshotBeforeUpdate

更重要的是,在 React 18 的并发模式下,这个阶段变得更加复杂。因为 Render 阶段可能是中断再重启的,所以 Commit 阶段必须非常小心地处理 nextEffect 链表,确保不会漏掉任何一个节点。

// React 18 源码中的一个小细节
function commitBeforeMutationEffects(root) {
  // ...
  while (nextEffect !== null) {
    var flags = nextEffect.flags;

    // 如果有副作用
    if (flags & (Placement | Update | Deletion)) {
      // 调用 beforeMutation 的逻辑
      commitBeforeMutationEffectsOnFiber(nextEffect);
    }

    // 关键点:commitBeforeMutationEffectsOnFiber 里面会处理
    // 如果有 cleanup 函数,它会在这里执行!
    // 这就是为什么 useEffect 的清理函数在 commitBeforeMutationEffects 阶段执行,
    // 而不是在 commitLayoutEffects 阶段执行。
    // 因为 cleanup 函数不需要等待 DOM 变更完成,也不需要等待布局计算。
    // 这是一个非常微妙的源码细节,值得大家细细品味。

    nextEffect = nextEffect.nextEffect;
  }
}

纠正与补充:
实际上,React 17 和 18 中,useEffect 的 cleanup 确实是在 commitBeforeMutationEffects 阶段执行的,也就是在 DOM 变更之前。而 useLayoutEffect 的执行是在 commitLayoutEffects 阶段,也就是在 DOM 变更之后。

这个顺序确保了:

  1. useEffect 的 cleanup 先跑,此时 DOM 还没变,这是为了安全地清理旧的状态。
  2. DOM 变更发生。
  3. useLayoutEffect 跑,此时 DOM 是最新的。
  4. useEffect 的 setup 跑,此时 DOM 也是最新的(因为已经画完了)。

这种精妙的编排,体现了 React 团队对细节的极致追求。


结语

useLayoutEffect 不仅仅是一个 API,它是 React 渲染机制中一个精妙的平衡点。它用同步执行的代价,换取了视觉上的平滑与布局计算的准确性。

希望通过这次源码级别的“扒光式”讲解,你能真正理解它,而不是仅仅记住“它能防闪烁”这个肤浅的结论。

如果你在面试中被问到这个问题,不要只是背诵生命周期,试着去讲讲 Commit 阶段,讲讲同步与异步的区别,讲讲浏览器重绘的机制。那样,你才会真正成为一个资深的前端工程师。

好了,今天的源码深潜就到这里。大家去写代码的时候,记得手下留情,别在 useLayoutEffect 里写死循环了,不然用户真的会骂娘的!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注