React useLayoutEffect 同步执行阻塞分析

各位来宾,欢迎来到今天的“React 内部架构解密”特别讲座。

我是你们的老朋友,一个在代码堆里摸爬滚打多年,看着 React 从 ClassComponent 变成 Hooks,又看着 Concurrent Mode(并发模式)像个幽灵一样若隐若现的资深编程专家。

今天我们不聊业务,不聊怎么把组件拆得像俄罗斯套娃一样漂亮。我们要聊一个有点“硬核”,有点“带感”,甚至会让你的用户体验瞬间从“丝般顺滑”变成“卡顿到怀疑人生”的话题——useLayoutEffect 与它的同步阻塞机制

别被这个名字吓到了,它听起来像是某种高深的魔法,但实际上,它就是浏览器主线程上的一场“强制加班”。

准备好了吗?让我们把目光聚焦在 React 的渲染流程上。

第一部分:渲染的“幕后花絮”

首先,我要纠正一个很多开发者心中的误解。React 的渲染,并不是像画画那样,拿起笔(DOM)就往上画。React 有它自己的节奏,我们可以把 React 的渲染周期想象成一场大型舞台剧

1. 渲染阶段:脑力劳动者

在这个阶段,React 会做很多数学题。它会计算哪些组件需要更新,哪些 props 变了,哪些 state 变了。它需要把虚拟 DOM 树和旧的虚拟 DOM 树进行比对(Diff 算法)。

在这个阶段,React 是异步的。它就像是一个正在算账的会计,算到一半,如果用户突然点了个按钮,React 会停下来,甚至可能直接放弃刚才算到一半的账,重新开始算。

2. 提交阶段:搬砖工人

一旦计算完成,React 就要开始干活了。它会把计算结果变成真正的 DOM 节点,插入到浏览器里。

注意了,这是关键点! 在提交阶段,React 会依次执行两个东西:

  1. useLayoutEffect:这是 React 在把东西画到屏幕上之前,在浏览器绘制(Paint)之前,必须完成的同步任务。
  2. 浏览器绘制:浏览器拿到 DOM 变化,开始把像素点画到屏幕上。

3. useEffect:事后诸葛亮

至于 useEffect,它就像是一个打扫卫生的保洁阿姨。等舞台剧演完了,灯光亮了,观众走光了,保洁阿姨才进场。这时候,浏览器已经画好图了,用户已经看到画面了。保洁阿姨再去做一些清理工作(比如发网络请求),完全不会影响观众看戏。

总结一下:

  • useEffect:异步,在绘制后执行。不阻塞
  • useLayoutEffect:同步,在绘制前执行。阻塞

第二部分:同步阻塞的“恐怖故事”

为什么 useLayoutEffect 会阻塞?因为它是同步的。它必须等待它执行完毕,浏览器才能去绘制。

这就好比你在装修房子。useLayoutEffect 是那个必须在开灯前把插座接好的电工。如果这个电工是个“卷王”,他在插座接好后,突然决定把整个房子的电路系统重写一遍,还顺便写了一篇几千行的代码分析报告。

结果是什么?你点了开关,但是灯不会亮,因为电工还在写代码。你一直等到他写完,灯才会突然“啪”地一下亮起来。

在 React 里,这个过程叫Layout PaintuseLayoutEffect 就是在这个瞬间之前,硬生生地把浏览器的主线程给占住了。

让我们来看一段代码,体验一下什么叫“白屏恐惧症”。

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

function HeavyLayoutEffect() {
  const [count, setCount] = useState(0);

  // 这是一个极其愚蠢的 useLayoutEffect
  useLayoutEffect(() => {
    console.log('useLayoutEffect 开始执行,开始做数学题...');

    // 模拟一个极其耗时的计算,比如计算斐波那契数列
    // 斐波那契(40) 就已经很大了,斐波那契(45) 就会导致卡顿
    const n = 45; 
    let result = 0;
    for(let i = 0; i < 100000000; i++) { // 1亿次循环,纯粹为了卡死你
        result += i;
    }

    console.log('数学题做完了,终于可以画图了');
  }, [count]); // 每次 count 变化都会触发

  return (
    <div style={{ padding: '20px' }}>
      <h1>当前数字: {count}</h1>
      <button onClick={() => setCount(c => c + 1)}>
        点击我 (这会导致卡顿)
      </button>
    </div>
  );
}

