你是否曾经构建过一个从左上角突然跳到正确位置的 tooltip?或者一个在稳定下来之前会闪烁错误尺寸的 modal?这种瞬间的闪烁就是布局闪烁 (layout flicker)。它发生在 React 读取 DOM、计算修正值并更新状态,但浏览器已经开始向屏幕推送像素的时候。通常的解决方案是将 useEffect 替换为 useLayoutEffect。这种替换有效,但前提是你必须准确理解每个 Hook 在浏览器流水线中触发的时机。
浏览器流水线:渲染、提交、绘制
React 通过三个不同的阶段来更新组件。在渲染 (render) 阶段,React 构建(或重建)虚拟 DOM 并计算 diff。此时还没有实际的像素变化;这纯粹是在内存中进行的计算。接下来是提交 (commit) 阶段,React 将这些更改应用于真实的 DOM 节点。样式会更新,节点会被插入或移除,文本也会发生变化。
然后浏览器接管。在绘制 (paint) 阶段,浏览器的渲染引擎计算布局几何形状并将像素绘制到屏幕上。这个序列是固定的。浏览器必须在绘制之前完成布局,并且必须在用户看到任何新内容之前完成绘制。提交与绘制之间的间隔以毫秒计,但它是真实存在的,也是 useEffect 和 useLayoutEffect 分道扬镳的窗口期。
为什么 useEffect 会导致闪烁
useEffect 是异步运行的,它被安排在浏览器已经绘制完屏幕之后触发。DOM 已更新,像素已绘制,然后 React 才介入执行你的 effect。
想象一下你在按钮下方渲染了一个下拉菜单。在 useEffect 中,你调用 buttonRef.current.getBoundingClientRect(),计算出正确的 top 和 left 坐标,并将其存储在 state 中。因为 useEffect 在绘制后运行,浏览器已经将下拉菜单绘制在了默认位置,例如 top: 0, left: 0。只有在绘制完成后,你的 effect 才会更新 state。React 提交修正后的坐标,浏览器再次进行绘制。用户会看到两个帧:错误的坐标,紧接着是正确的坐标。这种视觉上的跳变就是每个人都试图避免的闪烁。
对于数据获取、API 调用、分析追踪或设置事件监听器,这种延迟并不重要。用户并不关心分析埋点是否在绘制后几毫秒才触发。事实上,将非视觉工作推迟到绘制之后可以保持初始渲染的响应能力。但对于依赖布局的修正,useEffect 实在太晚了。
useLayoutEffect 如何阻塞绘制
useLayoutEffect 是同步运行的,它紧随 React 修改 DOM 之后执行,但在浏览器有机会计算布局或绘制像素之前。它会完全阻塞绘制流水线。
如果你在 useLayoutEffect 中执行相同的下拉菜单测量,序列就会改变。React 提交初始的 DOM 更新,运行你的 layout effect,然后你的 state 更新会触发同步的重新渲染。React 提交修正后的坐标,只有在那之后,浏览器才会进行绘制。用户只会看到一帧,且那一帧已经是正确的。
这种阻塞行为既是特性也是风险。因为 useLayoutEffect 会阻止浏览器在完成任务前进行绘制,所以其中任何繁重的计算都会冻结 UI。即使是几十毫秒的阻塞绘制,用户也会感觉到卡顿 (jank)。这就是为什么 React 文档明确建议你先从 useEffect 开始,只有当你确实观察到无法忍受的闪烁时,才升级到 useLayoutEffect。
何时使用每个 Hook
大部分逻辑都应该属于 useEffect。将其用于:
- 从 API 获取数据
- 设置订阅或事件监听器
- 发送分析事件
- 任何不会立即读取或修改布局的副作用
将 useLayoutEffect 留给那些必须在用户看到帧之前读取 DOM 并写回的操作:
- 测量元素尺寸,如宽度、高度或滚动位置
- 为 tooltip、popover 或上下文菜单计算坐标
- 当视觉位置取决于渲染几何形状时,防止可见的布局偏移
如果你不确定该选哪一个,默认使用 useEffect。只有当你注意到视觉上的不稳定时,才切换到 useLayoutEffect。仅凭这一条规则,就能让绝大多数 React 应用平稳运行。
服务端渲染 (SSR) 的坑
如果你使用 Next.js、Remix 或任何在服务端渲染 React 的框架,在使用 useLayoutEffect 时会遇到警告。因为服务端没有 DOM,该 Hook 无从进行测量。React 会警告你它预期的是浏览器环境,但并未找到。在注水 (hydration) 过程中,这种不匹配还可能导致微妙的 Bug,因为服务端渲染的标记 (markup) 与客户端首次预期的渲染结果可能存在差异。
标准的解决方法是使用一个同构 (isomorphic) Hook,根据环境选择正确的 Effect:
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
在任何必须测量 DOM 节点但可能在服务端渲染期间执行的组件中使用此包装器。它可以消除警告并保持服务端输出的一致性。
性能与最佳实践
由于 useLayoutEffect 会阻塞绘制 (painting),请尽可能保持 Hook 主体的轻量化。读取布局值,计算修正值,然后写回。不要在其中获取数据、解析大型对象或运行复杂的算法。这里的重型代码会阻塞主线程,使你的界面感觉像是卡死了。
在测量元素时,请使用 React refs 而不是 document.getElementById。Refs 与你的组件实例绑定,在重新渲染时无需查询技巧即可保留,并且在配合 portals 或条件渲染时表现可靠。全局 ID 查询会破坏组件的封装性,并可能在你正需要它们的时候返回 null。
对于几乎所有的副作用,useEffect 都是正确的默认选择。它允许浏览器不间断地进行绘制,并能干净地处理数据、事件和外部同步。useLayoutEffect 是解决特定问题的专业工具:在绘制之前读取布局并写回。掌握它们之间的时机差异,你就能从“追逐闪烁”转变为“预防闪烁”。
核心要点: 默认对所有操作使用 useEffect。当你看到工具提示 (tooltip) 或模态框 (modal) 在自我修正前闪烁到了错误的位置时,那就是你的信号。切换到 useLayoutEffect,测量 DOM,调整布局,然后让浏览器进行一次——正确的绘制。
