useLayoutEffect 是 useEffect 的一个版本,只是运行得更早:在 React 更新 DOM 之后、浏览器绘制新的一帧之前立即运行。其中的代码可以测量元素并修改布局,而用户只会看到最终结果。它运行时会阻塞绘制,所以只在确实需要这个时机时才用它。
两个 Effect 的声明顺序是先 effect 后 layout effect,但每次渲染时控制台都先输出 layout effect。这两个 Hook 之间,声明顺序无关紧要,起决定作用的是它们的时机。
语法
它的签名与 useEffect 完全相同:一个 setup 函数,一个可选的清理函数,以及一个依赖数组。
useLayoutEffect(() => {
// runs after the DOM update, before paint
return () => {
// cleanup, before the next run and on unmount
};
}, [dependencies]);
你在 useEffect 中学到的关于依赖和清理函数的一切,在这里都适用。唯一的区别是 React 调用它的时机。
两者各自何时运行
一次渲染之后,React 更新 DOM,然后按固定顺序做三件事:
- 同步运行所有 layout effect 的清理函数和 layout effect。
- 让浏览器绘制。如果某个 layout effect 设置了 state,React 会先重新渲染,这仍然发生在绘制之前。
- 运行普通 Effect,通常在绘制之后。
在整棵树中,所有 layout effect 都在任何普通 Effect 之前运行,而且子组件先于父组件运行,因为父组件只有在子组件就位后才算完成:
控制台先显示两个 layout effect,先 Child 后 Parent,然后按同样的顺序显示两个普通 Effect。key={n} 让每次点击都挂载一个全新的 Parent,所以你每次都会看到同样的四行。给每个 Effect 加一个输出日志的清理函数,观察旧的清理函数在新的 Effect 之前运行。
闪烁问题
设想一个提示框,它应该显示在按钮上方,除非上方没有空间,那时就放到下方。在提示框进入 DOM、你能测量它的高度之前,你无法知道上方有没有空间。所以渲染要分两遍进行:
- 先把提示框渲染在某个地方,以便测量。
- 测量它,算出真正的位置,设置 state,再渲染一次。
用 useEffect 时,浏览器可能在这两遍之间进行绘制,用户会看到提示框从第一个位置跳到第二个位置。用 useLayoutEffect 时,第二遍发生在绘制之前,所以第一个位置永远不会被显示出来。
把鼠标悬停在上面的按钮上:它上方没有空间,所以提示框出现在下方。悬停在下面那个按钮上,提示框就出现在上方。控制台会告诉你测量结果选择了哪一侧。把 useLayoutEffect 改成 useEffect 再悬停一次:提示框现在可能会在盒子顶部闪现一帧,然后才移动过去。在速度快的电脑上,这往往快到察觉不到,而这正是这类 bug 被发布上线、然后从速度较慢的手机上被用户报告回来的原因。
offsetHeight、offsetTop 和 getBoundingClientRect() 都会强制浏览器计算布局,所以在 layout effect 中读取它们,得到的是 React 刚刚写入的 DOM 的真实值。
测量元素
同样的写法适用于渲染依赖的任何尺寸:在 layout effect 中通过 ref 读取它,存进 state,再用它来渲染。
点击按钮,宽度那一行与文字在同一帧更新。页面永远不会出现新文字配旧宽度的情况,因为第二次渲染发生在绘制之前。把 [text] 从依赖数组中去掉,测量就会在每次渲染后运行,包括由它自己的 setWidth 引起的那次;它之所以会停下来,只是因为宽度不再变化了。
优先使用 useEffect
layout effect 会推迟绘制,直到它返回;如果它设置了 state,还要等额外的那次渲染完成。一个慢的 layout effect 会让页面上的每次更新都显得卡顿。只有在以下条件全部满足时,才使用 useLayoutEffect:
- Effect 会读取布局信息(尺寸、位置、滚动)。
- 它根据读取结果改变渲染内容。
- 中间状态会被看到,而且看起来是错的。
其他所有情况(数据请求、订阅、定时器、日志、统计分析)都应该放在 useEffect 中。一个简单的原则:先用 useEffect,只有当你看到闪烁时才换掉它。
useEffect 通常在绘制之后运行,但并不总是如此。当更新来自点击或按键这类离散的用户操作时,React 可能会在浏览器绘制之前运行 Effect。这也是闪烁有时出现、有时不出现的原因之一。
服务端渲染
服务端渲染期间 Effect 从不运行,useLayoutEffect 也一样。服务器发送第一次渲染的 HTML,layout effect 只在浏览器中 hydration 完成之后才运行。React 18 及更早版本在服务器上渲染这种组件时,会警告 "useLayoutEffect does nothing on the server";React 19 去掉了这条警告。
如果没有测量结果,服务端渲染出的第一帧看起来就会不对,常见的修复方法有两种:先渲染一个中性的占位内容(例如在测量完成之前隐藏提示框),或者只在客户端渲染这个组件:
function ClientOnly({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? children : null;
}
useInsertionEffect
还有第三个 Effect Hook,useInsertionEffect,它在所有 layout effect 之前运行。它是为在运行时生成 <style> 标签的 CSS-in-JS 库准备的:在这里插入样式,意味着测量元素的 layout effect 看到的已经是加好样式的元素。它不能更新 state,ref 此时也还没有绑定,所以在应用代码中没有用处。如果你不是在写样式库,就不会用到它。
常见问题
useEffect 和 useLayoutEffect 有什么区别?
区别在于时机。useLayoutEffect 在 React 修改完 DOM 之后、浏览器绘制之前同步运行,所以它触发的 state 更新会在用户看到任何内容之前生效。useEffect 通常在绘制之后运行。两者的 API 和依赖数组完全相同。
什么时候应该用 useLayoutEffect?
当一个 Effect 要读取布局信息(尺寸或位置),并马上根据它改变渲染内容时,比如决定把提示框放在按钮上方还是下方。不用它的话,用户可能会在一帧之内看到最初那个错误的位置。
useLayoutEffect 会影响性能吗?
在它以及它触发的重新渲染完成之前,浏览器都无法绘制。一个慢的 layout effect 会让整个页面显得卡顿,所以除非需要避免肉眼可见的闪烁,否则请用 useEffect。
useLayoutEffect 会在服务器上运行吗?
不会。服务端渲染时两种 Effect 都不会运行。旧版 React 在服务器上渲染使用了 useLayoutEffect 的组件时会打印警告;React 19 不再打印,但这个 Effect 仍然只在浏览器中运行。