export default HeavyLayoutEffect;

发生了什么?
当你点击按钮,setCount 触发。React 进入渲染阶段。然后,它发现组件里有个 useLayoutEffect。好家伙,它二话不说,直接把主线程抢过来,开始跑那个 1 亿次循环。

此时,浏览器是什么状态?
它收到了 DOM 变化的指令(数字从 0 变成了 1),它想画图。但是,它手里没有活干啊,因为主线程被 React 抢走了。浏览器会一直等待,等待 JS 代码执行完毕。

用户体验:
屏幕会瞬间变白(或者保留上一帧的图像),你的鼠标点击完全没反应,CPU 占用率直接飙到 100%。直到那个 useLayoutEffect 循环跑完,浏览器才会“啪”地把新画面画出来。

这就是同步阻塞。它剥夺了浏览器的绘制权,直到 JS 代码执行完毕。

第三部分:DOM 操作的“幽灵闪烁”

useLayoutEffect 经常被用来做 DOM 操作,特别是读取布局信息,比如 getBoundingClientRect。这通常是为了做动画的初始化。

比如,我们想做一个侧边栏从左侧滑入的动画。

错误的写法(在 useEffect 里做):

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

function Sidebar() {
  const [isOpen, setIsOpen] = useState(false);
  const sidebarRef = useRef(null);

  // useEffect 是异步的,发生在绘制之后
  useEffect(() => {
    if (isOpen && sidebarRef.current) {
      // 这里读取宽度
      const width = sidebarRef.current.getBoundingClientRect().width;
      // 这里设置宽度
      sidebarRef.current.style.width = `${width}px`;
    }
  }, [isOpen]);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>打开侧边栏</button>
      <div 
        ref={sidebarRef}
        style={{ 
          position: 'absolute', 
          left: 0, 
          width: '0px', // 初始宽度为0
          background: 'blue',
          height: '100%',
          transition: 'width 0.3s ease' // CSS 过渡
        }}
      >
        侧边栏内容
      </div>
    </div>
  );
}

现象:
你点开按钮。首先,浏览器画出了侧边栏(宽度为 0)。然后,useEffect 开始运行,读取宽度,设置宽度。紧接着,浏览器根据 CSS 的 transition 属性开始动画,把宽度从 0 慢慢变成设定值。

结果:
你会看到侧边栏先“瞬移”了一下(虽然只是 0 到宽度的瞬间跳变),然后才开始动画。这就是幽灵闪烁。用户看到了不该看到的瞬间状态。

正确的写法(在 useLayoutEffect 里做):

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

function SidebarCorrect() {
  const [isOpen, setIsOpen] = useState(false);
  const sidebarRef = useRef(null);

  // useLayoutEffect 是同步的,发生在绘制之前
  useLayoutEffect(() => {
    if (isOpen && sidebarRef.current) {
      // 读取宽度
      const width = sidebarRef.current.getBoundingClientRect().width;
      // 设置宽度
      sidebarRef.current.style.width = `${width}px`;
    }
  }, [isOpen]);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>打开侧边栏</button>
      <div 
        ref={sidebarRef}
        style={{ 
          position: 'absolute', 
          left: 0, 
          width: '0px', 
          background: 'blue',
          height: '100%',
          transition: 'width 0.3s ease' 
        }}
      >
        侧边栏内容
      </div>
    </div>
  );
}

现象:
React 计算出 isOpen 变为 true。useLayoutEffect 立即同步执行,把宽度设好了。此时,浏览器还没来得及画图,它看到的数据已经是正确的宽度了。然后浏览器画图,CSS 动画开始。完美! 没有任何闪烁。

