useEffect vsuseLayoutEffect
副作用应该什么时候执行?什么时候需要阻塞浏览器绘制?
先记住选择原则:默认使用 useEffect。只有必须在浏览器绘制前读取或修改布局,并且不能接受用户看到中间状态时,才使用 useLayoutEffect。
1. 两者解决的问题不同
两者都在组件提交更新后运行,并且都可以返回 cleanup;区别在于它们相对于浏览器绘制的时机不同。
useEffect
useEffect(() => {
const subscription = subscribe(userId);
return () => subscription.unsubscribe();
}, [userId]);
useEffect 适合把 React 与外部系统同步,例如:
- 请求数据或同步网络状态;
- 订阅事件、WebSocket、定时器;
- 发送埋点;
- 调用不影响首屏布局的第三方 API。
它在 Commit 后由 React 调度执行,通常会让浏览器先完成绘制。但“绘制后执行”不是绝对的:交互触发的更新可能有不同调度时机。业务代码不应依赖一个过于绝对的时间顺序。
useLayoutEffect
useLayoutEffect(() => {
const rect = elementRef.current.getBoundingClientRect();
setPosition(rect);
}, []);
useLayoutEffect 会在 DOM 更新后、浏览器绘制前同步执行。它适合:
- 测量 DOM 尺寸或位置;
- 根据测量结果同步调整布局;
- 初始化必须在绘制前完成的 DOM 第三方库;
- 避免用户看到明显的布局跳动。
它会阻塞浏览器绘制,因此回调必须短小,不能在里面执行昂贵计算、网络请求或长时间循环。
2. 执行顺序
一次更新可以简化理解为:
Render
↓
React 更新 DOM
↓
useLayoutEffect setup
↓
浏览器绘制
↓
useEffect setup(通常)
当依赖变化时,React 会先清理旧 effect,再执行新的 setup:
旧 useLayoutEffect cleanup → 新 useLayoutEffect setup
旧 useEffect cleanup → 新 useEffect setup
具体 passive effect 的调度可能受 React 更新来源影响,但可以稳定依赖的原则是:每个 effect 的 cleanup 应撤销它自己的订阅、定时器或 DOM 外部操作。
3. DOM 测量示例
假设要根据 tooltip 自身尺寸设置位置:
function Tooltip({ children }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const element = tooltipRef.current;
if (!element) return;
const rect = element.getBoundingClientRect();
setPosition({
top: rect.top - rect.height,
left: rect.left,
});
}, []);
return (
<div
ref={tooltipRef}
style={{ top: position.top, left: position.left }}
>
{children}
</div>
);
}
如果使用 useEffect,浏览器可能先绘制初始位置,再执行测量和更新,用户可能看到一次闪烁。useLayoutEffect 可以在绘制前完成这次修正。
但更好的方案通常是优先使用 CSS 布局、CSS Anchor Positioning 或成熟的定位库;只有确实需要 JavaScript 测量时,才引入布局 effect。
4. 哪些场景不该用useLayoutEffect?
以下场景通常使用 useEffect 即可:
useEffect(() => {
document.title = title;
}, [title]);
useEffect(() => {
const timer = setInterval(refresh, 30_000);
return () => clearInterval(timer);
}, [refresh]);
不要仅因为“想让代码更快”就使用 useLayoutEffect。它不会让网络请求更快,也不会减少渲染次数,反而可能延迟绘制,造成卡顿。
下面这些行为尤其不应该放在 layout effect 中:
- 数据请求;
- 日志和埋点;
- 大量计算;
- 可以通过 CSS 完成的样式设置;
- 与布局无关的订阅。
5.useInsertionEffect 是什么?
useInsertionEffect 的执行时机比 useLayoutEffect 更早,主要为 CSS-in-JS 库插入样式设计。普通业务组件不应使用它:
- 它不能访问已经更新的 DOM;
- 不适合读取布局;
- 不适合替代
useEffect 或 useLayoutEffect。
可以这样记:
useInsertionEffect:注入样式
useLayoutEffect:读取/修正布局
useEffect:与外部系统同步
6. SSR 注意事项
服务端没有真实 DOM,也没有浏览器布局,因此 effect 不会在服务端执行。useLayoutEffect 在服务端渲染时通常会产生警告,因为它要求在绘制前执行,但服务端不存在这个阶段。
仅在回调内部判断 window 不能消除这个警告:
// ❌ 仍然声明了 useLayoutEffect,SSR 阶段仍可能警告
useLayoutEffect(() => {
if (typeof window !== "undefined") {
// DOM 操作
}
}, []);
常见处理方式是让依赖布局的组件只在客户端渲染,或在明确需要同构支持时使用封装:
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
function MeasuredComponent() {
useIsomorphicLayoutEffect(() => {
// 客户端测量布局;服务端退化为普通 effect
}, []);
return <div />;
}
使用这种封装时仍要确认:服务端输出和客户端首次渲染必须保持一致,避免 hydration mismatch。
7. Strict Mode 与 cleanup
开发环境 Strict Mode 可能额外执行一次 setup → cleanup → setup,用来发现副作用是否可重复初始化、是否正确清理。这不是 useLayoutEffect 或 useEffect 的异常行为。
useLayoutEffect(() => {
const element = ref.current;
element?.addEventListener("scroll", handleScroll);
return () => {
element?.removeEventListener("scroll", handleScroll);
};
}, [handleScroll]);
无论使用哪一种 effect,都应该让 setup 和 cleanup 成对出现。不要依赖“只执行一次”的假设,也不要在 cleanup 中执行与旧资源无关的新业务操作。
8. 选择流程
可以按下面的顺序判断:
- 这段逻辑是否在与外部系统同步?如果不是,先确认是否根本不需要 effect。
- 如果需要 effect,是否必须在绘制前读取或修改布局?
- 如果不需要,使用
useEffect。
- 如果需要,使用
useLayoutEffect,并尽量缩短同步工作。
- 如果只是 CSS-in-JS 样式注入,才考虑由库使用
useInsertionEffect。
总结
| Hook | 大致时机 | 是否阻塞绘制 | 典型用途 |
|---|
useEffect | 提交后调度,通常在绘制后 | 通常不阻塞 | 请求、订阅、埋点、定时器 |
useLayoutEffect | DOM 更新后、绘制前 | 会阻塞 | DOM 测量、布局修正 |
useInsertionEffect | 更早的提交阶段 | 会影响提交 | CSS-in-JS 插入样式 |
面试时可以这样回答:默认使用 useEffect 与外部系统同步;如果必须在浏览器绘制前测量或修正布局,使用 useLayoutEffect,但要注意它会阻塞绘制并可能在 SSR 中产生警告。useInsertionEffect 主要供 CSS-in-JS 库使用,不是普通业务 effect 的替代品。