React渲染时机完全指南:从一个电商组件的优化说起
前言
作为前端开发者,我们每天都在和 React 打交道,但你真的了解 React 的渲染时机吗?
- 为什么有时候获取 DOM 元素的高度是 0?
- 为什么设置的动画效果没有生效?
- 为什么页面会出现闪烁?
- useEffect 和 useLayoutEffect 到底该用哪个?
- requestAnimationFrame 在 React 中有什么用?
在这篇文章中,我将通过一个真实的电商项目案例去搞懂 React 的渲染时机。
一、从一个真实的电商场景说起
1.1 业务背景
在电商项目中,商品分类筛选是一个非常常见的功能。想象一下淘宝或京东的商品列表页,顶部通常会有这样的筛选器:
手机通讯 > 手机 > 华为 | 小米 | OPPO | vivo | 苹果 | 三星 | 荣耀 | realme | 一加 | 魅族... |
当分类项特别多时,我们需要:
- 默认只显示 2 行,多余的折叠起来
- 提供展开/收起按钮
- 支持平滑的展开/收起动画
- 切换一级分类时,二级分类需要重置
1.2 组件效果演示


(gif效果太差了。。。。)
1.3 核心技术挑战
看似简单的需求,实现起来却遇到了不少挑战:
- 高度计算问题:如何准确获取内容的完整高度?
- 动画流畅性:如何实现平滑的高度过渡动画?
- 状态切换问题:切换分类时如何避免不必要的动画?
- 响应式适配:如何在不同屏幕尺寸下保持良好体验?
这些问题的核心都指向一个关键点:我们需要在正确的时机执行正确的操作。
二、React 渲染机制深度解析
2.1 React 的工作流程
在深入代码之前,我们先来理解 React 的完整工作流程:
graph TB
A[用户交互/Props变化] --> B[触发状态更新]
B --> C[React 调度更新]
C --> D[Render Phase
渲染阶段]
D --> E[Reconciliation
协调过程]
E --> F[生成 Fiber 树]
F --> G[Commit Phase
提交阶段]
G --> H[更新 DOM]
H --> I[执行 useLayoutEffect]
I --> J[浏览器绘制]
J --> K[执行 useEffect]
K --> L[用户看到更新]
style D fill:#f9f,stroke:#333,stroke-width:2px
style G fill:#9ff,stroke:#333,stroke-width:2px
style J fill:#ff9,stroke:#333,stroke-width:2px
2.2 三个关键阶段
阶段一:Render Phase(渲染阶段)
- 特点:可中断、可恢复、可并发
- 任务:调用组件函数,生成新的虚拟 DOM 树
- 限制:不能执行副作用(side effects)
// 这个阶段执行的代码 |
阶段二:Commit Phase(提交阶段)
- 特点:同步执行,不可中断
- 任务:将变更应用到真实 DOM
- 时机:useLayoutEffect 在此阶段执行
function MyComponent() { |
阶段三:Browser Paint(浏览器绘制)
- 特点:浏览器的工作,React 不参与
- 任务:计算布局、绘制像素
- 时机:useEffect 在此之后执行
2.3 时序对比图
让我们通过一个详细的时序图来对比不同 Hook 的执行时机:
sequenceDiagram
participant User as 用户
participant React as React
participant DOM as DOM
participant Browser as 浏览器
participant Effect as useEffect
participant LayoutEffect as useLayoutEffect
User->>React: 点击按钮
React->>React: setState 更新状态
rect rgb(255, 230, 230)
Note over React: Render Phase 开始
React->>React: 调用组件函数
React->>React: 生成虚拟 DOM
React->>React: Diff 算法对比
Note over React: Render Phase 结束
end
rect rgb(230, 255, 230)
Note over React,DOM: Commit Phase 开始
React->>DOM: 更新真实 DOM
DOM-->>React: DOM 更新完成
React->>LayoutEffect: 同步执行 useLayoutEffect
LayoutEffect-->>React: 执行完成
Note over React,DOM: Commit Phase 结束
end
rect rgb(230, 230, 255)
Note over Browser: Paint Phase 开始
DOM->>Browser: 触发重排/重绘
Browser->>Browser: 计算布局
Browser->>Browser: 绘制像素
Browser->>User: 显示更新后的界面
Note over Browser: Paint Phase 结束
end
Browser->>Effect: 异步执行 useEffect
Effect-->>React: 执行完成
三、代码实战:剖析折叠组件的实现
现在让我们来看看实际的代码实现,我会逐步解析每个关键部分。
3.1 组件整体结构
首先,让我们了解组件的整体结构:
// 主组件:SecondCategoryBox |
3.2 核心难点一:精确的高度计算
这是整个组件最核心的部分。我们需要:
- 获取内容的完整高度(展开时的高度)
- 计算折叠时应该显示的高度
- 决定是否需要显示展开/收起按钮
const calculateHeightsWithScale = useCallback(() => { |
关键问题:什么时候调用这个函数?
3.3 核心难点二:选择正确的执行时机
这就涉及到我们要深入讨论的 React 渲染时机问题。让我们看看不同方案的对比:
方案一:使用 useEffect(❌ 会闪烁)
useEffect(() => { |
问题分析:
graph LR
A[分类数据变化] --> B[组件重新渲染]
B --> C[DOM 更新]
C --> D[浏览器绘制]
D --> E[用户看到错误高度]
E --> F[useEffect 执行]
F --> G[计算并设置正确高度]
G --> H[再次渲染]
H --> I[用户看到正确高度]
style E fill:#ffcccc
style I fill:#ccffcc
用户会先看到错误的高度,然后突然跳到正确高度——这就是”闪烁”!
方案二:使用 useLayoutEffect(⚠️ 可能阻塞渲染)
useLayoutEffect(() => { |
优点:在浏览器绘制前执行,避免闪烁 缺点:同步执行,可能阻塞渲染,影响性能
方案三:使用 requestAnimationFrame(✅ 最佳方案)
useEffect(() => { |
为什么这是最佳方案?
graph TB
A[useEffect 执行] --> B[注册 RAF 回调]
B --> C[浏览器完成当前帧绘制]
C --> D[布局信息已确定]
D --> E[RAF 回调执行]
E --> F[准确获取高度]
F --> G[更新组件状态]
style D fill:#ccffcc
style F fill:#ccffcc
requestAnimationFrame 确保:
- 不阻塞当前的渲染
- 在下一帧开始前执行
- 此时布局计算已完成,可以准确获取尺寸
3.4 核心难点三:优雅地处理动画
当用户切换一级分类时,我们需要重置二级分类,但不希望用户看到收起动画:
useEffect(() => { |
时序分析:
sequenceDiagram
participant User as 用户
participant Component as 组件
participant CSS as CSS动画
participant Browser as 浏览器
User->>Component: 切换一级分类
Component->>Component: categoryList 变化
Component->>CSS: 禁用 transition
Component->>Component: 重置状态(高度变为折叠状态)
Note over CSS: 无动画,瞬间变化
Component->>Browser: 请求下一帧
Browser-->>Component: 下一帧开始
Component->>CSS: 启用 transition
Note over CSS: 后续交互有动画
3.5 性能优化:响应式设计
组件还实现了一个巧妙的响应式系统:
const calculateScale = useCallback(() => { |
这确保了组件在不同设备上都有合适的显示效果。
四、深入理解 useEffect 和 useLayoutEffect
4.1 本质区别
// useEffect:在浏览器完成绘制后异步执行 |
4.2 使用场景对比
graph TB
A[需要执行副作用] --> B{是否影响视觉呈现?}
B -->|是| C{是否需要 DOM 测量?}
B -->|否| D[使用 useEffect]
C -->|是| E{测量是否紧急?}
C -->|否| F[使用 useLayoutEffect]
E -->|是| G[useLayoutEffect]
E -->|否| H[useEffect + RAF]
D --> I[数据获取
事件订阅
日志上报]
F --> J[阻止闪烁
同步滚动
焦点管理]
G --> K[关键布局计算
动画初始状态]
H --> L[非关键测量
性能优化]
style B fill:#ffffcc
style C fill:#ffffcc
style E fill:#ffffcc
4.3 实际案例对比
让我们通过几个实际例子来加深理解:
案例1:工具提示定位
function Tooltip({ children, content }) { |
案例2:滚动位置恢复
function ScrollRestore({ location }) { |
案例3:动画序列
function AnimatedList({ items }) { |
五、requestAnimationFrame 的高级应用
5.1 什么是 requestAnimationFrame?
requestAnimationFrame(简称 RAF)是浏览器提供的一个 API,用于在下一次重绘之前执行动画。它的执行时机非常特殊:
graph LR
A[帧开始] --> B[处理用户输入]
B --> C[JS 执行]
C --> D[RAF 回调]
D --> E[样式计算]
E --> F[布局]
F --> G[绘制]
G --> H[合成]
H --> I[帧结束]
style D fill:#ffcccc
5.2 在 React 中的应用场景
场景1:确保布局完成后测量
function useMeasure() { |
场景2:批量 DOM 操作
function batchDOMUpdates(updates) { |
场景3:平滑动画
function useAnimation(duration = 300) { |
5.3 RAF vs setTimeout/setInterval
// ❌ 不推荐:可能导致掉帧或不流畅 |
六、常见问题与最佳实践
6.1 常见错误及解决方案
错误1:在渲染阶段读取 DOM
// ❌ 错误 |
错误2:过度使用 useLayoutEffect
// ❌ 错误:非视觉相关操作 |
错误3:忽视清理函数
// ❌ 错误:内存泄漏 |
6.2 性能优化建议
1. 避免不必要的布局计算
// ❌ 性能差:每次渲染都计算 |
2. 批量更新 DOM
// ✅ 批量读取和写入 |
3. 使用 CSS 代替 JS 动画
// ❌ JS 动画(性能较差) |
6.3 调试技巧
1. 可视化渲染时机
function useRenderLog(name) { |
2. 性能监控
function usePerformanceMonitor(name) { |
七、React 18 并发特性与渲染时机
7.1 并发渲染的影响
React 18 引入的并发特性改变了一些渲染行为:
import { startTransition, useDeferredValue, useId } from 'react'; |
7.2 并发渲染下的 useEffect
在并发模式下,组件可能会多次渲染但只提交一次:
graph TB
A[开始渲染] --> B{高优先级更新?}
B -->|是| C[中断当前渲染]
B -->|否| D[继续渲染]
C --> E[处理高优先级]
E --> F[重新开始低优先级]
D --> G[提交到 DOM]
F --> D
G --> H[执行 Effects]
style C fill:#ffcccc
style E fill:#ffcccc
7.3 实践建议
function SearchResults({ query }) { |
八、实战总结:回到我们的折叠组件
现在,让我们用学到的知识重新审视最初的折叠组件,看看它是如何解决各种渲染时机问题的:
8.1 问题与解决方案对照
| 问题 | 解决方案 | 原理 |
|---|---|---|
| 获取准确的内容高度 | useEffect + RAF | 确保布局计算完成 |
| 切换分类时的动画闪烁 | 禁用/启用 transition | 精确控制 CSS 动画时机 |
| 响应式适配 | 动态计算缩放比例 | 避免频繁的 DOM 操作 |
| 首次加载的默认状态 | firstUpdate 标记 | 区分初始化和更新 |
8.2 完整的渲染流程
sequenceDiagram
participant U as 用户
participant C as 组件
participant D as DOM
participant B as 浏览器
Note over U,B: 场景1:组件首次加载
U->>C: 页面加载
C->>C: 初始化状态
C->>D: 渲染 DOM
C->>C: useEffect 执行
C->>B: RAF 注册回调
B->>C: 下一帧执行测量
C->>C: 设置正确高度
C->>D: 更新 DOM
B->>U: 显示完整内容
Note over U,B: 场景2:用户点击展开
U->>C: 点击展开按钮
C->>C: setIsExpanded(true)
C->>D: 更新高度样式
Note over D,B: CSS transition 生效
B->>U: 平滑展开动画
Note over U,B: 场景3:切换分类
U->>C: 选择新分类
C->>C: 禁用 transition
C->>C: 重置状态
C->>D: 立即更新(无动画)
C->>B: RAF 注册回调
B->>C: 下一帧恢复 transition
Note over C,B: 后续交互恢复动画
8.3 关键代码片段回顾
// 1. 自定义 Hook 封装复杂逻辑 |
九、写在最后
通过这个真实的电商项目案例,我们深入探讨了 React 的渲染时机问题。让我们再次总结一下核心要点:
9.1 核心原则
- 理解时机:知道代码在 React 生命周期的哪个阶段执行
- 选对工具:useEffect、useLayoutEffect、RAF 各有适用场景
- 避免闪烁:需要立即生效的视觉变化用 useLayoutEffect
- 性能优先:非紧急操作放在 useEffect 中异步执行
- 精确控制:使用 RAF 在正确的时机进行 DOM 测量
9.2 决策流程图
graph TD
A[需要副作用?] -->|是| B[影响视觉?]
A -->|否| Z[纯组件逻辑]
B -->|是| C[需要 DOM 测量?]
B -->|否| D[useEffect]
C -->|是| E[测量紧急?]
C -->|否| F[useLayoutEffect]
E -->|是| G[useLayoutEffect]
E -->|否| H[useEffect + RAF]
D --> I[异步操作
数据获取
事件订阅]
F --> J[防止闪烁
滚动恢复]
G --> K[关键测量
初始定位]
H --> L[性能优化
非关键测量]
style A fill:#f9f,stroke:#333,stroke-width:4px
style B fill:#bbf,stroke:#333,stroke-width:2px
style C fill:#bbf,stroke:#333,stroke-width:2px
style E fill:#bbf,stroke:#333,stroke-width:2px
9.3 从理论到实践
理解 React 的渲染时机不仅仅是理论知识,更是解决实际问题的关键。当你遇到以下问题时,请想起这篇文章:
- 页面闪烁 → 检查是否应该使用 useLayoutEffect
- 获取的尺寸为 0 → 使用 RAF 确保布局完成
- 动画卡顿 → 考虑使用 CSS 动画或 RAF
- 性能问题 → 将非紧急操作移到 useEffect
十、参考资料
- React 官方文档 - Hooks Reference
- React 源码解析 - Fiber 架构
- Web 性能优化 - requestAnimationFrame
- React 18 Working Group
- 浏览器渲染原理
如果这篇文章对你有帮助,欢迎点赞、收藏和分享。有任何问题或不同见解,也欢迎在评论区讨论!
- 标题: React渲染时机完全指南:从一个电商组件的优化说起
- 作者: Simon
- 创建于 : 2025-07-24 14:53:25
- 更新于 : 2025-07-24 15:45:25
- 链接: https://www.simonicle.cn/2025/07/24/【从项目到技术】React渲染时机指南/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。