React useLayoutEffect 面试题:为什么它比 useEffect 更容易导致页面白屏?请结合浏览器渲染流水线说明

好,各位开发者,把你们的键盘握紧一点,今天我们要聊的是 React 里的“幽灵”——useLayoutEffect。别以为它只是 useEffect 的一个兄弟,这家伙可是个披着羊皮的狼,是个披着 React 外衣的“同步阻塞怪兽”。

为什么它更容易导致页面白屏?为什么当你不小心在它里面写了一行稍微重一点的代码,整个页面就像死机了一样?别急,咱们把这事儿掰开揉碎了,像看一部惊悚片一样,顺着浏览器渲染的流水线,一步步把它扒个精光。

第一幕:浏览器的“装修流水线”

要理解 useLayoutEffect 为什么会搞破坏,你首先得明白,当你点击一个按钮,或者输入一个字符时,浏览器到底在干嘛。别告诉我它在“渲染”,那是外行话。它是在执行任务

想象一下,浏览器就是一个正在装修的毛坯房,而 React 就是那个装修队队长。当你更新状态时,React 的工作流程是这样的,这一步一步,环环相扣,就像多米诺骨牌:

  1. 阶段一:虚拟 DOM 的碰撞(React 层面)
    React 在内存里算了一笔账,发现:“哎呀,这个 div 的宽度变了,那个 span 的颜色也变了。” 它把这一堆变化整理成一份“装修计划书”(虚拟 DOM Diff)。

  2. 阶段二:真实 DOM 的施工(浏览器层面 – 布局计算)
    React 把计划书扔给浏览器,浏览器说:“好嘞,先把旧墙拆了,量量新墙要多宽。” 这一步叫布局,或者更专业的说法叫回流。浏览器计算每个元素的位置、大小。这时候,屏幕上还是一片空白(或者是旧的画面)。

  3. 阶段三:上色(浏览器层面 – 绘制)
    量完尺寸,浏览器开始刷漆了。它把计算好的样式画在 Canvas 或者 DOM 节点上。现在,你看到房子的轮廓了,但是可能还没上色。

  4. 阶段四:组装(浏览器层面 – 合成)
    最后,浏览器把所有的图层拼起来,加上阴影、加上过渡动画,最终变成你眼睛里看到的那个高清大图。

这就是经典的渲染流水线

第二幕:两个性格迥异的“监工”

现在,我们要在这个流水线上安插两个监工,一个叫 useEffect,一个叫 useLayoutEffect。它们都在等 React 干完活,但它们进场的时机完全不同。

1. useEffect:那个“事后诸葛亮”的懒汉

useEffect 的进场时机非常从容,甚至有点懒。它是在阶段四(合成)之后才被调用的。

流程是这样的:

  • React 更新状态。
  • 浏览器计算布局。
  • 浏览器绘制画面。
  • 浏览器合成画面(此时你的眼睛已经看到了屏幕上的内容)。
  • 突然! React 唤醒了 useEffect,说:“嘿,兄弟,刚才画完了,你看看能不能改改?”

因为 useEffect 发生在视觉更新之后,所以它对用户的体验影响极小。你看到的是最终的画面,如果它在 useEffect 里改了点什么(比如修改了 URL),你不会感觉到任何卡顿或闪烁。它就像一个下班后才发现工地上有个螺丝松了的保安,虽然晚,但没事。

2. useLayoutEffect:那个“同步暴君”

useLayoutEffect 是个急性子,也是个独裁者。它的进场时机非常霸道,直接插队到了阶段二和阶段三之间

流程是这样的:

  • React 更新状态。
  • 浏览器开始计算布局(刚刚量完尺寸)。
  • 突然! React 唤醒了 useLayoutEffect,说:“快!给我干活!”
  • useLayoutEffect 立即开始执行代码。因为它是在浏览器开始绘制之前执行的,所以它是同步的。这意味着,浏览器根本没法画画,必须停下来,死死地盯着 useLayoutEffect,直到它把活干完。
  • useLayoutEffect 执行完毕(可能修改了 DOM,可能计算了尺寸)。
  • 浏览器才敢继续进行绘制合成

这就好比你正准备在墙上挂画,结果装修队长突然冲进来,抢过你的锤子,把墙砸了个洞,非要自己重新量一遍尺寸。你只能在旁边干瞪眼,画是绝对画不上去的。

第三幕:白屏的恐怖故事

现在,我们要搞清楚为什么 useLayoutEffect 更容易导致白屏。这得从JS 主线程的独占性说起。

浏览器是单线程的,JS 代码执行、DOM 操作、样式计算,全部都在这唯一的主线程上排队。而 useLayoutEffect 是同步执行的,它就像一条拦路虎,挡在了浏览器渲染流水线的咽喉要道上。

场景模拟:用户点击了一个按钮,触发数据加载。

我们写了一段代码,看起来很正常,但里面藏着一个 useLayoutEffect

