React 基础体系 · 第 29/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React useLayoutEffect 与 useInsertionEffect:时机、测量和闪烁
useLayoutEffect 和 useInsertionEffect 都属于 React 的 Effect Hooks,但它们解决的是两个不同阶段的问题:
useLayoutEffect:DOM 已经完成提交,可以在浏览器绘制前读取布局并同步修正界面。useInsertionEffect:React 提交 DOM 变更的早期,用于让 CSS-in-JS 库尽早插入样式。useEffect:通常在浏览器绘制之后执行,适合不影响本次视觉结果的副作用。
如果把本应使用 useLayoutEffect 的测量逻辑放进 useEffect,用户可能先看到错误位置,再看到修正后的位置,这就是常说的“闪烁”。如果把普通 DOM 操作或测量逻辑放进 useInsertionEffect,则会遇到时机过早、引用尚未就绪等问题。
先建立浏览器与 React 的时序模型
理解这两个 Hook,必须先区分几个概念。
Render:计算下一棵 React 树
React 组件函数执行时,React 根据当前 props、state 和上下文计算下一次 UI:
function Counter({ count }: { count: number }) {
return <button>{count}</button>;
}
这一步主要是计算,不应读取或修改 DOM,也不应执行网络请求、订阅、定时器等副作用。
在并发渲染下,render 阶段可能被暂停、重新开始,甚至直接丢弃。因此:
只有已经提交的渲染结果,才会产生对应的 Effect。
React 不会因为某次未提交的 render 执行了 useLayoutEffect 或 useInsertionEffect。
Commit:把结果提交到宿主环境
React 将 render 阶段得到的差异应用到 DOM,这一步称为 commit。以更新文本为例:
setCount(1);
一个简化的过程是:
- React 重新执行组件,计算出
<button>1</button>。 - React 发现旧文本是
0,新文本是1。 - React 修改真实 DOM。
- React 执行相应的 Effect。
- 浏览器进行样式计算、布局和绘制。
真实 React 内部还包含更细的 commit 子阶段,但应用代码应该依赖公开 API 所承诺的时机,而不是依赖某个内部函数名或实现细节。
Paint:浏览器把结果显示给用户
浏览器在绘制前通常需要完成:
- 样式计算;
- 布局,也叫 layout 或 reflow;
- 绘制;
- 合成。
getBoundingClientRect() 等 API 可以读取布局结果。读取布局本身通常不会改变 DOM,但如果前面存在尚未完成的样式或几何修改,浏览器可能为了返回准确结果而同步计算布局。
一个足够实用的抽象时序如下:
sequenceDiagram
participant App as 组件代码
participant React as React
participant DOM as DOM
participant Browser as 浏览器
App->>React: setState / props 变化
React->>React: render
React->>React: commit 开始
React->>React: useInsertionEffect
React->>DOM: 应用 DOM mutations
React->>React: useLayoutEffect
React->>React: 必要时同步触发下一次 render
React->>DOM: 提交同步修正
Browser->>Browser: 样式计算、布局、绘制
Browser-->>App: 用户看到最终画面
React->>App: useEffect 通常在绘制后执行
这张图是对公开行为的概念化表达。尤其是 useInsertionEffect 与具体 DOM mutation 子阶段的内部排列不应当被当成稳定的内部 API。稳定的使用边界是:
useInsertionEffect用于在 React 进行 DOM 变更前插入样式;useLayoutEffect在 DOM 已更新后、浏览器绘制前运行;useEffect不用于本次绘制前的布局修正。
useLayoutEffect:在绘制前读取和修正布局
useLayoutEffect 的签名与 useEffect 类似:
useLayoutEffect(setup, dependencies?)
它的核心语义是:
React 提交 DOM 更新后,浏览器重新绘制前执行
setup。
因此,useLayoutEffect 适合处理必须在用户看到画面前完成的 DOM 布局工作,例如:
- 读取元素尺寸;
- 读取元素相对视口的位置;
- 根据测量结果计算弹窗位置;
- 处理需要两次渲染才能得到最终位置的工具提示;
- 在绘制前同步修正滚动位置。
它不是“更快的 useEffect”,而是插入了一个会阻塞本次绘制的同步阶段。
基本测量过程
import { useLayoutEffect, useRef, useState } from "react";
export function BoxSize() {
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const element = boxRef.current;
if (element === null) {
return;
}
const nextWidth = element.getBoundingClientRect().width;
setWidth((previousWidth) => {
return previousWidth === nextWidth ? previousWidth : nextWidth;
});
}, []);
return (
<section>
<div ref={boxRef} style={{ width: "50%" }}>
内容
</div>
<p>宽度:{width}px</p>
</section>
);
}
执行过程如下:
- 初次 render,
width为0。 - React 将
<div>插入 DOM,并把 DOM 节点赋给boxRef.current。 useLayoutEffect执行。getBoundingClientRect()读取实际宽度。- 如果宽度不是
0,调用setWidth。 - React 在浏览器绘制前完成第二次 render。
- 用户直接看到正确的宽度文本,而不是先看到
0再看到实际值。
这里使用函数式状态更新并进行相等性判断,是为了避免不必要的更新。对于数字类型,React 也会基于 Object.is 判断新旧 state 是否相同,但显式避免无意义的更新可以让意图更清楚。
为什么 useEffect 可能产生闪烁
如果把上面的 Hook 改成:
useEffect(() => {
const element = boxRef.current;
if (element === null) {
return;
}
setWidth(element.getBoundingClientRect().width);
}, []);
可能发生这样的过程:
第一次 render:width = 0
↓
commit DOM
↓
浏览器绘制:用户看到“宽度:0px”
↓
useEffect 执行
↓
setWidth(真实宽度)
↓
第二次 render
↓
浏览器再次绘制:用户看到真实宽度
这两个版本最终状态相同,但视觉路径不同。差异不在于 getBoundingClientRect() 的结果,而在于状态修正发生在绘制前还是绘制后。
可以用一个简化模型表示:
V₀:第一次 render 产生的视觉状态;M:从 DOM 测得的布局信息;V₁ = f(M):根据测量结果计算出的正确视觉状态;P:浏览器绘制。
使用 useEffect 时,可能是:
commit(V₀) → P(V₀) → measure(M) → commit(V₁) → P(V₁)
使用 useLayoutEffect 时,目标是:
commit(V₀) → measure(M) → commit(V₁) → P(V₁)
因此,useLayoutEffect 的价值不是减少 render 次数,而是把中间状态 V₀ 隐藏在用户第一次看到画面之前。
完整算例:没有测量时无法正确定位 Tooltip
工具提示通常不能只靠一次 render 得到最终位置:
- 先把 Tooltip 渲染出来;
- 测量 Tooltip 自身尺寸;
- 根据触发按钮和 Tooltip 尺寸计算最终位置;
- 重新渲染。
下面是一个可运行的 TypeScript 示例。它使用固定定位,避免 Tooltip 被父级的 overflow 或定位上下文影响。
import {
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from "react";
type Position = {
top: number;
left: number;
};
const initialPosition: Position = {
top: 0,
left: 0,
};
function samePosition(a: Position, b: Position): boolean {
return a.top === b.top && a.left === b.left;
}
export function MeasuredTooltip({
text,
children,
}: {
text: string;
children: React.ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<Position>(initialPosition);
const [ready, setReady] = useState(false);
useLayoutEffect(() => {
if (!open) {
return;
}
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (trigger === null || tooltip === null) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
// 让 Tooltip 出现在按钮下方,并在必要时保持在视口内。
const gap = 8;
const nextPosition: Position = {
top: triggerRect.bottom + gap,
left: Math.min(
triggerRect.left,
window.innerWidth - tooltipRect.width - gap,
),
};
setPosition((previous) => {
return samePosition(previous, nextPosition)
? previous
: nextPosition;
});
setReady(true);
}, [open, text]);
const tooltipStyle: CSSProperties = {
position: "fixed",
top: position.top,
left: position.left,
visibility: ready ? "visible" : "hidden",
pointerEvents: "none",
padding: "6px 8px",
borderRadius: 4,
background: "#111",
color: "#fff",
fontSize: 14,
whiteSpace: "nowrap",
};
return (
<>
<button
ref={triggerRef}
type="button"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{children}
</button>
{open && (
<div ref={tooltipRef} role="tooltip" style={tooltipStyle}>
{text}
</div>
)}
</>
);
}
使用方式:
export function Demo() {
return (
<MeasuredTooltip text="这是工具提示">
将鼠标移到这里
</MeasuredTooltip>
);
}
第一次打开时,Tooltip 已经被插入 DOM,但初始阶段 visibility 为 hidden。随后 useLayoutEffect 读取:
- 触发按钮的
getBoundingClientRect(); - Tooltip 的
getBoundingClientRect()。
然后计算新的 top 和 left,触发同步更新。由于这个更新发生在绘制前,用户通常不会看到 Tooltip 出现在错误位置。
这里有几个重要细节:
ref用来读取真实 DOM 节点,但改变ref.current不会触发 render。- Tooltip 必须先存在,才能测量它自己的尺寸。
visibility: hidden防止在极端情况下测量与绘制之间暴露未准备好的内容。setPosition对新旧坐标做比较,避免 effect 重复执行时持续创建新对象。open和text是这个测量逻辑的依赖。Tooltip 内容变化会改变宽度,因此必须重新测量。
位置变化不只由 React state 引起
上面的示例只在 open 或 text 变化时测量,但窗口缩放、容器尺寸变化、字体加载完成都可能改变布局。生产代码通常需要配合 ResizeObserver 或 resize 事件:
useLayoutEffect(() => {
if (!open) {
return;
}
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (trigger === null || tooltip === null) {
return;
}
const updatePosition = () => {
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const nextPosition = {
top: triggerRect.bottom + 8,
left: Math.min(
triggerRect.left,
window.innerWidth - tooltipRect.width - 8,
),
};
setPosition((previous) =>
samePosition(previous, nextPosition) ? previous : nextPosition,
);
};
updatePosition();
const resizeObserver = new ResizeObserver(updatePosition);
resizeObserver.observe(trigger);
resizeObserver.observe(tooltip);
window.addEventListener("resize", updatePosition);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updatePosition);
};
}, [open, text]);
这里的清理函数很重要。否则组件卸载后,观察器和窗口事件仍可能调用已经失效的更新函数,造成内存泄漏或无效工作。
不过,ResizeObserver 的回调并不等同于 useLayoutEffect。它负责监听后续尺寸变化;useLayoutEffect 负责在 React 提交后的初始测量阶段运行。
useLayoutEffect 中读取布局时的性能代价
读取布局和写入布局都不是免费的。典型的危险模式是“写一次、读一次、写一次、读一次”:
element.style.width = "200px";
const width = element.getBoundingClientRect().width;
otherElement.style.height = `${width}px`;
const height = otherElement.getBoundingClientRect().height;
浏览器可能在每次读取时被迫确认前面的样式修改已经如何影响布局,这种现象通常称为强制同步布局或 layout thrashing。
更合理的方向是:
- 尽量集中读取;
- 再集中写入;
- 避免在大量节点上执行同步测量;
- 不要把与首屏视觉无关的计算放入
useLayoutEffect。
例如:
const firstRect = firstElement.getBoundingClientRect();
const secondRect = secondElement.getBoundingClientRect();
firstElement.style.transform = `translateX(${secondRect.left}px)`;
secondElement.style.transform = `translateY(${firstRect.top}px)`;
transform 通常比频繁修改 top、width、height 更容易避免布局级联,但这不是绝对保证。具体代价仍取决于元素、CSS 规则、浏览器和页面规模。
React 文档明确提醒:useLayoutEffect 会阻塞浏览器绘制。它解决的是视觉一致性问题,不能因为“更早”就取代所有 useEffect。
useInsertionEffect:为 CSS-in-JS 提供插入时机
useInsertionEffect 的目的更专门:
在 React 对 DOM 进行变更之前执行,用于插入样式规则。
它主要服务于 CSS-in-JS 库。例如,一个 CSS-in-JS 库可能在组件 render 时生成哈希类名:
const className = "button_a1b2c3";
但如果对应的 CSS 规则还没有插入文档:
.button_a1b2c3 {
color: red;
}
则 React 提交组件后,浏览器可能先按“没有这条规则”的状态计算样式。CSS-in-JS 库需要在合适的 commit 时机把规则写入 <style>,让后续布局和绘制能够使用正确的 CSS。
一个简化示例:
import { useInsertionEffect } from "react";
function useStyleRule(cssText: string): void {
useInsertionEffect(() => {
const styleElement = document.createElement("style");
styleElement.setAttribute("data-demo-style", "");
styleElement.textContent = cssText;
document.head.appendChild(styleElement);
return () => {
styleElement.remove();
};
}, [cssText]);
}
export function RedLabel() {
useStyleRule(`
.red-label {
color: crimson;
font-weight: 600;
}
`);
return <span className="red-label">重要信息</span>;
}
执行路径可以简化为:
组件 render,得到 class="red-label"
↓
useInsertionEffect 插入对应 <style>
↓
React 提交组件 DOM
↓
浏览器使用新规则进行样式计算和布局
↓
绘制带有正确样式的元素
这个示例展示了用途,但真正的 CSS-in-JS 库还需要处理:
- 规则去重;
- 多个组件的插入顺序;
- 服务端生成的 CSS;
- hydration;
- 样式表容器;
- 主题变化;
- 组件卸载时是否删除规则;
- 多个 React root 之间的共享缓存。
因此,应用代码通常不应为了设置一段普通 CSS 就直接使用 useInsertionEffect。
为什么不能用 useLayoutEffect 代替它
如果样式插入放在 useLayoutEffect 中,React 可能已经完成了 DOM mutation。虽然 useLayoutEffect 仍在浏览器绘制前执行,但它已经不是 CSS-in-JS 专门需要的最早插入阶段。
此外,布局 effect 的职责是:
DOM 已经更新 → 读取布局或同步调整 DOM
而插入 effect 的职责是:
DOM 更新前 → 插入影响后续计算的样式
两者都发生在绘制附近,但所处的前提不同:
| Hook | DOM 状态 | 主要用途 |
|---|---|---|
useInsertionEffect |
不应假设组件 DOM 已完成更新 | 插入 CSS 规则 |
useLayoutEffect |
组件 DOM 已完成更新 | 测量布局、同步修正视觉状态 |
useEffect |
通常已完成一次绘制 | 订阅、请求、日志等非首帧视觉副作用 |
useInsertionEffect 的限制
useInsertionEffect 不是一个更早、更强的 useLayoutEffect。它有意受到限制。
在其中不应依赖:
- 当前组件的 DOM 已经完成更新;
ref已经指向新的 DOM 节点;- 可以读取到最终布局;
- 可以安全地执行任意状态更新。
例如,下面的代码设计方向是错误的:
function WrongMeasurement() {
const ref = useRef<HTMLDivElement>(null);
useInsertionEffect(() => {
// 不应在这里读取组件布局。
const rect = ref.current?.getBoundingClientRect();
console.log(rect);
});
return <div ref={ref}>内容</div>;
}
插入 effect 的时机太早,不能把它当成“DOM 更新后的 effect”。如果需求是读取 ref.current 并测量尺寸,应使用 useLayoutEffect。
同样,不应使用它来执行普通 DOM 修改:
useInsertionEffect(() => {
document.title = "页面标题";
});
设置标题既不属于 CSS 规则插入,也不需要参与 DOM 提交前的样式准备。它应该放在普通 useEffect 中,或者在确实需要同步标题的特殊场景下使用其他明确的同步方案。
官方 API 对 useInsertionEffect 的定位很窄:它主要供 CSS-in-JS 库使用。应用层代码很少有理由直接调用它。
闪烁不只有一种原因
测量晚于绘制
这是 useLayoutEffect 最典型的场景:
渲染默认尺寸或默认位置
→ 浏览器绘制
→ useEffect 测量
→ 修正位置
→ 再绘制
用户看到的就是错误状态到正确状态的过渡。
CSS 晚于 DOM
如果 CSS-in-JS 库先提交带有类名的 DOM,再在不合适的时间插入 CSS,可能出现:
DOM 已存在,但规则不存在
→ 浏览器按默认样式计算
→ CSS 规则插入
→ 样式变化
这类闪烁的根因不是布局测量,而是样式规则的准备时机,属于 useInsertionEffect 的问题域。
数据晚于绘制
如果内容来自网络请求,useLayoutEffect 也无法让异步数据在首帧前出现:
useLayoutEffect(() => {
fetch("/api/user").then(/* ... */);
}, []);
网络请求不会因为放进 useLayoutEffect 就变成同步操作。此时首帧显示加载状态是正常的,应使用加载状态、服务端数据预取、框架 loader 或其他数据获取机制解决。
图片和字体晚于绘制
图片的自然尺寸、Web Font 的最终字形都可能在初次布局之后才可用。因此,即使使用 useLayoutEffect 测量了元素,后续图片加载或字体替换仍可能改变几何位置。
例如:
useLayoutEffect(() => {
const element = ref.current;
if (element === null) return;
setHeight(element.getBoundingClientRect().height);
}, []);
这只能测量 effect 执行时的高度。如果图片随后加载并撑高容器,原来的高度已经过时。此时应考虑:
- 给图片提供明确的
width和height; - 使用
aspect-ratio预留空间; - 使用
ResizeObserver监听实际变化; - 等待字体加载后再执行需要精确测量的逻辑。
Effect 的依赖与生命周期
以如下代码为例:
useLayoutEffect(() => {
const element = ref.current;
if (element === null) {
return;
}
const observer = new ResizeObserver(() => {
console.log(element.getBoundingClientRect());
});
observer.observe(element);
return () => {
observer.disconnect();
};
}, [someValue]);
生命周期规则是:
- 组件提交并满足依赖变化条件时运行 setup;
- 如果上一次 setup 返回 cleanup,React 会先运行 cleanup;
- 然后运行新的 setup;
- 组件卸载时运行最后一次 cleanup。
依赖数组不是“只运行一次”的语法,而是声明 setup 所读取的响应式值。若 effect 读取了 someValue,通常应将其列入依赖;如果读取了会变化的 props、state 或函数,也要处理对应的依赖关系。
ref.current 是一个例外:它不是由 React state 驱动的响应式依赖,改变它本身不会让组件重新 render。因此,把 ref.current 放入依赖数组通常不能解决测量更新问题。需要更新时,应由相关 state、观察器、事件或外部订阅触发。
Strict Mode 下的额外执行
开发环境的 <StrictMode> 会对 Effect 进行额外的 setup → cleanup → setup 检查,以帮助发现不完整的清理逻辑。示意流程可能是:
setup
→ cleanup
→ setup
因此,Effect 必须可以安全地重复建立和清理:
- 事件监听要移除;
ResizeObserver要断开;- 定时器要清除;
- 插入的
<style>要移除或正确复用; - 外部订阅要取消。
不要通过全局布尔变量“屏蔽第二次执行”来绕过 Strict Mode。那通常会掩盖真正的资源清理问题。
服务端渲染与客户端边界
useLayoutEffect 和 useInsertionEffect 都依赖浏览器环境,服务端没有真实 DOM,也没有浏览器绘制阶段,因此服务端不会执行它们。
在服务端渲染环境中,常见的现象是:
- 服务器输出初始 HTML;
- 浏览器收到 HTML 并开始加载;
- 客户端 React hydration;
- 只有客户端提交后,Effect 才可能执行。
因此,useLayoutEffect 不能用于修复服务端生成 HTML 与客户端第一次 render 之间的结构差异。若服务端输出:
<div class="server-version">...</div>
而客户端第一次 render 输出:
<div class="client-version">...</div>
这属于 hydration 不匹配问题,应保证两端初始 render 的结果一致,或者使用明确的客户端边界。
在支持 React Server Components 的框架中,包含这些 Hook 的组件必须位于客户端组件边界。例如,某些框架要求文件顶部使用:
"use client";
这不是 React 核心 Hook 的参数,而是框架定义的客户端模块边界声明。具体语法和边界规则由所使用的框架决定。
等价的同构 Hook
有些库会定义:
import {
useEffect,
useLayoutEffect,
type EffectCallback,
type DependencyList,
} from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined"
? useLayoutEffect
: useEffect;
export function useBrowserLayoutEffect(
effect: EffectCallback,
dependencies?: DependencyList,
) {
useIsomorphicLayoutEffect(effect, dependencies);
}
这样在服务端使用 useEffect,在浏览器使用 useLayoutEffect。
但它并不能消除服务端与客户端输出不一致的问题,也不能让服务端完成布局测量。它只是在服务端避免调用依赖布局的 Hook。更重要的是,服务端分支仍然不能读取 window、document 或 DOM。
如何选择三个 Hook
可以按照副作用是否影响本次绘制来判断。
使用 useInsertionEffect
只有当问题是“CSS 规则必须在 React DOM 变更前插入”时,才考虑它:
useInsertionEffect(() => {
// 插入或注册 CSS 规则
return () => {
// 删除或解除注册
};
}, [rule]);
典型使用者是 CSS-in-JS 库,而不是普通业务组件。
使用 useLayoutEffect
当用户不应看到中间布局状态时使用:
useLayoutEffect(() => {
// DOM 已存在
// 读取尺寸或位置
// 必要时同步设置 state 或修正 DOM
}, [dependencies]);
典型场景是测量 Tooltip、弹层、锚点、滚动位置或动画初始位置。
使用 useEffect
当副作用不影响当前绘制时,优先使用:
useEffect(() => {
const connection = connect();
return () => connection.disconnect();
}, []);
典型场景包括:
- 网络请求;
- 日志;
- 事件订阅;
- WebSocket;
- 定时器;
- 与外部系统同步;
- 不需要在首帧前完成的 DOM 操作。
如果把 useLayoutEffect 改成 useEffect 后只是日志晚一点出现,没有视觉问题,就没有必要阻塞绘制。
常见错误与失败表现
在 render 阶段直接测量 DOM
function BadComponent() {
const ref = useRef<HTMLDivElement>(null);
const width = ref.current?.getBoundingClientRect().width ?? 0;
return <div ref={ref}>{width}</div>;
}
初次 render 时,ref.current 通常还没有指向当前 DOM 节点。更重要的是,render 阶段不应依赖尚未提交的 DOM。即使某次更新中读取到了旧节点,也会得到过时数据。
应将测量放进 useLayoutEffect,并把结果存入 state。
在 useLayoutEffect 中无条件更新对象 state
useLayoutEffect(() => {
setPosition({
top: element.getBoundingClientRect().top,
});
});
由于没有依赖数组,这个 effect 每次 render 都执行;每次又创建新对象并更新 state,可能造成无限更新或高频重渲染。
至少需要:
- 正确的依赖数组;
- 比较新旧测量结果;
- 必要时使用稳定的数据表示。
把 useInsertionEffect 当成“最早的 DOM effect”
useInsertionEffect(() => {
inputRef.current?.focus();
});
这段代码依赖 DOM 已经存在并且 ref 已经绑定,不符合插入 effect 的用途。聚焦属于 DOM 提交后的操作;如果必须在绘制前完成,可考虑 useLayoutEffect,但普通聚焦还应结合可访问性和用户交互语义谨慎处理。
用 useLayoutEffect 解决服务端 hydration 问题
useLayoutEffect(() => {
setIsClient(true);
}, []);
这只能在客户端提交后更新状态,不能改变服务器已经输出的 HTML,也不能修复两端首次 render 的结构差异。应该让服务器和客户端的初始树一致,再在客户端 effect 中处理浏览器专属行为。
诊断闪烁的具体方法
遇到“页面先错后对”时,不要先盲目替换 Hook,可以按时序确认原因。
先确认错误状态是否真的被绘制
在关键位置加入日志:
console.log("render", position);
useLayoutEffect(() => {
console.log("layout effect", position);
});
useEffect(() => {
console.log("passive effect", position);
});
然后观察:
- 是否发生了两次 render;
- 布局修正发生在
useLayoutEffect还是useEffect; - 是否存在图片、字体、窗口变化等后续布局变化;
- 是否是开发 Strict Mode 导致的额外 setup/cleanup。
日志顺序不能精确代表所有浏览器内部阶段,但能帮助发现明显的“先绘制、后修正”路径。
使用浏览器 Performance 面板
在 Performance 录制中观察:
- React commit;
- Recalculate Style;
- Layout;
- Paint;
- 后续脚本任务。
如果先出现 Paint,随后 effect 修改样式并再次出现 Layout/Paint,通常说明中间状态已经暴露。若大量时间集中在 Layout,则可能是同步测量规模过大或读写交错。
检查是否真正需要同步修正
可以暂时把:
useLayoutEffect
改为:
useEffect
如果只是视觉上出现一次中间状态,说明该逻辑依赖绘制时机;如果行为完全不变,则可能不需要 layout effect。这个实验只用于诊断,最终仍应根据副作用的语义选择 Hook。
生产取舍:消除闪烁与阻塞绘制之间的平衡
useLayoutEffect 能隐藏中间布局状态,但代价是阻塞浏览器绘制。测量越多、同步更新越复杂,首帧等待时间越长。
可以从结构上减少对它的依赖:
- 用 CSS 布局表达可表达的关系,而不是先测量再设置位置;
- 为图片和媒体预留尺寸,降低后续布局跳动;
- 使用
aspect-ratio、min-height等方式稳定几何空间; - 只测量真正需要测量的节点;
- 通过
ResizeObserver处理后续尺寸变化,而不是反复在 render 中测量; - 将不影响首帧的工作放入
useEffect; - 对需要同步修正的 state 更新做相等性判断。
可以把决策归纳为两个问题:
问题一:是否需要插入 CSS 规则?
是 → useInsertionEffect,通常由 CSS-in-JS 库封装
问题二:DOM 更新后,用户是否不能看到未修正的布局?
是 → useLayoutEffect
否 → useEffect
最终,三个 Hook 的差异不是“执行速度排序”,而是它们位于不同的生命周期边界:
useInsertionEffect处理样式规则进入文档的时机;useLayoutEffect处理提交后的 DOM 测量和绘制前修正;useEffect处理不需要阻塞当前绘制的外部副作用。
只要先判断副作用影响的是“样式准备”“布局结果”还是“绘制之后的外部同步”,就能避免把闪烁、测量、服务端渲染和 CSS 注入混为同一个问题。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Memo、useMemo 与 useCallback:引用、成本和正确边界
- 下一篇:React Ref 与 useImperativeHandle:焦点、测量和命令式 API
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论