第四部分:深入剖析“阻塞”的本质

为什么 useLayoutEffect 会阻塞?这涉及到浏览器的渲染管线。

  1. JavaScript 执行: 主线程在跑 JS 代码。
  2. Layout(布局计算): 浏览器计算元素的位置和大小。
  3. Paint(绘制): 浏览器把像素画到屏幕上。

React 的策略:
React 想要保证 useLayoutEffect 执行完毕后,浏览器看到的 DOM 状态是最终状态。如果 useLayoutEffect 改变了 DOM,浏览器必须等到它执行完,才能去计算布局和绘制。

这就导致了“阻塞”:
如果 useLayoutEffect 执行时间过长(比如上面的 1 亿次循环),那么浏览器就会被一直卡在 JavaScript 执行阶段,Layout 和 Paint 阶段完全被推迟。

更糟糕的是“幽灵输入”:
因为主线程被卡住了,用户在这个期间点击的任何事件(鼠标、键盘)都会被放入事件队列中排队等待。
useLayoutEffect 终于执行完毕,浏览器开始绘制。紧接着,它处理事件队列。这会导致用户的点击动作和视觉更新不同步。有时候你会感觉点击了没反应,或者点击后画面跳变。

第五部分:flushSync —— 强制同步的武器

既然 useLayoutEffect 是同步的,那有没有办法让普通的 useState 更新也变成同步的?有,React 提供了一个叫做 ReactDOM.flushSync 的 API。

flushSync 的作用是强制 React 将更新包裹在一个同步渲染中。这会打断 React 的并发渲染机制,确保更新立即应用到 DOM 上,并且立即执行后续的 useLayoutEffect

场景:
假设你有一个计数器,你希望点击按钮时,数字瞬间增加,而不是在 useEffect 里异步处理,导致视觉延迟。

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 普通的 setState 是异步的(在非严格模式下),会触发重新渲染
    // 但这里我们想确保它同步执行,防止视觉跳动

    // 使用 flushSync 强制同步
    ReactDOM.flushSync(() => {
      setCount(c => c + 1);
    });

    // 此时,count 已经变了,React 已经重新渲染并提交了
    // 我们可以立即执行一些需要基于最新 state 的逻辑
    console.log('点击后立即执行,count 是:', count);
  };

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={handleClick}>增加</button>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Counter />);

注意:
flushSync 不是万能药。它会强制同步渲染,这会消耗主线程资源,可能导致其他交互变卡。它通常只在需要防止视觉闪烁,并且逻辑非常简单直接时使用。

第六部分:React 18 并发模式下的新挑战

好,聊到现在,大家可能觉得 useLayoutEffect 就是“在绘制前同步运行”这么简单。

但是,现在是 React 18 时代,我们有了并发模式。这就像是给渲染引擎装上了“暂停键”和“倒带键”。

在并发模式下,useLayoutEffect 的行为变得更加微妙。

1. 中断与重启

在并发模式下,React 可以中断当前的渲染任务。
假设你在 useLayoutEffect 里有一个非常耗时的操作。React 开始渲染,useLayoutEffect 开始执行。突然,用户点击了另一个按钮,或者输入了文字。React 会暂停当前的 useLayoutEffect,保存现场,去处理新的更新。

这会导致什么?

  • 旧的状态被保留。
  • 新的状态可能正在计算。
  • useLayoutEffect 被挂起。

当用户停止操作,React 恢复渲染。它会合并之前的计算结果和新的计算结果,然后重新执行 useLayoutEffect

这有什么后果?
如果你的 useLayoutEffect 依赖了某些状态,而状态在这个过程中被多次更新,useLayoutEffect 可能会被执行多次。虽然 React 会尽量优化,但如果你在里面做了副作用(比如修改 DOM),你可能会看到 DOM 被反复操作,导致性能下降或不可预期的行为。

