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)
function MyComponent ({ data } ) { const processedData = useMemo (() => processData (data), [data]); return <div > {processedData}</div > ; }
阶段二:Commit Phase(提交阶段)
特点 :同步执行,不可中断
任务 :将变更应用到真实 DOM
时机 :useLayoutEffect 在此阶段执行
function MyComponent ( ) { useLayoutEffect (() => { const height = ref.current .scrollHeight ; console .log ('真实高度:' , height); }); }
阶段三: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 组件整体结构 首先,让我们了解组件的整体结构:
const SecondCategoryBox = ({ categoryList, // 分类数据 defaultCategoryIds, // 默认选中项 maxVisibleRows, // 最大可见行数 onCategoryChange // 选中项变化回调 } ) => { const [activeIDList, setActiveIDList] = useState ([]); const { containerRef, isExpanded, showToggleButton, setIsExpanded } = useCollapse ({ maxVisibleRows, dependencies : [categoryList] }); };
3.2 核心难点一:精确的高度计算 这是整个组件最核心的部分。我们需要:
获取内容的完整高度(展开时的高度)
计算折叠时应该显示的高度
决定是否需要显示展开/收起按钮
const calculateHeightsWithScale = useCallback (() => { if (!containerRef.current ) return ; const container = containerRef.current ; const originalHeight = container.style .height ; const originalOverflow = container.style .overflow ; container.style .height = 'auto' ; container.style .overflow = 'visible' ; const fullHeight = container.scrollHeight ; const visibleHeight = rowHeight * maxVisibleRows + gap * (maxVisibleRows - 1 ); container.style .height = originalHeight; container.style .overflow = originalOverflow; setHeights ({ full : fullHeight, visible : visibleHeight }); setShowToggleButton (fullHeight > visibleHeight); }, [maxVisibleRows, rowHeight, gap]);
关键问题:什么时候调用这个函数?
3.3 核心难点二:选择正确的执行时机 这就涉及到我们要深入讨论的 React 渲染时机问题。让我们看看不同方案的对比:
方案一:使用 useEffect(❌ 会闪烁) useEffect (() => { calculateHeightsWithScale (); }, [categoryList]);
问题分析 :
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 (() => { calculateHeightsWithScale (); }, [categoryList]);
优点 :在浏览器绘制前执行,避免闪烁 缺点 :同步执行,可能阻塞渲染,影响性能
方案三:使用 requestAnimationFrame(✅ 最佳方案) useEffect (() => { requestAnimationFrame (() => { calculateHeightsWithScale (); }); }, [categoryList]);
为什么这是最佳方案?
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 (() => { if (firstUpdate) { setActiveIDList (defaultCategoryIds); setFirstUpdate (false ); } else { setEnableTransition (false ); setIsExpanded (false ); setActiveIDList ([]); requestAnimationFrame (() => { setEnableTransition (true ); }); } }, [categoryList]);
时序分析 :
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 (() => { const currentWidth = window .innerWidth ; const scale = currentWidth / baseWidth; const clampedScale = Math .max (0.8 , Math .min (1.5 , scale)); return { scale : clampedScale, rowHeight : Math .round (baseRowHeight * clampedScale), gap : Math .round (baseGap * clampedScale) }; }, [baseWidth, baseRowHeight, baseGap]);
这确保了组件在不同设备上都有合适的显示效果。
四、深入理解 useEffect 和 useLayoutEffect 4.1 本质区别 useEffect (() => { console .log ('1. DOM 已更新' ); console .log ('2. 浏览器已绘制' ); console .log ('3. 用户已看到变化' ); console .log ('4. 现在执行不会阻塞渲染' ); }); useLayoutEffect (() => { console .log ('1. DOM 已更新' ); console .log ('2. 浏览器还未绘制' ); console .log ('3. 用户还看不到变化' ); console .log ('4. 可以在这里调整样式避免闪烁' ); });
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 } ) { const [position, setPosition] = useState ({ top : 0 , left : 0 }); const triggerRef = useRef (); const tooltipRef = useRef (); useLayoutEffect (() => { if (triggerRef.current && tooltipRef.current ) { const triggerRect = triggerRef.current .getBoundingClientRect (); const tooltipRect = tooltipRef.current .getBoundingClientRect (); setPosition ({ top : triggerRect.top - tooltipRect.height - 8 , left : triggerRect.left + (triggerRect.width - tooltipRect.width ) / 2 }); } }, []); return ( <> <span ref ={triggerRef} > {children}</span > <div ref ={tooltipRef} className ="tooltip" style ={{ position: 'fixed ', ...position }} > {content} </div > </> ); }
案例2:滚动位置恢复 function ScrollRestore ({ location } ) { useLayoutEffect (() => { const savedPosition = sessionStorage .getItem (`scroll-${location} ` ); if (savedPosition) { window .scrollTo (0 , parseInt (savedPosition)); } }, [location]); useEffect (() => { const handleScroll = ( ) => { sessionStorage .setItem (`scroll-${location} ` , window .scrollY ); }; window .addEventListener ('scroll' , handleScroll); return () => window .removeEventListener ('scroll' , handleScroll); }, [location]); }
案例3:动画序列 function AnimatedList ({ items } ) { const [visibleItems, setVisibleItems] = useState ([]); useEffect (() => { let frameId; let index = 0 ; const animate = ( ) => { if (index < items.length ) { setVisibleItems (prev => [...prev, items[index]]); index++; frameId = requestAnimationFrame (animate); } }; frameId = requestAnimationFrame (animate); return () => { if (frameId) { cancelAnimationFrame (frameId); } }; }, [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 ( ) { const ref = useRef (); const [bounds, setBounds] = useState ({}); useEffect (() => { if (!ref.current ) return ; const measure = ( ) => { requestAnimationFrame (() => { if (ref.current ) { setBounds (ref.current .getBoundingClientRect ()); } }); }; measure (); window .addEventListener ('resize' , measure); return () => window .removeEventListener ('resize' , measure); }, []); return [ref, bounds]; }
场景2:批量 DOM 操作 function batchDOMUpdates (updates ) { requestAnimationFrame (() => { updates.forEach (update => update ()); if (needsRead) { document .body .offsetHeight ; } }); }
场景3:平滑动画 function useAnimation (duration = 300 ) { const [progress, setProgress] = useState (0 ); const frameRef = useRef (); const startTimeRef = useRef (); const start = useCallback (() => { startTimeRef.current = performance.now (); const animate = (currentTime ) => { const elapsed = currentTime - startTimeRef.current ; const progress = Math .min (elapsed / duration, 1 ); setProgress (progress); if (progress < 1 ) { frameRef.current = requestAnimationFrame (animate); } }; frameRef.current = requestAnimationFrame (animate); }, [duration]); useEffect (() => { return () => { if (frameRef.current ) { cancelAnimationFrame (frameRef.current ); } }; }, []); return [progress, start]; }
5.3 RAF vs setTimeout/setInterval useEffect (() => { const timer = setInterval (() => { setPosition (prev => prev + 1 ); }, 16 ); return () => clearInterval (timer); }, []); useEffect (() => { let frameId; const animate = ( ) => { setPosition (prev => prev + 1 ); frameId = requestAnimationFrame (animate); }; frameId = requestAnimationFrame (animate); return () => cancelAnimationFrame (frameId); }, []);
六、常见问题与最佳实践 6.1 常见错误及解决方案 错误1:在渲染阶段读取 DOM function BadComponent ( ) { const ref = useRef (); const height = ref.current ?.offsetHeight || 0 ; return <div ref ={ref} > Content</div > ; } function GoodComponent ( ) { const ref = useRef (); const [height, setHeight] = useState (0 ); useEffect (() => { if (ref.current ) { setHeight (ref.current .offsetHeight ); } }, []); return <div ref ={ref} > Content</div > ; }
错误2:过度使用 useLayoutEffect useLayoutEffect (() => { fetch ('/api/data' ).then (setData); }, []); useEffect (() => { fetch ('/api/data' ).then (setData); }, []);
错误3:忽视清理函数 useEffect (() => { const timer = setInterval (() => { console .log ('tick' ); }, 1000 ); }, []); useEffect (() => { const timer = setInterval (() => { console .log ('tick' ); }, 1000 ); return () => clearInterval (timer); }, []);
6.2 性能优化建议 1. 避免不必要的布局计算 function BadComponent ({ items } ) { const heights = items.map (item => { const element = document .getElementById (item.id ); return element?.offsetHeight || 0 ; }); } function GoodComponent ({ items } ) { const [heights, setHeights] = useState ([]); useEffect (() => { requestAnimationFrame (() => { const newHeights = items.map (item => { const element = document .getElementById (item.id ); return element?.offsetHeight || 0 ; }); setHeights (newHeights); }); }, [items]); }
2. 批量更新 DOM function BatchUpdate ({ items } ) { useLayoutEffect (() => { const measurements = items.map (item => ({ id : item.id , height : document .getElementById (item.id )?.offsetHeight || 0 })); measurements.forEach (({ id, height } ) => { const element = document .getElementById (id); if (element) { element.style .transform = `translateY(${height} px)` ; } }); }, [items]); }
3. 使用 CSS 代替 JS 动画 const [height, setHeight] = useState (0 );useEffect (() => { let current = 0 ; const timer = setInterval (() => { current += 5 ; setHeight (current); if (current >= 100 ) clearInterval (timer); }, 16 ); }, []); const [expanded, setExpanded] = useState (false );return ( <div className ={ `container ${expanded ? 'expanded ' : ''}`} style ={{ transition: 'height 0.3s ease-out ', height: expanded ? '100px ' : '0px ' }} /> );
6.3 调试技巧 1. 可视化渲染时机 function useRenderLog (name ) { console .log (`${name} rendering` ); useLayoutEffect (() => { console .log (`${name} layout effect` ); }); useEffect (() => { console .log (`${name} effect` ); requestAnimationFrame (() => { console .log (`${name} next frame` ); }); }); }
2. 性能监控 function usePerformanceMonitor (name ) { const renderStart = performance.now (); useLayoutEffect (() => { const layoutEffectTime = performance.now (); console .log (`${name} to layout effect: ${layoutEffectTime - renderStart} ms` ); }); useEffect (() => { const effectTime = performance.now (); console .log (`${name} to effect: ${effectTime - renderStart} ms` ); requestAnimationFrame (() => { const frameTime = performance.now (); console .log (`${name} to next frame: ${frameTime - renderStart} ms` ); }); }); }
七、React 18 并发特性与渲染时机 7.1 并发渲染的影响 React 18 引入的并发特性改变了一些渲染行为:
import { startTransition, useDeferredValue, useId } from 'react' ;function ConcurrentComponent ({ searchTerm, items } ) { const deferredSearchTerm = useDeferredValue (searchTerm); const handleExpensiveUpdate = ( ) => { startTransition (() => { setExpensiveState (calculateExpensiveValue ()); }); }; const handleUrgentUpdate = ( ) => { setUrgentState (value); }; }
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 } ) { const [results, setResults] = useState ([]); const [isSearching, setIsSearching] = useState (false ); const deferredQuery = useDeferredValue (query); useEffect (() => { setIsSearching (query !== deferredQuery); }, [query, deferredQuery]); useEffect (() => { let cancelled = false ; async function doSearch ( ) { const data = await searchAPI (deferredQuery); if (!cancelled) { startTransition (() => { setResults (data); }); } } doSearch (); return () => { cancelled = true ; }; }, [deferredQuery]); return ( <div > {isSearching && <Spinner /> } <ResultsList results ={results} /> </div > ); }
八、实战总结:回到我们的折叠组件 现在,让我们用学到的知识重新审视最初的折叠组件,看看它是如何解决各种渲染时机问题的:
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 关键代码片段回顾 export const useCollapse = (options ) => { const { maxVisibleRows = 2 , dependencies = [] } = options; const containerRef = useRef (); const [isExpanded, setIsExpanded] = useState (false ); const [showToggleButton, setShowToggleButton] = useState (false ); useEffect (() => { requestAnimationFrame (() => calculateHeightsWithScale ()); }, dependencies); return { containerRef, isExpanded, showToggleButton, setIsExpanded, containerStyle : { height : !showToggleButton ? 'auto' : isExpanded ? heights.full : heights.visible , overflow : 'hidden' , transition : 'height 0.3s ease-in-out' } }; };
九、写在最后 通过这个真实的电商项目案例,我们深入探讨了 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
浏览器渲染原理
如果这篇文章对你有帮助,欢迎点赞、收藏和分享。有任何问题或不同见解,也欢迎在评论区讨论!