useEffect和useLayoutEffect是React中两个功能几乎一致的副作用钩子,连官方文档都直接用复制粘贴的方式介绍它们,唯一的区别在于一行小字:useLayoutEffect会在所有的DOM变更之后同步调用,可以在浏览器绘制之前执行。正是这一点点差异,决定了两个钩子完全不同的适用场景。用错了轻则页面闪烁,重则主线程被阻塞、交互卡顿。本文结合React的浏览器渲染流程,把这两个钩子的执行时机彻底讲清楚。

先理解浏览器一帧里发生了什么
要弄清楚两个钩子的区别,必须先知道浏览器渲染一帧的基本流程。React触发一次状态更新后,大致经历这几个阶段:组件函数执行(render阶段)、React计算差异并提交到真实DOM(commit阶段)、浏览器执行布局计算layout、最后把页面画到屏幕上paint。整个过程可以简化为:JS执行、样式计算、布局、绘制、合成。
useEffect被调度的位置在整个流程的最后面。React在commit阶段完成后,并不会立即执行useEffect的回调,而是把它交给调度器,以普通优先级异步安排执行。通常浏览器会先完成这一帧的绘制,再在稍后的空闲时间或下一次任务中执行effect回调。这意味着当useEffect的代码运行时,用户已经可能看到了更新前的旧界面。
useLayoutEffect则完全相反。它在commit阶段结束后被同步调用,此时DOM节点已经全部更新完毕,但浏览器还没有来得及计算布局和绘制。因此你在useLayoutEffect里读取offsetHeight、getBoundingClientRect这类布局属性,拿到的一定是最新值,而且在此处修改DOM或调用state更新,浏览器会把两次变更合并到同一次绘制中,用户完全感知不到中间状态。
执行顺序实验:一个组件里看清楚差异
下面这个例子可以直接粘贴到项目中运行,通过打印顺序观察两者的调用时机:
import React, { useState, useEffect, useLayoutEffect } from 'react';
export default function Demo() {
const [count, setCount] = useState(0);
// 打印顺序固定为:render -> layout -> effect
useEffect(() => {
console.log('useEffect 执行:浏览器已经绘制完成');
});
useLayoutEffect(() => {
console.log('useLayoutEffect 执行:DOM已更新,还未绘制');
});
console.log('组件函数执行 render');
return (
<button onClick={() => setCount(count + 1)}>
点击 {count}
</button>
);
}每次点击按钮,控制台的输出顺序都是:组件函数执行、useLayoutEffect执行、useEffect执行。这验证了一个关键结论:useLayoutEffect总是先于useEffect执行,因为它发生在浏览器绘制之前,而useEffect发生在绘制之后。
更值得注意的是useLayoutEffect里的同步setState行为。如果你在useLayoutEffect中调用了setCount并改变了状态,React会在浏览器绘制之前同步完成重渲染,用户看到的是最终结果而不是中间状态。而同样的代码如果放在useEffect里,浏览器会先绘制一次旧状态,再因状态更新绘制第二次,页面上就出现了肉眼可见的闪烁。这也是useLayoutEffect存在的主要原因——解决两阶段渲染带来的视觉跳动问题。
典型使用场景对比
useLayoutEffect适合的场景
第一类是读取布局并同步修改DOM的场景。比如实现一个跟随鼠标的悬浮提示框,需要先测量触发元素的位置,再设置提示框的坐标。如果用useEffect,第一帧绘制时提示框位置是旧的,第二帧才修正,鼠标快速移动时会明显抖动。
function Tooltip({ targetRef, children }) {
const ref = React.useRef(null);
useLayoutEffect(() => {
// DOM更新后立即测量,数据是最新的
const rect = targetRef.current.getBoundingClientRect();
const el = ref.current;
// 直接操作样式,与本次DOM更新合并为同一次绘制
el.style.left = rect.right + 8 + 'px';
el.style.top = rect.top + 'px';
});
return <div ref={ref} style={{ position: 'fixed' }}>{children}</div>;
}第二类是动画初始状态防闪烁。元素需要从某个测量好的位置开始做过渡动画,初始位置必须在绘制前确定。第三类是滚动位置恢复:列表数据刷新后要还原用户之前的滚动距离,如果放在useEffect里执行,用户会先看到列表回到顶部再跳回原位置。
useEffect适合的场景
绝大多数副作用都应该用useEffect:数据请求、订阅事件、定时器、日志上报、操作localStorage等。这些操作既不依赖布局信息,也不需要在绘制前完成,放在同步阶段反而会阻塞绘制,拖慢首屏呈现。数据请求尤其如此,接口响应本来就是异步的,用useLayoutEffect没有任何收益。
换一个角度看,useLayoutEffect是同步阻塞的,回调执行时间过长会直接推迟浏览器的绘制,造成掉帧。而useEffect是异步的,不影响本次渲染的呈现速度。所以社区有一个经验法则:默认写useEffect,只有在出现闪屏、布局抖动等视觉问题时,才切换到useLayoutEffect。这是正确顺序,不要反过来。
服务端渲染中的警告与兼容方案
在服务端渲染场景下,useLayoutEffect会触发一个著名的警告:useLayoutEffect does nothing on the Server。原因很直接,服务端不存在DOM和浏览器绘制流程,这个钩子的核心语义无从谈起。React为了保持行为一致性,直接在SSR环境下禁用了它。
如果组件确实需要useLayoutEffect,又要支持SSR,常用方案是用typeof window !== 'undefined'判断动态选择钩子,或者借助useIsomorphicLayoutEffect这类社区封装。代码示意如下:
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
这样在浏览器端走useLayoutEffect保证绘制前执行,在服务端退化为useEffect避免报错。此外还要注意,React 18的并发特性下,useEffect的触发时机由调度器决定,不同优先级的更新可能让effect延后执行;而useLayoutEffect始终紧跟commit同步执行,这个语义在任何版本中都是稳定的。
选型总结
一句话概括两者的关系:useLayoutEffect是浏览器绘制前的同步钩子,useEffect是绘制之后的异步钩子。判断标准很简单,问自己两个问题。第一,effect里的逻辑是否需要读取布局信息或者写DOM样式?第二,这步操作是否必须在用户看到画面之前完成?两个问题都是肯定答案才用useLayoutEffect,其余情况一律useEffect。记住这个决策路径,就能在保持页面流畅的同时彻底消除那些莫名其妙的闪烁问题。
useLayoutEffectuseEffectReact hooks修改时间:2026-09-12 23:00:33