2. useLayoutEffectuseEffect 的区别

在 React 18 中,useEffect 也会被暂停和合并。但是 useLayoutEffect 因为是同步的,它在提交阶段执行。如果渲染被中断了,那么提交阶段可能根本不会发生。

这意味着,如果你在 useLayoutEffect 里有一些副作用(比如测量 DOM 大小),而这些副作用又触发了状态更新,导致渲染被中断,那么这些副作用可能永远不会执行,或者只在最终渲染时执行一次。

第七部分:实战中的“避坑指南”

作为资深专家,我看过太多因为滥用 useLayoutEffect 而导致应用卡顿的代码。下面是我总结的几条黄金法则,请务必刻在脑子里:

规则 1:禁止在 useLayoutEffect 里做数学题

这是铁律。useLayoutEffect 是同步的,它会阻塞 UI。如果你在里面做复杂计算,用户会看到白屏。

错误示范:

useLayoutEffect(() => {
  // 这里做图像处理、大数据计算、复杂的正则匹配
  const heavyData = processLargeDataset(data); 
  // ...
}, []);

正确做法:
把计算放到 useEffect 里(异步),或者使用 Web Worker。

规则 2:谨慎使用 DOM 查询

getBoundingClientRectoffsetHeight 等操作需要读取浏览器布局,这本身就会触发重排。如果你在 useLayoutEffect 里频繁调用,或者调用链很长,那就是在主线程上反复摩擦。

优化:
尽量减少 DOM 查询的次数。利用 ref 缓存值,而不是每次都去读 DOM。

规则 3:不要在 useLayoutEffect 里做 API 请求

这听起来很反直觉,但 useLayoutEffect 是同步的。虽然 API 请求本身是异步的(不会阻塞主线程),但它的回调函数是同步执行的。

如果你在 useLayoutEffect 里发起请求,然后拿到数据去更新 state。由于 useLayoutEffect 执行完毕后,React 会立即进入提交阶段。这意味着,你的数据请求还没回来,React 就已经渲染了 UI。

结果:
你可能会看到闪烁的“加载中”状态,然后瞬间跳变到实际内容。这还不如直接在 useEffect 里做,至少 useEffect 是在绘制后,用户已经看到骨架屏或加载状态了。

规则 4:区分“副作用”和“布局副作用”

  • 副作用: 网络请求、订阅、日志记录。这些对 UI 视觉没有直接影响,放在 useEffect
  • 布局副作用: 读取 DOM 尺寸、设置 CSS 样式以配合布局。这些对 UI 视觉有直接影响,必须放在 useLayoutEffect

第八部分:代码实验室——对比实验

让我们来做一个终极对比实验。我们将创建三个组件,分别展示:

  1. useEffect 的异步行为。
  2. useLayoutEffect 的同步阻塞行为。
  3. 优化的 useLayoutEffect

实验 1:异步的 useEffect

import React, { useState, useEffect } from 'react';

export function EffectDemo() {
  const [width, setWidth] = useState(0);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    console.log('useEffect: 开始执行');
    // 模拟耗时操作
    setTimeout(() => {
      console.log('useEffect: 耗时操作完成,设置宽度');
      setWidth(300);
      setIsLoaded(true);
    }, 1000);
  }, []);

  return (
    <div style={{ border: '1px solid red', padding: 10 }}>
      <h3>useEffect Demo</h3>
      <div style={{ 
        width: width, 
        height: '50px', 
        background: isLoaded ? 'green' : 'gray',
        transition: 'width 1s ease' // 动画
      }}></div>
      <p>状态: {isLoaded ? '加载完成' : '加载中...'}</p>
    </div>
  );
}

观察: 组件挂载 -> 灰色块(0宽) -> 1秒后变绿 -> 绿色块动画展开。

实验 2:阻塞的 useLayoutEffect

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