function MyComponent() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 模拟异步数据获取
    fetch('/api/data').then(res => res.json()).then(json => {
      setData(json);
      setIsLoading(false);
    });
  }, []);

  // 危险区域:同步布局计算
  useLayoutEffect(() => {
    // 假设我们想在渲染前计算一下宽度,或者做点复杂的 DOM 操作
    if (data) {
      const element = document.getElementById('my-content');
      if (element) {
        // 这里可能发生重排
        element.style.width = Math.random() * 500 + 'px'; 
      }
    }
  }, [data]);

  return (
    <div id="my-content">
      {data ? <div>{data.text}</div> : <div>Loading...</div>}
    </div>
  );
}

让我们看看时间线发生了什么:

  1. T0 (用户点击): 用户点击按钮。
  2. T1 (React 更新): React 更新 datanullisLoadingtrue
  3. T2 (浏览器布局阶段): 浏览器看到 isLoading 变了,开始计算布局。此时,屏幕上显示的是“Loading…”。
  4. T3 (useLayoutEffect 执行 – 阻塞点):
    • React 调用 useLayoutEffect
    • 这段代码开始同步执行。
    • 如果你的 useLayoutEffect 里面包含复杂的计算(比如上面的 Math.random(),或者更糟糕的,一个巨大的循环计算),或者包含一些阻塞主线程的操作(比如同步的 alert,或者一个非常重的 DOM 操作)。
    • 关键点来了: 浏览器此时正准备开始绘制下一帧。但是,它被 useLayoutEffect 锁住了。它不能画,因为 React 还没说画呢,React 在等 useLayoutEffect 结束。
  5. T4 (useLayoutEffect 结束): 经过 100ms(或者 500ms,取决于你的代码多烂)的计算,useLayoutEffect 终于跑完了。
  6. T5 (浏览器绘制): 浏览器终于拿到了最新的 DOM 状态(此时 data 已经是真实数据了),开始绘制。

结果是什么?

  • 情况 A:白屏(闪烁)
    用户在 T2-T4 这段时间里,屏幕上显示的是上一帧的画面(可能是空的,也可能是“Loading…”)。然后到了 T5,画面突然跳变成了新数据。对于用户来说,屏幕上闪过一瞬间的空白或者旧内容,然后才显示新内容。这就是白屏闪烁。虽然不是完全的白屏,但体验极差。

  • 情况 B:完全白屏(如果 JS 执行时间过长)
    如果你的 useLayoutEffect 里面写了一个死循环,或者一个极其耗时的计算(比如递归遍历巨大的树结构),而浏览器为了等待这个同步函数完成,根本来不及进入绘制阶段。
    那么用户看到的屏幕,会长时间停留在上一帧的状态,甚至如果是首次加载,用户可能根本看不到任何内容,只能看到浏览器原本的背景色。这就叫白屏

第四幕:隐藏元素的“测量陷阱”

除了计算密集型任务,还有一个非常经典的导致白屏的原因:useLayoutEffect 里测量隐藏元素

这是一个非常常见的 React 技巧:先创建一个隐藏的 div,往里面塞入一段 HTML 字符串,然后通过 offsetWidth 等属性去获取它的宽度,计算好之后再把 div 删掉,最后在屏幕上渲染真正的内容。

看起来很聪明,对吧?但这在 useLayoutEffect 里就是一颗定时炸弹。

function MeasureText() {
  const [dimensions, setDimensions] = useState({ width: 0 });

  useLayoutEffect(() => {
    const hiddenDiv = document.createElement('div');
    hiddenDiv.style.visibility = 'hidden';
    hiddenDiv.style.position = 'absolute';
    hiddenDiv.style.whiteSpace = 'nowrap';
    hiddenDiv.innerText = '这是一段很长的文本';
    document.body.appendChild(hiddenDiv);

    // 获取宽度
    const width = hiddenDiv.offsetWidth;

    // 清理工作
    document.body.removeChild(hiddenDiv);

    setDimensions({ width });
  }, []);

  return <div style={{ width: dimensions.width }}>真实内容</div>;
}

让我们看看为什么这会白屏:

  1. T0: 组件首次挂载。
  2. T1: React 渲染组件。此时 dimensions{ width: 0 }。屏幕上显示一个宽度为 0 的 div
  3. T2: 浏览器开始布局计算。它看到宽度为 0,没问题。
  4. T3: 浏览器准备绘制。此时,useLayoutEffect 被调用。
  5. T4 (阻塞):
    • useLayoutEffect 创建了 hiddenDiv 并添加到 document.body
    • 注意! 此时 DOM 结构变了!
    • 浏览器原本准备画那个宽度为 0 的 div 的计划被打乱了。
    • 浏览器必须重新计算布局,因为 document.body 里突然多了一个节点。
    • useLayoutEffect 读取 offsetWidth
    • useLayoutEffect 删除 hiddenDiv。DOM 结构又变了!
    • 浏览器再次不得不重新计算布局。
  6. T5: useLayoutEffect 结束。
  7. T6: 浏览器终于可以绘制了。它根据最新的 DOM(那个宽度为 0 的 div)进行绘制。

