React useLayoutEffect 完全指南

25 阅读2分钟

React useLayoutEffect 完全指南

一、核心定义与执行时机

useLayoutEffect 是 React 提供的用于处理DOM 相关同步操作的 Hook,其执行时机具有严格的顺序性,且是同步阻塞的(会阻塞浏览器绘制,直到回调函数执行完毕)。

单次组件更新周期的执行顺序:

组件 render 生成新 DOMuseLayoutEffect 回调同步执行浏览器绘制页面useEffect 回调异步执行

二、核心使用场景:解决 DOM 闪烁/抖动问题

useLayoutEffect 的核心价值在于处理需要依赖更新后 DOM 信息,且需要同步修改 DOM 以避免页面闪烁/抖动的场景,常见场景包括:

  1. 同步读取并修改 DOM 样式(如获取元素宽高后,立即调整其位置、尺寸);

  2. 计算 DOM 元素的布局信息(如滚动位置、偏移量)并同步更新组件状态;

  3. 实现无闪烁的 DOM 操作(避免 useEffect 异步执行导致的"先渲染错误样式,再修正"的闪烁问题)。

代码示例:避免 DOM 位置闪烁


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

function LayoutEffectDemo() {
  const [show, setShow] = useState(false);
  const boxRef = useRef(null);

  useLayoutEffect(() => {
    if (show && boxRef.current) {
      const { height } = boxRef.current.getBoundingClientRect();
      boxRef.current.style.transform = `translateY(${height + 10}px)`;
    } else if (boxRef.current) {
      boxRef.current.style.transform = 'translateY(0)';
    }
  }, [show]);

  return (
    <div>
      <button onClick={() => setShow(!show)}>切换显示/隐藏</button>
      {show && (
        <div 
          ref={boxRef}
          style={{ 
            width: '200px', 
            height: '100px', 
            background: 'blue', 
            transition: 'transform 0.3s ease' 
          }}
        >
          测试 useLayoutEffect 无闪烁布局
        </div>
      )}
    </div>
  );
}

export default LayoutEffectDemo;

三、与 useEffect 的核心区别

特性useLayoutEffectuseEffect
执行时机DOM 更新后、浏览器绘制前(同步)浏览器绘制完成后(异步)
阻塞特性阻塞浏览器绘制,同步执行不阻塞浏览器绘制,异步执行(不阻塞主线程)
适用场景DOM 布局相关同步操作(避免闪烁)数据请求、订阅/取消订阅、非 DOM 相关副作用
执行优先级更高(早于 useEffect)更低(晚于 useLayoutEffect)
对用户体验的影响执行耗时过长会导致页面卡顿(阻塞绘制)几乎不影响页面渲染流畅度
关键补充:若副作用不依赖 DOM 信息,优先使用 useEffect,避免不必要的阻塞,保证页面渲染性能。

四、DOM 更新后绘制前能否获取元素大小和位置

可以的

  1. DOM 更新完成意味着内存中的 DOM 树已经修改完毕,元素的结构、属性、样式都已确定,其大小和位置等布局信息是 DOM 节点的固有属性,无需等待浏览器绘制即可读取。

  2. useLayoutEffect 正好在这个时机执行,通过 getBoundingClientRect()offsetWidthoffsetTop 等 API 能获取到准确的元素布局信息。

代码验证:读取元素布局信息


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

function GetElementLayoutDemo() {
  const [isShow, setIsShow] = useState(false);
  const elementRef = useRef(null);

  useLayoutEffect(() => {
    if (isShow && elementRef.current) {
      const rect = elementRef.current.getBoundingClientRect();
      console.log('元素大小和位置(绘制前):', {
        宽度: rect.width,
        高度: rect.height,
        距离视口顶部: rect.top,
        距离视口左侧: rect.left
      });

      console.log('元素偏移宽高:', {
        offsetWidth: elementRef.current.offsetWidth,
        offsetHeight: elementRef.current.offsetHeight,
        offsetTop: elementRef.current.offsetTop
      });
    }
  }, [isShow]);

  return (
    <div style={{ padding: 20 }}>
      <button onClick={() => setIsShow(!isShow)}>
        {isShow ? '隐藏元素' : '显示元素并读取布局'}
      </button>
      {isShow && (
        <div
          ref={elementRef}
          style={{
            width: 300,
            height: 200,
            background: 'green',
            margin: 10,
            position: 'relative',
            top: 20
          }}
        >
          测试布局读取的元素
        </div>
      )}
    </div>
  );
}

export default GetElementLayoutDemo;

五、useLayoutEffect 如何感知执行时机

useLayoutEffect 不是自身“主动知道”时机,而是 React 内部更新调度机制将其精准嵌入到 DOM 操作和浏览器绘制之间的固定流程,底层依赖浏览器的渲染流水线和任务队列机制。

1. React 组件更新的核心流程

  1. 触发更新state/props 变化触发组件重新渲染;

  2. 虚拟 DOM 对比:通过 Diff 算法计算真实 DOM 的最小修改操作;

  3. 执行 DOM 操作:React 提交阶段,将 DOM 修改同步应用到真实 DOM 树(内存中 DOM 已更新,未绘制);

  4. 执行 useLayoutEffect 回调:React 内置步骤,DOM 变更后立即同步调用回调;

  5. 释放主线程useLayoutEffect 执行完毕,浏览器获得主线程控制权;

  6. 浏览器绘制:执行绘制流程,将 DOM 渲染到屏幕;

  7. 执行 useEffect 回调:浏览器绘制完成后,主线程空闲时异步执行。