export function LayoutEffectDemo() {
  const [width, setWidth] = useState(0);
  const [isLoaded, setIsLoaded] = useState(false);

  useLayoutEffect(() => {
    console.log('useLayoutEffect: 开始执行');
    // 模拟耗时操作
    setTimeout(() => {
      console.log('useLayoutEffect: 耗时操作完成');
      setWidth(300);
      setIsLoaded(true);
    }, 1000);
  }, []);

  return (
    <div style={{ border: '1px solid blue', padding: 10 }}>
      <h3>useLayoutEffect Demo</h3>
      <div style={{ 
        width: width, 
        height: '50px', 
        background: isLoaded ? 'green' : 'gray',
        transition: 'width 1s ease' 
      }}></div>
      <p>状态: {isLoaded ? '加载完成' : '加载中...'}</p>
    </div>
  );
}

观察: 组件挂载 -> 白屏/无反应 1 秒 -> 突然变绿并展开。
原因: useLayoutEffect 同步阻塞了主线程,导致浏览器无法绘制。直到 1 秒后 JS 代码跑完,浏览器才一次性把所有状态(包括灰色的 0 宽和绿色的 300 宽)画出来。

实验 3:优化的 useLayoutEffect

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

export function OptimizedLayoutEffect() {
  const [isOpen, setIsOpen] = useState(false);
  const sidebarRef = useRef(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  // 只在状态变化时读取 DOM,且是同步的
  useLayoutEffect(() => {
    if (sidebarRef.current) {
      const rect = sidebarRef.current.getBoundingClientRect();
      setDimensions({
        width: rect.width,
        height: rect.height
      });
    }
  }, [isOpen]); // 依赖 isOpen

  return (
    <div style={{ border: '1px solid orange', padding: 10 }}>
      <h3>Optimized Demo</h3>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>

      <div 
        ref={sidebarRef}
        style={{ 
          position: 'absolute', 
          left: isOpen ? 0 : -200, // 使用 CSS 控制位置,而不是 JS 修改 width
          width: '200px',
          height: '100px',
          background: 'orange',
          transition: 'left 0.3s ease' // CSS 动画
        }}
      >
        侧边栏
      </div>
    </div>
  );
}

观察: 点击按钮 -> 侧边栏平滑滑入。没有闪烁。
原因: 我们没有在 useLayoutEffect 里做耗时计算,只是读取了 DOM 尺寸(非常快),并利用 CSS 的 transition 来处理动画。这展示了如何正确使用 useLayoutEffect

第九部分:总结与展望

好了,各位,今天的讲座接近尾声。

回顾一下,我们聊了:

  1. React 的渲染阶段和提交阶段。
  2. useEffectuseLayoutEffect 的执行时机差异。
  3. useLayoutEffect同步阻塞特性及其带来的“白屏”和“幽灵输入”问题。
  4. 如何利用 useLayoutEffect 处理布局副作用(如读取 DOM 尺寸)。
  5. 在 React 18 并发模式下,useLayoutEffect 的中断与重绘行为。
  6. 最佳实践:不做数学题,不搞网络请求,善用 CSS 动画。

核心要点:
useLayoutEffect 就像一个必须要同步完成的前台服务员。他必须在你上菜之前把桌子擦干净(布局计算)。如果你让他去后厨切菜(做计算),那整个餐厅(浏览器)都会乱套,因为主厨(主线程)被卡住了。

作为开发者,我们的目标就是不要让这个服务员做他不该做的事

如果只是简单的布局调整,让他去做,没问题,这能保证用户体验。
如果涉及复杂逻辑,请把他支开,让他去后厨(useEffect)慢慢磨。

最后,我想说,React 的设计哲学一直是“声明式”和“高效”。useLayoutEffect 虽然强大,但它是一个“双刃剑”。它给了我们控制 DOM 的权力,但也要求我们必须对主线程的负载负责。

希望今天的讲座能让你在面对 useLayoutEffect 时,不再感到迷茫,而是充满自信,知道何时该用它,何时该绕着它走。

谢谢大家,下课!

发表回复

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