发生了什么?
在 T2 到 T6 之间,屏幕上实际上一直显示的是上一帧的画面。由于 useLayoutEffect 的同步特性,浏览器被迫在同一个渲染帧内反复计算布局。如果这个计算过程稍微慢一点(比如你的机器性能不好,或者浏览器开销大),用户就会看到屏幕上一瞬间的空白,或者看到“真实内容”的宽度为 0 的情况,然后突然弹跳到正确宽度。

这就是典型的布局抖动,也是白屏的前兆。

第五幕:为什么 useEffect 就没事?

回到 useEffect。如果上面的 MeasureText 代码里的 useLayoutEffect 换成 useEffect,会发生什么?

流程变成:

  1. T0: 组件挂载。
  2. T1: React 渲染。dimensions 为 0。
  3. T2: 浏览器布局计算。
  4. T3: 浏览器绘制。屏幕上显示宽度为 0 的 div
  5. T4: 浏览器合成。
  6. T5: useEffect 终于执行了!(此时用户已经看到宽度为 0 的画面了)。
  7. T6: useEffect 计算、创建、删除 hiddenDiv
  8. T7: useEffect 更新 dimensions 为真实宽度。
  9. T8: React 触发下一次渲染。

结果:
用户在第一眼看到的是宽度为 0 的 div(因为那是浏览器绘制的),然后过了一小会儿(通常只有几毫秒到几十毫秒),React 重新渲染,div 变成了正确宽度。
这叫闪烁,虽然不好看,但不是白屏。因为 useEffect 没有阻塞浏览器的绘制流程。浏览器已经把像素画到屏幕上了,useEffect 只是事后修改了数据,让下一帧画得更准一点。虽然用户感觉到了“跳变”,但屏幕不会出现长时间的空白。

第六幕:实战演练——如何避坑

既然知道了 useLayoutEffect 是个同步阻塞怪兽,我们在写代码时就得像防贼一样防着它。

1. 检查你的代码:有循环吗?

如果你的 useLayoutEffect 里面有一个 while(true),或者一个递归调用,恭喜你,你的页面直接死机。白屏是轻的,浏览器可能会直接提示“页面无响应”。

// 绝对禁止的写法!
useLayoutEffect(() => {
  while (true) {
    console.log('我在死循环');
  }
}, []);

2. 检查你的代码:有同步 alert 吗?

alert 是同步阻塞的,它会暂停整个 JS 主线程。如果在 useLayoutEffect 里弹出一个 alert,浏览器会死死等用户点掉 alert 才能继续渲染。这绝对会白屏。

// 绝对禁止的写法!
useLayoutEffect(() => {
  alert('请先点击确定,我才能渲染页面');
}, []);

3. 检查你的代码:有隐藏元素测量吗?

尽量把隐藏元素的操作移到 useEffect 里。虽然 useEffect 会导致初始渲染时的尺寸跳动,但它不会阻塞浏览器,不会导致白屏。

// 推荐的写法
useEffect(() => {
  const hiddenDiv = document.createElement('div');
  // ... 测量逻辑
  setDimensions({ width: hiddenDiv.offsetWidth });
  // ... 清理逻辑
}, []);

4. 尽量减少 DOM 操作

useLayoutEffect 的核心问题是同步。一旦你动了 DOM,浏览器就必须重新计算布局。如果你在 useLayoutEffect 里疯狂地操作 DOM(比如批量插入 1000 个节点),浏览器会累得喘不过气来。

5. 使用 requestAnimationFrame 替代(如果可能)

有时候我们想在绘制前做点事,但不想阻塞。虽然 React 的生命周期里没有直接对应 requestAnimationFrame 的钩子,但我们可以利用 requestAnimationFrameuseLayoutEffect 的末尾执行一些非关键任务,或者使用 useEffect 来处理视觉反馈。

总结

所以,为什么 useLayoutEffect 更容易导致白屏?

因为它是一个同步的、阻塞式的、插队到浏览器绘制前一刻的监工

它强迫浏览器在每一帧渲染前,必须先完成它的计算任务。如果它的任务太重,或者触发了额外的 DOM 变化导致浏览器反复计算布局,那么浏览器就会卡在“计算”这一步,而无法进入“绘制”这一步。屏幕上留存的,就是上一帧的画面,或者原本的空白。

相比之下,useEffect 是个“事后诸葛亮”,它不抢戏,不阻塞,哪怕它把房子装修完了再告诉你,房子也已经盖好了,你只是看不到装修的过程而已。

记住这个原则:useLayoutEffect 里,每一毫秒都至关重要。如果你在写代码时感觉到手指头停顿了一下,或者代码跑得有点慢,赶紧检查一下是不是你的 useLayoutEffect 又在搞破坏了。

好了,今天的讲座就到这里。现在,去检查你的代码,看看有没有哪个 useLayoutEffect 正在屏幕后面搞鬼吧!

发表回复

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