2. 浏览器底层机制支撑

  • 渲染流水线顺序:浏览器处理 DOM 遵循 解析 HTML → 构建 DOM 树 → 构建 CSSOM 树 → 生成布局树 → 绘制 → 合成 的固定步骤,前一步完成才能进入后一步;

  • 任务优先级useLayoutEffect 是主线程的同步任务,优先级高于浏览器绘制的宏任务,因此会阻塞绘制,直到自身执行完毕。

六、useLayoutEffect 回调执行是否阻塞绘制

会阻塞,这是其核心特性之一,阻塞的本质是浏览器单线程模型 + 同步执行特性

  1. 浏览器主线程同一时间只能执行一个任务,useLayoutEffect 回调是同步执行的,会独占主线程;

  2. 浏览器的绘制任务会被搁置在任务队列,必须等 useLayoutEffect 回调执行完毕、主线程释放后才能启动;

  3. 若回调内包含耗时操作,会导致页面卡顿、渲染延迟。

错误示例:耗时操作导致阻塞


useLayoutEffect(() => {
  if (boxRef.current) {
    // 大量同步循环,阻塞绘制很久
    let total = 0;
    for (let i = 0; i < 100000000; i++) {
      total += i;
    }
    console.log(total);
    boxRef.current.style.left = '100px';
  }
}, []);

七、DOM 变更后绘制被阻塞的场景

浏览器绘制被阻塞的核心原因是:主线程被未完成的同步任务占用,无法执行绘制操作,分为两类场景:

1. React 框架层面

useLayoutEffect 回调的同步执行是最典型场景,React 刻意设计该机制,以实现无闪烁的 DOM 调整。

2. 浏览器原生层面

脱离 React 时,DOM 变更后主线程存在未完成的同步任务,也会阻塞绘制:

  • DOM 变更后立即执行耗时同步代码:如大量循环计算、复杂 DOM 遍历;

  • 强制同步布局:循环中交替读取和修改 DOM 布局属性(如 offsetWidth + style.width),导致浏览器反复计算布局。

原生场景阻塞示例


// 1. DOM 变更
const div = document.createElement('div');
div.style.width = '100px';
document.body.appendChild(div);

// 2. 同步耗时任务阻塞绘制
let total = 0;
for (let i = 0; i < 100000000; i++) {
  total += i;
}
// 循环结束后才会绘制 div

八、useLayoutEffect 性能优化清单

1. 核心使用原则:能不用就不用

  • **优先用 ** useEffect:仅在需要同步处理 DOM 布局、避免闪烁时使用;

  • 严格控制依赖项:用精准的 DOM 相关状态作为依赖,避免频繁触发。

    
    // ❌ 依赖宽泛
    useLayoutEffect(() => { /* DOM 操作 */ }, [props, state]);
    // ✅ 依赖精准
    useLayoutEffect(() => { /* DOM 操作 */ }, [state.showElement]);
    

2. 代码优化:最小化回调执行时间

  • 移除非 DOM 相关逻辑:耗时计算、数据处理等移到 useMemo/useEffect 或组件外部。

    
    // ❌ 耗时计算阻塞绘制
    useLayoutEffect(() => {
      const total = heavyCalculation();
      const rect = ref.current.getBoundingClientRect();
      ref.current.style.top = `${rect.top + total}px`;
    }, []);
    
    // ✅ 提前计算
    const total = useMemo(() => heavyCalculation(), []);
    useLayoutEffect(() => {
      const rect = ref.current.getBoundingClientRect();
      ref.current.style.top = `${rect.top + total}px`;
    }, [total]);
    
  • 批量执行 DOM 操作:遵循 先读后写 原则,避免频繁触发重排。

    
    // ❌ 频繁读写触发多次重排
    useLayoutEffect(() => {
      const list = ref.current.querySelectorAll('li');
      list.forEach(item => {
        item.style.width = `${item.offsetWidth + 10}px`;
      });
    }, []);
    
    // ✅ 先读后写,批量修改
    useLayoutEffect(() => {
      const list = ref.current.querySelectorAll('li');
      const widths = Array.from(list).map(item => item.offsetWidth);
      list.forEach((item, index) => {
        item.style.width = `${widths[index] + 10}px`;
      });
    }, []);
    
  • 避免强制同步布局:禁止循环中交替读写 DOM 布局属性。

3. 避坑技巧:特殊场景处理

  • SSR 兼容:加环境判断,避免服务端执行 DOM 操作。

    
    useLayoutEffect(() => {
      if (typeof window === 'undefined') return;
      // 客户端 DOM 操作逻辑
    }, []);
    
  • 合并状态更新:避免在回调内多次 setState,减少额外 DOM 计算。

    
    // ❌ 多次 setState
    useLayoutEffect(() => {
      const rect = ref.current.getBoundingClientRect();
      setWidth(rect.width);
      setHeight(rect.height);
    }, []);
    
    // ✅ 合并更新
    useLayoutEffect(() => {
      const rect = ref.current.getBoundingClientRect();
      setLayout({ width: rect.width, height: rect.height });
    }, []);
    
  • 长耗时逻辑延迟执行:无法避免的耗时操作,用 requestAnimationFrame 延迟(注意会失去无闪烁效果)。

    
    useLayoutEffect(() => {
      requestAnimationFrame(() => {
        heavyDomOperation();
      });
    }, []);
    

4. 性能检测:验证优化效果

  • Chrome DevTools Performance 面板:录制组件更新过程,查看 useLayoutEffect 任务耗时,超过 16ms(60fps 标准)需优化;

  • 低性能设备测试:验证是否存在卡顿问题。


(注:文档部分内容可能由 AI 生成)