React useLayoutEffect vs useEffect:页面闪一下的元凶与正确修法
你写了个 Tooltip,根据触发元素的位置动态算坐标。跑起来功能没问题,但每次弹出时它会先在左上角闪现一下,再"跳"到正确位置。这种肉眼可见的抖动,十有八九是因为你用了 useEffect 而不是 useLayoutEffect。
这两个 Hook 签名一模一样,90% 的场景可以互换,但剩下 10% 用错就会闪屏。这篇讲清它们的执行时机差异,以及什么时候必须换成 useLayoutEffect。
核心区别:在浏览器绘制之前还是之后
React 更新 DOM 的大致流程是:执行组件函数 → 提交 DOM 变更 → 浏览器绘制到屏幕 → 用户看到画面。两个 Hook 的差别就在于相对「绘制」这一步的时机:
useEffect:在浏览器绘制之后异步执行。用户已经看到了这一帧,你的 effect 才跑。useLayoutEffect:在 DOM 变更后、浏览器绘制之前同步执行。它跑完,用户才看到画面。
关键推论:如果你的 effect 里读取 DOM 布局(尺寸、位置)然后又修改 DOM ,用 useEffect 会让用户先看到「修改前」的一帧、再看到「修改后」的一帧------这就是闪屏。而 useLayoutEffect 在绘制前就改完了,用户只看到最终结果。
复现闪屏:一个定位 Tooltip
jsx
import { useEffect, useRef, useState } from "react";
function Tooltip({ targetRef, children }) {
const tipRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useEffect(() => {
// 读取触发元素的位置,算出 tooltip 该放哪
const rect = targetRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 8, left: rect.left });
// 问题:此时浏览器已经把 tooltip 画在了 {0,0},
// setPos 触发重渲染后才移到正确位置 ------ 用户看到它"跳"了一下
}, [targetRef]);
return (
<div ref={tipRef} style={{ position: "fixed", top: pos.top, left: pos.left }}>
{children}
</div>
);
}
初始 pos 是 {0,0},浏览器先把 tooltip 绘制在左上角,useEffect 绘制之后才跑、才 setPos、才移到正确位置。中间那一帧的左上角残影,就是用户看到的闪烁。
修法:换成 useLayoutEffect
只改一个词:
jsx
import { useLayoutEffect, useRef, useState } from "react";
function Tooltip({ targetRef, children }) {
const tipRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 8, left: rect.left });
// useLayoutEffect 在绘制前同步执行,setPos 引发的重渲染也在绘制前完成,
// 浏览器一次性画出最终位置 ------ 没有中间帧,不闪
}, [targetRef]);
return (
<div ref={tipRef} style={{ position: "fixed", top: pos.top, left: pos.left }}>
{children}
</div>
);
}
React 会在 useLayoutEffect 里的 setState 之后、绘制之前完成重渲染,所以用户直接看到正确位置。这就是它存在的意义:同步测量 + 修改 DOM,消除中间帧。
那什么时候该用哪个?
判断很简单,问一句:这个 effect 会不会在绘制前读取布局并修改视觉结果?
用 useLayoutEffect 的典型场景(都涉及「测量后立即改样式」):
- 读取元素尺寸/位置来定位(Tooltip、Popover、下拉菜单)。
- 根据内容高度做动画的初始值(比如手风琴展开)。
- 滚动位置的同步恢复(避免先跳到顶部再跳回)。
其余情况一律用 useEffect,包括:
jsx
// 数据请求 ------ 和布局无关,用 useEffect
useEffect(() => {
fetch("/api/data").then(/* ... */);
}, []);
// 订阅事件、埋点、定时器 ------ 都用 useEffect
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
别滥用:useLayoutEffect 会阻塞绘制
既然 useLayoutEffect 能防闪屏,为什么不全用它?因为它是同步、阻塞绘制的:浏览器必须等它(以及它引发的重渲染)跑完才能画下一帧。如果你在里面干重活(复杂计算、大量 DOM 操作),会直接拖慢首屏、造成卡顿。
原则:默认用 useEffect,只有出现闪屏、或明确需要绘制前测量 DOM 时,才换 useLayoutEffect。 不要「为了保险」全部用它。
附带一个 SSR 警告
如果你用 Next.js 等服务端渲染,useLayoutEffect 在服务端会打印一条警告:「useLayoutEffect does nothing on the server」。因为服务端没有真实 DOM 可测量。处理方式:确认这段逻辑确实只需在客户端跑,可以用「客户端挂载后才渲染」的模式规避,或把纯副作用挪回 useEffect。不要为了消警告无脑改回 useEffect 而让闪屏复活------先判断它到底需不需要绘制前执行。
小结
useEffect在浏览器绘制后 异步执行,useLayoutEffect在绘制前同步执行。- 「读取 DOM 布局 → 修改视觉结果」的 effect 用
useEffect会闪一帧,换useLayoutEffect消除中间帧。 - 数据请求、订阅、定时器等和布局无关的副作用,一律用
useEffect。 useLayoutEffect阻塞绘制,别滥用;里面别干重活。- SSR 下
useLayoutEffect会告警,按「是否真需要绘制前执行」来决定处理方式,而不是无脑改回去。 - 一句话记忆点:画面闪一下、元素"跳"到最终位置,先想想那个 effect 是不是该从 useEffect 换成 useLayoutEffect。