各位开发者,大家好!
今天,我们将深入探讨 React 渲染机制的核心——它的双阶段模型:Render Phase(渲染阶段)和 Commit Phase(提交阶段)。理解这两个阶段的物理界限,对于我们编写高性能、可维护的 React 应用至关重要。React 框架在背后为我们抽象了大量的复杂性,但作为专业的开发者,我们需要揭开这层神秘面纱,洞悉其内部运作原理。
React 渲染的本质:UI 与状态的同步
在前端应用中,用户界面 (UI) 本质上是应用程序状态的一个可视化表示。当应用状态发生变化时,我们期望 UI 能够随之更新,以反映最新的状态。然而,直接操作 DOM(文档对象模型)既复杂又效率低下,尤其是在频繁的状态更新场景下。DOM 操作是昂贵的,并且可能导致布局抖动、性能瓶颈。
React 引入了虚拟 DOM (Virtual DOM) 的概念,作为真实 DOM 的一个轻量级内存表示。它的核心思想是:
- 声明式 UI:开发者只需要描述 UI 在给定状态下应该呈现的样子,而不是指令性地描述如何从一个状态转换到另一个状态。
- 效率优化:React 会在内部比较新旧虚拟 DOM 树,计算出最小的 DOM 变更集,然后一次性地将这些变更应用到真实 DOM 上。
为了实现这一目标,React 的渲染过程被精心划分为两个截然不同的阶段,每个阶段都有其独特的职责和特性。
React 渲染的双阶段模型
| 特性 | Render Phase (渲染阶段) | Commit Phase (提交阶段) |
|---|---|---|
| 主要职责 | 构造新的虚拟 DOM 树,计算 DOM 变更。 | 将变更应用到真实 DOM,执行副作用。 |
| 可中断性 | 可中断 (Interruptible) | 不可中断 (Uninterruptible) |
| 纯洁性 | 理想情况下是纯净的,不应产生副作用。 | 必然会产生副作用,直接操作 DOM,运行副作用函数。 |
| 执行时机 | 在组件状态或 props 更新后,或父组件重新渲染时。 | 在渲染阶段计算出所有变更后,立即同步执行。 |
| 异步性 | 可以是异步的,React 调度器可以暂停、恢复或放弃此阶段。 | 必须是同步的,以确保 UI 的一致性和完整性。 |
| 副作用 | 不应包含副作用,否则可能导致不一致的行为。 | 专门用于处理副作用,如 DOM 操作、订阅、网络请求等。 |
| 主要操作 | 调用组件函数(render 方法),执行 useState/useReducer,计算 useMemo/useCallback,Diff 算法。 |
实际 DOM 变更,运行 useLayoutEffect、useEffect、componentDidMount/Update/WillUnmount。 |
这两个阶段是 React 协调 (Reconciliation) 过程的核心,它们共同确保了 UI 的高效且一致的更新。
阶段一:Render Phase(渲染阶段)—— 构思与规划
Render Phase 是 React 渲染过程的第一步,它的主要目标是确定 UI 在当前状态下应该是什么样子。这个阶段的核心是“计算”和“规划”,而不是“执行”或“改变”。
1.1 核心职责与特性
在 Render Phase 中,React 会执行以下操作:
- 调用组件函数或
render方法:对于函数组件,React 会直接调用你的组件函数;对于类组件,它会调用render方法。 - 处理 Hooks:在函数组件中,
useState、useReducer、useContext、useMemo、useCallback等 Hooks 会在这个阶段被执行。它们计算新的状态、派生值或 memoized 值。 - 构建新的 Fiber 树:React 内部使用 Fiber 架构来表示组件树。在渲染阶段,React 会遍历当前的 Fiber 树,并根据组件的返回值(JSX)构建一个新的 Fiber 树(或者说,更新现有 Fiber 树上的“work-in-progress”副本)。
- 执行 Diff 算法:React 会将新的 Fiber 树与旧的 Fiber 树进行比较,识别出所有需要对真实 DOM 进行的变更(如元素的添加、删除、更新属性、移动位置等)。这些变更被称为“副作用列表”或“更新队列”。
这个阶段有几个关键特性:
- 纯净性 (Purity):Render Phase 理想情况下应该是纯净的,这意味着它不应该产生任何副作用。组件的渲染逻辑应该是幂等的:给定相同的 props 和 state,它应该总是返回相同的 JSX。
- 可中断性 (Interruptibility):这是 Render Phase 最重要的特性之一。React 可以随时暂停、恢复甚至放弃这个阶段的工作。这种能力是实现并发模式(Concurrent Mode)和时间切片(Time Slicing)的基础。
1.2 为什么 Render Phase 是可中断的?
可中断性是 React Fiber 架构的核心优势之一。在传统的同步渲染模型中,一旦渲染开始,它就必须一气呵成地完成,这可能会阻塞主线程,导致 UI 响应迟缓或卡顿,尤其是在大型、复杂的组件树渲染时。
React Fiber 通过将渲染工作分解成小单元(即对单个 Fiber 节点的处理),并允许浏览器在这些单元之间插入其他任务(如用户输入、动画),从而解决了这个问题。
当 React 启动 Render Phase 时:
- 它会调度一个任务到内部的调度器(Scheduler)。
- 调度器会利用浏览器的
requestIdleCallback(如果支持,否则会降级到setTimeout等) 来在浏览器空闲时执行渲染工作。 - 如果浏览器在渲染过程中需要执行更高优先级的任务(例如,用户点击了一个按钮,或者需要执行动画帧),React 可以暂停当前的渲染工作,将控制权交还给浏览器。
- 当浏览器再次空闲时,React 可以从上次暂停的地方继续渲染,或者如果状态在暂停期间再次更新,它甚至可以完全放弃之前的渲染工作,从头开始新的渲染。
这种能力使得 React 应用在处理大量并发更新时能够保持流畅的用户体验。
1.3 示例:纯净的 Render 逻辑与潜在的副作用
考虑一个简单的计数器组件:
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// 这是一个副作用,它会在提交阶段运行
useEffect(() => {
console.log('Count changed:', count);
// 这里可能会进行 DOM 操作,例如设置 document title
document.title = `Count: ${count}`;
}, [count]);
// 渲染阶段的核心逻辑
// 这里是纯净的,只是基于 state 计算 JSX
const message = `Current count is: ${count}`;
return (
<div>
<h1>{message}</h1>
<button onClick={() => setCount(prevCount => prevCount + 1)}>Increment</button>
<button onClick={() => setCount(prevCount => prevCount - 1)}>Decrement</button>
</div>
);
}
export default Counter;
在这个 Counter 组件中:
const [count, setCount] = useState(0);:useState在 Render Phase 中被调用,它根据当前状态返回count的值。如果setCount被调用,它会触发一个新的 Render Phase。const message =Current count is: ${count};:这是一个纯计算,基于count生成一个字符串。它只读取count,不修改任何外部状态,也没有 DOM 操作。这是典型的 Render Phase 逻辑。return ( ... );:返回的 JSX 描述了 UI 的结构,同样是纯净的。
1.4 Render Phase 的禁忌:副作用
由于 Render Phase 的可中断性,如果在该阶段产生副作用,可能会导致严重的问题:
- 不一致的状态:如果渲染被中断然后重新开始,而你在中断前执行了副作用(例如,修改了全局变量或发起了网络请求),那么重新开始的渲染可能会在不一致的状态下进行,或者副作用被重复执行。
- 性能问题:不必要的副作用可能导致额外的计算或操作,即便最终的渲染结果被废弃。
- 难以调试:副作用的执行时机变得不可预测,使得问题追踪变得非常困难。
错误示例:在 Render Phase 产生副作用
import React, { useState } from 'react';
let globalCounter = 0; // 外部可变状态
function UnsafeCounter() {
const [count, setCount] = useState(0);
// 这是一个在 Render Phase 中直接修改外部状态的副作用
// 极度危险,可能导致重复执行和不一致
globalCounter++;
console.log('Global counter in Render:', globalCounter);
// 更糟糕的是,如果这里有 DOM 操作,它可能会在渲染被中断时发生一半
// document.getElementById('some-div').textContent = `Render count: ${count}`;
return (
<div>
<h1>Unsafe Counter: {count}</h1>
<p>Global Counter: {globalCounter}</p> {/* 这里会显示递增后的值 */}
<button onClick={() => setCount(prevCount => prevCount + 1)}>Increment</button>
</div>
);
}
export default UnsafeCounter;
当 UnsafeCounter 渲染时,globalCounter++ 会在 Render Phase 中执行。如果 React 由于某种原因(例如,时间切片或并发更新)中断并重新开始渲染这个组件,globalCounter 可能会被意外地递增多次,导致 UI 上的值与预期不符。此外,如果该组件被 StrictMode 包裹,globalCounter++ 甚至可能被有意地执行两次,以帮助开发者发现这类副作用。
正确处理副作用的方式是使用 useEffect 或 useLayoutEffect,它们保证在 Commit Phase 中执行。
阶段二:Commit Phase(提交阶段)—— 应用与执行
Commit Phase 是 React 渲染过程的第二步,也是最后一步。这个阶段的目标是将 Render Phase 计算出的所有 DOM 变更一次性地应用到真实 DOM 上,并执行所有与副作用相关的清理和设置。
2.1 核心职责与特性
在 Commit Phase 中,React 会执行以下操作:
- 应用 DOM 变更:React 会遍历 Render Phase 生成的副作用列表,并批量、同步地执行所有必要的 DOM 操作:
- 插入新的 DOM 节点
- 删除不再需要的 DOM 节点
- 更新 DOM 节点的属性和文本内容
- 移动 DOM 节点(例如,列表项的顺序变化)
- 运行
useLayoutEffect回调:在所有 DOM 变更完成后,但在浏览器进行绘制之前,同步执行useLayoutEffect的回调函数。这对于需要测量 DOM 布局(如获取元素尺寸、滚动位置)或同步修改 DOM 的场景非常有用。 - 更新 Refs:将
ref属性指向的 DOM 节点或组件实例更新为最新的值。 - 运行
useEffect清理函数:在浏览器绘制完成后,异步地执行上一次渲染的useEffect返回的清理函数。 - 运行
useEffect回调:在浏览器绘制完成后,异步地执行当前渲染的useEffect回调函数。 - 生命周期方法调用:对于类组件,会调用
componentDidMount或componentDidUpdate方法。如果组件被卸载,会调用componentWillUnmount方法。
Commit Phase 有以下关键特性:
- 不可中断性 (Uninterruptible):一旦 Commit Phase 开始,它就必须同步、原子地完成,不能被中断。这是为了确保 UI 的一致性和避免视觉上的闪烁或不完整状态。想象一下,如果 DOM 变更只进行了一半就被中断,用户将看到一个破损的 UI。
- 副作用 (Side-Effectful):这个阶段是专门用于执行副作用的。所有需要与外部系统(如 DOM、浏览器 API、网络)交互的操作都应该在这个阶段进行。
- 同步执行 (Synchronous Execution):为了保证 UI 的原子性更新,Commit Phase 是一个同步、阻塞的过程。这也是为什么我们要尽可能地减少在这个阶段执行的复杂或耗时操作,以避免阻塞主线程。
2.2 useEffect 与 useLayoutEffect 的区别
useEffect 和 useLayoutEffect 是处理副作用的主要 Hooks,它们都运行在 Commit Phase,但执行时机略有不同。
| 特性 | useEffect |
useLayoutEffect |
|---|---|---|
| 执行时机 | 在浏览器绘制之后,异步执行。 | 在所有 DOM 变更完成后,浏览器绘制之前,同步执行。 |
| 阻塞性 | 不阻塞浏览器绘制,用户在副作用执行前可能看到旧 UI。 | 阻塞浏览器绘制,用户在副作用执行前不会看到旧 UI。 |
| 用例 | 大多数副作用,如数据获取、订阅、定时器、手动 DOM 订阅。 | 需要同步测量 DOM 布局,或同步修改 DOM 以避免视觉闪烁。 |
| 性能影响 | 不会阻塞 UI 渲染,性能影响较小。 | 可能会阻塞 UI 渲染,如果操作耗时,可能导致卡顿。 |
| 服务器端渲染 | 不会在服务器端运行。 | 会在服务器端运行(在 renderToString 期间),但其 DOM 测量特性在 SSR 中无意义。 |
示例:useEffect 与 useLayoutEffect
考虑一个场景,我们需要在组件挂载后获取一个元素的宽度,并基于此宽度调整另一个元素的样式。
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
function MeasuringComponent() {
const [width, setWidth] = useState(0);
const myDivRef = useRef(null);
// 使用 useEffect
// 这个副作用会在 DOM 更新并浏览器绘制完成后执行
// 如果在副作用中修改了样式,可能会导致一次视觉上的“闪烁”,
// 因为用户会先看到原始布局,然后看到调整后的布局
useEffect(() => {
if (myDivRef.current) {
const currentWidth = myDivRef.current.offsetWidth;
console.log('useEffect: Div width is', currentWidth);
// setWidth(currentWidth); // 如果在这里更新状态,会触发新的渲染
}
}, []); // 空数组表示只在组件挂载和卸载时运行
// 使用 useLayoutEffect
// 这个副作用会在 DOM 更新后,浏览器绘制前同步执行
// 保证了在用户看到 UI 之前,所有必要的布局调整已经完成
useLayoutEffect(() => {
if (myDivRef.current) {
const currentWidth = myDivRef.current.offsetWidth;
console.log('useLayoutEffect: Div width is', currentWidth);
if (width !== currentWidth) { // 避免不必要的渲染循环
setWidth(currentWidth); // 更新状态,触发新的渲染
}
}
}, [width]); // 依赖 width,以便在 width 变化时重新测量
return (
<div>
<div ref={myDivRef} style={{ border: '1px solid blue', padding: '10px', minWidth: '100px' }}>
This is a div to measure.
</div>
<p>Measured width (from useLayoutEffect): {width}px</p>
<p>
<button onClick={() => myDivRef.current.style.width = Math.random() * 200 + 100 + 'px'}>
Change Div Width
</button>
</p>
</div>
);
}
export default MeasuringComponent;
在这个例子中,useLayoutEffect 更适合用来测量 DOM 尺寸并同步更新状态,因为它可以确保在浏览器绘制下一帧之前完成所有布局计算和调整,从而避免视觉上的不一致或闪烁。如果使用 useEffect,用户可能会短暂地看到一个未经调整的布局,然后才看到正确的布局。
2.3 Commit Phase 的执行顺序(简化版)
为了确保 UI 的正确性,React 在 Commit Phase 中执行操作的顺序是严格定义的:
- 执行
componentWillUnmount/useEffect清理函数 (针对上一次渲染中被卸载的组件或即将卸载的副作用)。 - 应用所有 DOM 变更 (插入、更新、删除、移动)。
- 更新 Refs。
- 执行
useLayoutEffect回调。 - 触发浏览器绘制。
- 执行
useEffect清理函数 (针对上一次渲染中仍然存在的副作用,清理旧的副作用)。 - 执行
useEffect回调 (针对当前渲染的副作用)。 - 执行
componentDidMount/componentDidUpdate。
这个顺序对于理解副作用的生命周期至关重要。例如,useLayoutEffect 在浏览器绘制前同步执行,而 useEffect 在绘制后异步执行,这解释了它们在处理视觉一致性问题时的不同表现。
物理界限:Fiber 架构如何划分两阶段
Render Phase 和 Commit Phase 的“物理界限”并非指两个完全独立的代码库或内存区域,而是指 React Fiber 架构在处理工作流上的逻辑划分和数据结构的演变。
3.1 Fiber 架构:可中断的基石
在 React 16 之前,渲染过程是完全同步且递归的,一旦开始就无法中断。React 16 引入了 Fiber 架构,彻底改变了这一局面。
Fiber 是一种工作单元 (Unit of Work) 的概念。每个 React 元素(组件实例、DOM 节点等)在 Fiber 树中都有一个对应的 Fiber 节点。Fiber 节点是一个 JavaScript 对象,它包含了组件的类型、props、状态、子节点、以及指向其父节点和兄弟节点的指针。更重要的是,Fiber 节点还包含了与该组件相关的“工作”(例如,需要执行的副作用、需要更新的 DOM 属性)。
Fiber 架构的核心思想是:
- 链表遍历:React 不再使用递归遍历组件树,而是使用链表式遍历(“work loop”),这使得它可以在任何 Fiber 节点之间暂停和恢复工作。
- 双 Fiber 树:React 内部维护两棵 Fiber 树:
- Current Fiber Tree (当前 Fiber 树):表示当前渲染到屏幕上的 UI 状态。
root.current指向它。 - Work-in-Progress Fiber Tree (工作中的 Fiber 树):在 Render Phase 中构建和修改的树。它从 Current Fiber Tree 克隆而来,或者根据需要创建新的 Fiber 节点。
root.pending(概念上)或通过workInProgressRoot变量引用。
- Current Fiber Tree (当前 Fiber 树):表示当前渲染到屏幕上的 UI 状态。
- 副作用列表:Render Phase 的输出不再仅仅是新的虚拟 DOM 树,而是一个包含所有 DOM 变更指令和副作用回调的链表。这个链表被称为“副作用列表” (Effect List) 或“更新链表”。
3.2 Render Phase 中的 Fiber 工作流
当状态更新触发渲染时:
- React 会从
root.currentFiber 树开始,创建一个workInProgressFiber 树。 - 它会以深度优先的方式遍历
workInProgress树。对于每个 Fiber 节点:- 如果 Fiber 节点对应一个函数组件,React 会调用该函数。
- 如果 Fiber 节点对应一个类组件,React 会调用其
render方法。 - Hooks(如
useState,useMemo)在这个阶段被执行。 - React 会比较新旧 props 和 state,计算出该 Fiber 节点是否需要更新,以及它的子节点是否需要更新。
- 如果该 Fiber 节点或其子节点有需要执行的副作用(如 DOM 变更),这些副作用会被添加到该 Fiber 节点的
flags属性中,并被链接到一个全局的副作用列表中。
- 这个遍历过程可以在任何时候被暂停。React 会保存当前的
workInProgress节点,并在下一次调度时从该节点继续。 - 如果在这个过程中有更高优先级的更新进来,或者浏览器需要执行其他任务,当前的 Render Phase 可能会被完全中断并废弃。之前的
workInProgress树会被丢弃,一个新的 Render Phase 会从头开始,以最新的状态重新构建workInProgress树。这是 Render Phase 可中断性的核心体现。
3.3 Commit Phase 中的 Fiber 工作流
当 Render Phase 完成,并且生成了一个完整的 workInProgress Fiber 树以及副作用列表时,React 就会进入 Commit Phase。
- Commit 阶段是同步的。React 不会中断它。
- React 会遍历 Render Phase 生成的副作用列表。
- 对于列表中的每个 Fiber 节点,React 会根据其
flags属性执行相应的 DOM 操作(插入、更新、删除等)。 - 接着,它会执行
useLayoutEffect回调、更新 Refs。 - 然后,它会触发浏览器绘制。
- 最后,它会执行
useEffect清理函数和回调,以及类组件的生命周期方法。 - 原子性切换:一旦所有的 DOM 变更和同步副作用都已应用到真实 DOM 上,React 会将
root.current指针从旧的 Fiber 树切换到新的workInProgressFiber 树。此时,新的 UI 状态正式“提交”到屏幕上。旧的 Fiber 树会被标记为“备用”或在垃圾回收时清理。
物理界限的体现:
Render Phase 的工作是在一个临时的、可丢弃的 workInProgress Fiber 树上进行的。它只进行计算,不触碰真实 DOM。Commit Phase 的工作是基于 Render Phase 计算出的变更,直接修改真实 DOM,并将 root.current 指针指向新的、已完成的 Fiber 树。
这种双树结构和分阶段处理,是 React 能够在保证 UI 一致性的同时,实现高性能、可中断渲染的根本原因。
graph TD
A[状态/Props更新] --> B{调度器};
B -- 优先级判断 --> C[启动Render Phase];
C -- 构建Work-in-Progress Fiber Tree --> D[遍历组件,调用函数/render方法];
D -- 执行Hooks (useState, useMemo, etc.) --> E[Diffing算法,计算DOM变更];
E -- 收集副作用 (DOM操作, Effect回调) --> F[Work-in-Progress Tree & 副作用列表];
F -- 浏览器空闲? --> G{是};
F -- 浏览器忙碌/高优先级任务? --> H{否};
G -- 继续渲染 --> D;
H -- 暂停渲染, 交出控制权 --> I[浏览器执行其他任务];
I -- 空闲时 --> G;
H -- 更高优先级更新/状态变化 --> J[放弃当前Work-in-Progress Tree, 重新启动Render Phase];
F -- Render Phase 完成 --> K[Commit Phase (不可中断)];
K -- 应用DOM变更 --> L[更新Refs];
L -- 执行useLayoutEffect --> M[触发浏览器绘制];
M -- 执行useEffect清理 --> N[执行useEffect回调];
N -- 更新root.current指针 --> O[UI更新完成,新Fiber Tree成为current];
这个流程图直观地展示了 Render Phase 的可中断性以及 Commit Phase 的原子性和同步性。
交互与高级概念
理解这两个阶段后,我们能更好地掌握 React 的高级特性和性能优化策略。
4.1 状态更新的调度
当我们调用 setState 或 dispatch 一个 action (通过 useReducer) 时,或者 setCount (通过 useState) 时,我们实际上是向 React 调度器提交了一个更新请求。
- 调度器:React 内部的调度器会根据更新的优先级(例如,用户输入优先级高于数据加载)来决定何时开始 Render Phase。在 Concurrent Mode 下,调度器可以根据时间切片来中断和恢复 Render Phase。
- 批量更新:React 会在某些情况下批量处理状态更新,以减少不必要的重新渲染。例如,在事件处理器中,多个
setState调用通常会被合并成一次 Render Phase 和 Commit Phase。
function BatchUpdateExample() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const handleClick = () => {
// 这两个 setState 调用通常会被批量处理,只触发一次完整的渲染周期
setCount(prevCount => prevCount + 1);
setText('Updated!');
console.log('handleClick executed'); // 这会先执行
};
useEffect(() => {
console.log('Component re-rendered (via useEffect)'); // 在 Commit Phase 之后执行
}, [count, text]);
console.log('Render Phase executed'); // 每次 Render Phase 都会执行
return (
<div>
<p>Count: {count}</p>
<p>Text: {text}</p>
<button onClick={handleClick}>Update Both</button>
</div>
);
}
// 观察控制台输出,会发现 'Render Phase executed' 和 'Component re-rendered' 在 handleClick 之后各只出现一次。
4.2 并发渲染与时间切片
并发模式是 React 的一项实验性功能(在 React 18 中部分稳定为 Concurrent Features),它允许 React 在后台同时处理多个渲染任务。Render Phase 的可中断性是实现并发模式的关键。
- 时间切片:React 可以将长时间运行的 Render Phase 工作分解成更小的“时间切片”,在每个切片之间检查是否有更高优先级的任务。如果有,它会暂停当前渲染,优先处理高优先级任务。
- Suspense:Suspense 允许组件在渲染过程中“暂停”并等待数据或其他异步资源加载完成。当一个组件 Suspense 时,React 可以切换到渲染另一个部分,或者显示一个加载指示器,而不会阻塞整个 UI。这同样得益于 Render Phase 的可中断性。
4.3 错误边界 (Error Boundaries)
错误边界是 React 组件,它们可以捕获子组件树中 JavaScript 错误,记录这些错误,并显示备用 UI,而不是使整个应用崩溃。
- 捕获时机:错误边界在 Render Phase 和 Commit Phase 中都能捕获错误。
- 在 Render Phase 中发生的错误(例如,在组件函数中抛出的错误)会被捕获,并导致 React 放弃当前的
workInProgress树,并尝试渲染错误边界的备用 UI。 - 在 Commit Phase 中发生的错误(例如,在
useLayoutEffect或componentDidMount中抛出的错误)也会被捕获。
- 在 Render Phase 中发生的错误(例如,在组件函数中抛出的错误)会被捕获,并导致 React 放弃当前的
理解错误边界捕获错误的阶段,有助于我们设计更健壮的错误处理机制。
实用建议与最佳实践
基于对 Render Phase 和 Commit Phase 的理解,我们可以总结出一些重要的开发原则:
5.1 保持 Render Phase 的纯净性
- 避免副作用:永远不要在组件函数体(Render Phase 逻辑)中直接修改 DOM、发起网络请求、设置定时器或修改外部变量。
- 使用 Hooks 隔离副作用:所有与外部系统交互的逻辑都应该放在
useEffect或useLayoutEffect中。 - 幂等性:组件的渲染逻辑应该是幂等的。多次调用组件函数,只要 props 和 state 相同,就应该返回相同的 JSX。
// 错误示范:在 render 阶段进行网络请求
function BadComponent() {
const [data, setData] = useState(null);
// 这会在每次渲染时都发起请求,并且可能在渲染被中断时重复发起
fetch('/api/data').then(res => res.json()).then(setData);
return <div>{data ? data.message : 'Loading...'}</div>;
}
// 正确示范:使用 useEffect 隔离网络请求
function GoodComponent() {
const [data, setData] = useState(null);
useEffect(() => {
let isMounted = true; // 避免在组件卸载后更新状态
fetch('/api/data')
.then(res => res.json())
.then(fetchedData => {
if (isMounted) {
setData(fetchedData);
}
});
return () => { // 清理函数
isMounted = false;
};
}, []); // 依赖空数组,只在挂载时执行一次
return <div>{data ? data.message : 'Loading...'}</div>;
}
5.2 区分 useEffect 和 useLayoutEffect
- 大多数副作用使用
useEffect:对于不影响视觉布局或不需要同步执行的副作用(如数据获取、日志记录、事件监听),优先使用useEffect。它不会阻塞浏览器绘制,提供更流畅的用户体验。 - 涉及 DOM 测量或同步视觉更新时使用
useLayoutEffect:当你的副作用需要读取 DOM 布局信息(如元素的尺寸、位置),并基于这些信息同步修改 DOM 以避免视觉闪烁时,使用useLayoutEffect。但请注意,滥用useLayoutEffect可能导致性能问题,因为它会阻塞渲染。
5.3 优化渲染性能
- Memoization:使用
React.memo优化函数组件,useMemo优化复杂计算,useCallback优化回调函数。这些工具可以帮助 React 在 Render Phase 中跳过不必要的组件渲染或计算,从而减少工作量。 - 列表渲染优化:为列表中的每个元素提供稳定的
key属性。这有助于 React 的 Diff 算法高效地识别元素的添加、删除和移动,从而最小化 Commit Phase 中的 DOM 操作。 - 避免不必要的重新渲染:确保状态更新是真正必要的。例如,如果
setState的值与当前状态相同,React 通常会跳过后续的渲染。
5.4 理解 StrictMode 的作用
React.StrictMode 是一个开发工具,它会:
- 双重调用 Render Phase 函数:在开发模式下,它会刻意地调用两次组件的函数(Render Phase 逻辑),以及某些 Hooks 的回调(如
useState的更新函数、useReducer的 reducer 函数)。 - 双重调用
useEffect/useLayoutEffect回调和清理函数:它会先执行一次副作用的设置和清理,然后再执行一次设置。
这些行为有助于开发者发现 Render Phase 中的不纯副作用,以及 useEffect / useLayoutEffect 中不正确的清理逻辑。如果在严格模式下你的应用行为异常,那很可能你的代码中存在副作用问题。
结语
React 的 Render Phase 和 Commit Phase 构成了其高效、响应式 UI 渲染机制的基石。Render Phase 是一个可中断的、纯净的计算阶段,负责构建虚拟 DOM 并规划变更;Commit Phase 是一个不可中断的、副作用化的执行阶段,负责将这些变更实际应用到真实 DOM 上。理解这两个阶段的物理界限,以及 Fiber 架构如何支撑这种划分,不仅能帮助我们深入 React 内部原理,更能指导我们写出更健壮、更高效的 React 应用。在日常开发中,始终牢记Render Phase的纯净性,并将副作用妥善地封装在Commit Phase的钩子中,是通往高质量React开发的必经之路。