目录
1. 概述
useLayoutEffect 是 React 提供的一个特殊的 Effect Hook,它在 DOM 更新完成后、浏览器绘制之前同步执行。这个特性使得它成为确保 DOM 更新时序的关键工具。
1.1 核心特性
- ✅ 同步执行:在 DOM 更新后立即执行,阻塞浏览器绘制
- ✅ 时序保证:确保副作用在用户看到界面前完成
- ✅ 避免闪烁:防止用户看到中间状态或不一致的 UI
- ⚠️ 性能影响:会阻塞浏览器渲染,需要谨慎使用
2. React 渲染时序详解
2.1 完整的渲染流水线
graph TD
A["组件状态更新<br/>(setState)"] --> B["React 开始重新渲染"]
B --> C["执行函数组件"]
C --> D["计算新的 JSX"]
D --> E["React 计算 DOM 差异<br/>(Reconciliation)"]
E --> F["同步更新 DOM<br/>(Mutation Phase)"]
F --> G["useLayoutEffect 执行<br/>🔴 同步执行"]
G --> H["浏览器布局<br/>(Layout)"]
H --> I["浏览器绘制<br/>(Paint)"]
I --> J["屏幕显示更新"]
J --> K["useEffect 执行<br/>🟡 异步执行"]
2.2 关键时间点
| 阶段 | 描述 | Hook 执行 |
|---|---|---|
| Render Phase | 计算新的组件状态和 JSX | - |
| Commit Phase | 更新实际的 DOM | - |
| Layout Phase | useLayoutEffect 执行 | useLayoutEffect |
| Paint Phase | 浏览器绘制到屏幕 | - |
| Post-Paint | 绘制完成后 | useEffect |
3. useLayoutEffect vs useEffect
3.1 执行时序对比
sequenceDiagram
participant U as 用户操作
participant R as React
participant D as DOM
participant B as 浏览器
participant S as 屏幕
Note over U,S: 使用 useEffect (可能有闪烁)
U->>R: 状态更新
R->>D: 更新DOM
D->>B: 触发布局
B->>S: 绘制旧状态
Note over S: 用户看到旧状态 ❌
R->>R: useEffect执行
R->>D: 再次更新DOM
D->>B: 再次布局
B->>S: 绘制新状态
Note over S: 用户看到闪烁 ❌
Note over U,S: 使用 useLayoutEffect (无闪烁)
U->>R: 状态更新
R->>D: 更新DOM
R->>R: useLayoutEffect同步执行
R->>D: 立即更新DOM
D->>B: 触发布局
B->>S: 绘制最终状态
Note over S: 用户直接看到正确状态 ✅
3.2 选择指南
| 场景 | 推荐使用 | 原因 |
|---|---|---|
| DOM 测量和布局 | useLayoutEffect | 需要在绘制前获取准确的布局信息 |
| 滚动位置同步 | useLayoutEffect | 避免滚动跳跃和闪烁 |
| 避免视觉闪烁 | useLayoutEffect | 确保状态更新在绘制前完成 |
| 数据获取 | useEffect | 不影响布局,异步执行更好 |
| 事件监听器 | useEffect | 不需要阻塞渲染 |
| 副作用清理 | useEffect | 通常不需要同步执行 |
4. 实际应用场景分析
4.1 场景一:时间选择器滚动同步
问题:当时间值改变时,需要将滚动列表定位到对应的时间项。
// ❌ 使用 useEffect - 会产生闪烁
useEffect(() => {
syncScroll(); // 用户先看到旧位置,然后看到跳跃
}, [value]);
// ✅ 使用 useLayoutEffect - 无闪烁
useLayoutEffect(() => {
syncScroll(); // 在绘制前同步滚动位置
clearDelayCheck();
return () => {
stopScroll();
clearDelayCheck();
};
}, [value, optionalValue, units]);
滚动同步的关键实现:
const syncScroll = () => {
const ul = ulRef.current;
if (ul) {
// 查询目标元素 - 需要 DOM 已更新
const targetLi = ul.querySelector(`[data-value="${value}"]`);
const firstLi = ul.querySelector(`li`);
if (targetLi && firstLi) {
// 计算目标位置 - 需要准确的布局信息
const targetTop = targetLi.offsetTop - firstLi.offsetTop;
// 平滑滚动到目标位置
ul.scrollTop = targetTop;
}
}
};
4.2 场景二:面板模式切换
问题:当用户在日期选择器中选择年份后,需要立即切换到月份面板。
// 模式切换必须在绘制前完成
useLayoutEffect(() => {
if (mergedOpen && activeIndex !== undefined) {
// 立即切换到正确的面板模式
triggerModeChange(null, picker, false);
}
}, [mergedOpen, activeIndex, picker]);
面板切换的视觉效果对比:
graph LR
A["用户点击年份"] --> B["状态更新"]
B --> C["DOM 更新"]
C --> D["useLayoutEffect<br/>立即切换到月份面板"]
D --> E["浏览器绘制<br/>用户看到月份面板"]
A2["用户点击年份"] --> B2["状态更新"]
B2 --> C2["DOM 更新"]
C2 --> E2["浏览器绘制<br/>用户看到年份面板"]
E2 --> F2["useEffect<br/>延迟切换到月份面板"]
F2 --> G2["浏览器重绘<br/>用户看到闪烁"]
subgraph "✅ useLayoutEffect - 无闪烁"
A --> B --> C --> D --> E
end
subgraph "❌ useEffect - 有闪烁"
A2 --> B2 --> C2 --> E2 --> F2 --> G2
end
4.3 场景三:弹窗状态同步
问题:当弹窗关闭时,需要立即处理确认逻辑,确保状态一致性。
useLayoutEffect(() => {
const lastOp = lastOperation();
// 弹窗关闭时的处理逻辑
if (!mergedOpen && lastOp === 'input') {
triggerOpen(false);
triggerConfirm(); // 必须在下次渲染前确认
}
// 复杂选择器的提交逻辑
if (!mergedOpen && complexPicker && !needConfirm && lastOp === 'panel') {
triggerConfirm(); // 确保状态同步
}
}, [mergedOpen]);
4.4 场景四:DOM 测量和计算
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
if (elementRef.current) {
// 测量 DOM 元素 - 需要在绘制前获取准确尺寸
const rect = elementRef.current.getBoundingClientRect();
setDimensions({
width: rect.width,
height: rect.height
});
}
}, [someProps]);
5. 性能考量与最佳实践
5.1 性能影响
// ❌ 避免在 useLayoutEffect 中执行耗时操作
useLayoutEffect(() => {
// 这会阻塞浏览器渲染!
for (let i = 0; i < 1000000; i++) {
// 耗时计算
}
}, []);
// ✅ 将耗时操作移到 useEffect
useLayoutEffect(() => {
// 只做必要的同步 DOM 操作
element.scrollTop = targetPosition;
}, []);
useEffect(() => {
// 耗时操作放在异步执行
performHeavyCalculation();
}, []);
5.2 最佳实践
5.2.1 明确使用场景
// ✅ 需要同步 DOM 操作
useLayoutEffect(() => {
// DOM 测量
// 滚动位置同步
// 避免闪烁的状态更新
}, []);
// ✅ 不需要同步的操作
useEffect(() => {
// 数据获取
// 事件监听
// 日志记录
}, []);
5.2.2 优化执行频率
// ❌ 频繁执行 useLayoutEffect
useLayoutEffect(() => {
updateLayout();
}, [mousePosition]); // 鼠标移动时频繁触发
// ✅ 使用防抖或节流
const debouncedPosition = useDebounce(mousePosition, 16); // 60fps
useLayoutEffect(() => {
updateLayout();
}, [debouncedPosition]);
5.2.3 条件执行
useLayoutEffect(() => {
// 只在必要时执行
if (shouldUpdateLayout) {
updateLayout();
}
}, [shouldUpdateLayout, otherDeps]);
6. 常见问题与解决方案
6.1 问题:useLayoutEffect 导致性能问题
症状:页面卡顿,渲染变慢
解决方案:
// 分离同步和异步操作
useLayoutEffect(() => {
// 只做必要的同步操作
element.style.transform = `translateX(${position}px)`;
}, [position]);
useEffect(() => {
// 异步操作
analyticsTrack('position_changed', position);
}, [position]);
6.2 问题:服务端渲染 (SSR) 警告
症状:控制台出现 useLayoutEffect does nothing on the server 警告
解决方案:
import { useLayoutEffect, useEffect } from 'react';
// 创建同构的 useLayoutEffect
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
// 使用
useIsomorphicLayoutEffect(() => {
// 布局相关操作
}, []);
6.3 问题:测试环境中的问题
解决方案:
// 在测试中模拟 useLayoutEffect
jest.mock('react', () => ({
...jest.requireActual('react'),
useLayoutEffect: jest.requireActual('react').useEffect,
}));
6.4 问题:依赖数组优化
// ❌ 依赖过多导致频繁执行
useLayoutEffect(() => {
updateLayout();
}, [prop1, prop2, prop3, prop4]);
// ✅ 使用 useMemo 优化依赖
const layoutDeps = useMemo(() =>
({ prop1, prop2, prop3, prop4 }),
[prop1, prop2, prop3, prop4]
);
useLayoutEffect(() => {
updateLayout();
}, [layoutDeps]);
7. 总结
7.1 核心要点
- 时序保证:
useLayoutEffect在 DOM 更新后、浏览器绘制前同步执行 - 避免闪烁:确保关键的 DOM 操作在用户看到界面前完成
- 性能权衡:提供完美视觉体验的同时会阻塞渲染
- 明确场景:只在需要同步 DOM 操作时使用
7.2 决策流程
flowchart TD
A[需要执行副作用] --> B{是否涉及 DOM 布局?}
B -->|是| C{是否需要在绘制前完成?}
B -->|否| D[使用 useEffect]
C -->|是| E{操作是否耗时?}
C -->|否| D
E -->|是| F[优化后使用 useLayoutEffect<br/>或考虑异步方案]
E -->|否| G[使用 useLayoutEffect]
style G fill:#ccffcc
style D fill:#cceeff
style F fill:#ffffcc
7.3 最后的建议
- 默认选择 useEffect:除非有明确的同步需求
- 谨慎使用 useLayoutEffect:确保理解其性能影响
- 持续监控性能:使用浏览器开发工具检查渲染性能
- 渐进式优化:先实现功能,再根据实际需要优化时序
通过正确使用 useLayoutEffect,我们可以创建出既高性能又具有完美视觉体验的 React 应用。
最后:部分灵感来源ai