React 渲染详解:状态更新与屏幕像素的关系
React的渲染、协调与提交阶段如何与浏览器的渲染过程——包括布局、绘制和合成——相连接,从而实现从状态更新到实际屏幕像素的呈现。
来源: Rendering Lifecycle in React: From State Update to Pixels on the Screen
在使用 React 时,经常会听到“组件重新渲染”这样的说法。但到底什么是渲染呢?
这时自然会有一些疑问:状态一改变,React 会立即将更新推送到 DOM 吗?状态变化是否会导致所有组件都重新渲染?而当 React 重新渲染某内容时,是否意味着浏览器会立刻重绘整个页面?事实证明,这些说法都不正确。
要真正理解渲染生命周期,就需要了解两个协同工作的独立系统:
React 自身的渲染生命周期,以及浏览器的渲染流程。
一旦你明白了这两个系统之间的关联,诸如协调、提交阶段、useEffect、useLayoutEffect、重新渲染以及记忆化这些概念就不再显得神秘,而是变得合情合理了。
1. 整体概览
从高层次来看,其流程如下:
User Interaction
│
▼
State / Props Change
│
▼
┌──────────────────────┐
│ React Render │
│ │
│ Component executes │
│ New UI is returned │
└──────────┬───────────┘
│
▼
Reconciliation
│
▼
┌──────────────────────┐
│ Commit Phase │
│ │
│ Update real DOM │
└──────────┬───────────┘
│
▼
┌──────────────────────────┐
│ Browser Rendering │
│ │
│ Style → Layout → Paint │
│ → Composite │
└────────────┬─────────────┘
│
▼
Screen
关键要点在于:
React 会确定界面应该呈现什么样子以及需要哪些 DOM 变更。然后浏览器会将这些 DOM 变更转化为屏幕上的实际像素。
2. “渲染”是什么意思?
“渲染”这个词有多种含义。
在 React 的语境中,渲染通常指的是:
React 会调用你的组件函数,并确定最终界面应该是什么样子。
请看这个组件:
function Counter() {
const [count, setCount] = useState(0);
return (
<button>
Count: {count}
</button>
);
}
当 React 进行渲染时,函数体会被执行:
Counter()
↓
returns React element
↓
<button>Count: 0</button>
这并不意味着浏览器已经将按钮绘制在屏幕上。
在任何视觉内容出现之前,React 还需要判断实际的 DOM 是否真的需要更改。
简而言之:
React Render
≠
DOM Update
≠
Browser Paint
渲染与更新 DOM 是两种不同的操作。
3. 浏览器渲染生命周期
在深入了解 React 的内部机制之前,先理解浏览器本身的工作流程会很有帮助。
假设你有这样的标记:
<h1 class="title">
Hello World
</h1>
搭配这样的样式表:
.title {
color: red;
font-size: 30px;
}
在这些内容以视觉形式呈现之前,浏览器会先执行一系列内部步骤。
该流程的简化版本如下:
HTML
↓
DOM
↓
CSS
↓
CSSOM
↓
Render Tree
↓
Layout
↓
Paint
↓
Composite
↓
Screen
让我们逐一了解每个阶段。
4. HTML → DOM
浏览器读取HTML并构建DOM,即文档对象模型。
给定这样的标记:
<body>
<h1>Hello World</h1>
<p>Welcome</p>
</body>
浏览器会构建出大致如下的树形结构:
Document
│
└── body
├── h1
│ └── "Hello World"
│
└── p
└── "Welcome"
JavaScript随后可以直接查询和操作这个结构:
document.querySelector("h1");
本质上,DOM回答的是一个问题:
页面上存在哪些元素?
5. CSS → CSSOM
除了DOM之外,浏览器还会解析CSS并构建CSSOM,即CSS对象模型。
例如:
h1 {
color: red;
font-size: 30px;
}
基于此,浏览器能够确定哪些样式适用于哪些元素。
从概念上来看,其过程如下:
h1
├── color: red
└── font-size: 30px
CSSOM回答的是另一个问题:
每个元素应如何进行视觉样式设置?
6. DOM + CSSOM → 渲染树
接下来,浏览器会将DOM与CSSOM合并,从而生成渲染树。
有一个值得注意的细节:
DOM中的并非每个节点都会出现在渲染树中。
例如,看这段标记:
<div class="hidden">
Hello
</div>
再结合这条规则:
.hidden {
display: none;
}
div元素存在于DOM中:
DOM
└── div
但由于它带有:
display: none;
因此它会被完全排除在视觉渲染树之外。
7. 布局
一旦浏览器确定了哪些元素需要显示,它就会计算这些元素的几何属性。
这包括计算以下内容:
- 宽度
- 高度
- 位置
- 外边距
- 内边距
- 坐标
- 元素之间的空间关系
以这条规则为例:
.box {
width: 200px;
height: 100px;
margin-left: 50px;
}
浏览器必须生成如下具体的数值:
x = 50px
y = ...
width = 200px
height = 100px
这个计算步骤被称为布局,也常被称作重排。
8. 绘制
布局完成后,浏览器便拥有了每个可见元素的坐标。
接下来的步骤就是将这些元素的实际外观绘制出来。
考虑以下样式:
.box {
background: blue;
color: white;
border: 2px solid black;
}
绘制包括以下内容:
- 背景
- 文本
- 边框
- 阴影
- 图片
- 其他视觉元素
顺序为:
Layout
↓
Paint
↓
Pixels
9. 合成
如今的浏览器可以将页面拆分成多个独立的图层。
例如:
Layer 1 → Page background
Layer 2 → Header
Layer 3 → Content
Layer 4 → Modal
随后,浏览器的合成器会将所有这些图层合并成最终显示在屏幕上的单一图像。
某些CSS属性,比如:
transform
opacity
通常仅通过合成操作即可实现动画效果,从而避免因影响几何形状的属性而产生的繁重布局工作。
这也是为何动画处理transform往往更受青睐的原因之一。
10. React 渲染生命周期
在介绍了浏览器端的相关内容后,接下来我们来探讨 React 是如何处理渲染的。
一个有助于理解的概念模型将其分为四个阶段:
1. Trigger
2. Render
3. Reconciliation
4. Commit
5. Browser Rendering
让我们逐一了解这些阶段。
11. 第一阶段——触发
在任何操作发生之前,React 需要一个信号来表明:
“用户界面可能需要更新。”
这个信号被称为渲染触发器。
常见的触发因素包括:
1. 状态更新:setCount(10);
2. 属性变化:
当父组件因count的值变化而重新渲染时,其子组件可能会因此获得新的属性。
3. 上下文更新:useContext(MyContext);
每当从上下文中读取的值发生变化时,使用该值的组件可能需要进行更新。
4. 外部存储更新:库和API也可以触发React的更新。
示例包括:
Redux
Zustand
useSyncExternalStore
首次加载应用也属于触发因素之一。
12. 第二阶段 — 渲染
一旦React检测到有更新,它就会进入渲染阶段,在此阶段它会运行各个组件以确定新的用户界面。
以这个示例为例:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
最初,count的值为0。React调用Counter()后生成:
<button>
Count: 0
</button>
一旦点击按钮并执行了相关代码后:
setCount(c => c + 1);
count 的值变为 1,React 会重新执行该组件,结果如下:
<button>
Count: 1
</button>
13. 重要提示:渲染并不等同于 DOM 更新
这是 React 模型中的一个关键区别。
当 React 执行此函数时:
function Counter() {
console.log("Counter rendered");
return <button>Count: 1</button>;
}
它只是计算 UI 应该呈现的样子。这并不意味着 DOM 已经改变或浏览器已经重新绘制了任何内容。
实际的流程如下:
Component executes
↓
React creates new element representation
↓
Reconciliation
↓
Determine required DOM changes
↓
Commit changes
由此可得出一条重要规则:
渲染组件并不一定会导致 DOM 更新。
14. 第三阶段——协调
在此阶段,React 会对比旧 UI 和新 UI,以确定具体有哪些差异。
假设之前的界面结构如下:
button
└── "Count: 0"
而新的界面结构则为:
button
└── "Count: 1"
React能够判断出:
button → same
text → changed
因此它只需修改实际发生变化的DOM部分即可:
button.textContent = "Count: 1";
其核心目的就是在只有少量内容发生变化时,避免拆解并重新构建整个DOM树。
15. 键与协调机制
当React处理列表时,键的作用尤为重要。
例如:
items.map(item => (
<li key={item.id}>
{item.name}
</li>
))
React依靠键来在多次渲染中识别各个元素的身份。假设初始状态为:
Before:
A
B
C
最终变为:
After:
A
C
只要有稳定的键,React就能正确推断出:
A → same
B → removed
C → same
这为React在协调机制中提供了更为可靠的信息依据。
当列表项可能被重新排序、添加或删除时,切勿使用数组索引作为键。
16. 第4阶段 — 提交
一旦 React 确定了需要哪些更改,就会进入提交阶段,在此阶段它会将这些更改实际应用到真实的 DOM 中。
可以想象这样一个按钮元素:在该阶段完成后,其显示的数值会从零变为一。
此时,React 的内部结构与实时 DOM 终于一致了。
提交阶段是与渲染阶段不同的独立步骤:
Render Phase
↓
Calculate what should change
Commit Phase
↓
Actually apply DOM changes
17. 渲染阶段与提交阶段
理解这种区分是面试官经常考察的内容之一。
渲染阶段
在此阶段,React 会:
- 执行组件函数
- 确定 UI 的下一个版本
- 进行同步处理
- 找出需要更改的内容
Component
↓
React elements
↓
Reconciliation
提交阶段
在此阶段,React 会:
- 执行实际的 DOM 变更操作
- 更新所有引用
- 运行与此次更新相关的布局效果处理
React result
↓
Real DOM
以下是该流程的简化示意图:
React
│
▼
┌──────────────┐
│ Render Phase │
└──────┬───────┘
│
▼
┌───────────────┐
│ Commit Phase │
└──────┬────────┘
│
▼
Real DOM
18. 提交后会发生什么?
一旦提交阶段结束,浏览器就会接管并检测到 DOM 已发生改变。
根据变化的内容,浏览器可能需要依次执行多个阶段:
DOM Changes
↓
Style Calculation
↓
Layout
↓
Paint
↓
Composite
并非所有变更都需要经历所有这些阶段才能产生实际效果——例如某些变更可能只需要重新绘制界面,而无需进行完整的布局重新计算。
这正是 React 的生命周期将控制权交给前述浏览器渲染流程的节点。
19. useLayoutEffect
useLayoutEffect会在React完成DOM更新后、浏览器有机会绘制新内容之前被触发。
执行顺序如下:
React Render
↓
Commit
↓
DOM updated
↓
useLayoutEffect
↓
Browser Paint
function Tooltip() {
const ref = useRef(null);
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
console.log(rect.width);
}, []);
return (
<div ref={ref}>
Tooltip
</div>
);
}
当你需要执行以下操作时,这个钩子是理想的选择:
- 测量DOM中的元素
- 读取当前的布局数值
- 在用户看到任何内容之前同步进行视觉调整
需要注意一点:如果useLayoutEffect中的操作过于复杂,可能会阻碍绘制过程,导致界面响应迟缓。
20. useEffect
相比之下,useEffect适用于那些不需要阻塞浏览器绘制过程的操作。
useEffect(() => {
fetch("/api/products");
}, []);
Render
↓
Commit
↓
Browser Paint
↓
useEffect
实际的调度细节比这更为复杂,但核心的实用原则是:
只有当必须在浏览器绘制之前执行某些操作时,才使用
useLayoutEffect;对于那些在界面已经显示后执行也没问题的操作,则使用useEffect。
21. 完整示例
import { useEffect, useLayoutEffect, useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
console.log("Render");
useLayoutEffect(() => {
console.log("Layout Effect");
});
useEffect(() => {
console.log("Effect");
});
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
从概念上讲,事件顺序如下:
User clicks button
↓
setCount()
↓
State update scheduled
↓
Counter() executes
↓
"Render"
↓
React reconciles
↓
React commits DOM update
↓
"Layout Effect"
↓
Browser paints updated UI
↓
"Effect"
22. 父组件重新渲染会导致子组件重新渲染吗?
以这个示例为例:
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
<Child />
</>
);
}
当 count 发生变化时:
Parent renders
↓
Child is encountered
↓
Child may render as part of reconciliation
So,
Parent state update
↓
Parent render
↓
Child can render
这种行为正是 React.memo 作为优化工具存在的原因之一。
23. React.memo
假设你有:
const Child = React.memo(function Child() {
console.log("Child rendered");
return <p>Hello</p>;
});
如果父组件重新渲染,但传递给子组件的属性在引用上保持不变,React 就可以完全跳过对该已缓存子组件的重新渲染。
以下是直观的示意图:
Parent update
↓
Parent renders
↓
React.memo checks Child props
↓
Props unchanged
↓
Skip Child render
不过请记住:
React.memo能提升性能,但它并不能保证组件永远不会再次渲染。
上下文变化、内部状态以及其他触发因素仍可能迫使组件更新,与是否使用了记忆化无关。
24. useMemo 和 useCallback
这两个钩子常常被误解。
useMemo:用于记忆化计算后的值。
const result = useMemo(() => expensiveCalculation(data), [data]);
useCallback:用于记忆化函数引用。
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
在那些需要通过引用相等性来避免重复工作的场景中,这两种方法确实非常有用——尤其是在将属性传递给已应用记忆化的子组件时。
不过,如果默认就在所有地方使用它们,反而会增加代码的复杂性,却无法带来实际的性能提升。
25. React的渲染与浏览器的绘制并非同一回事
这一点值得明确指出。
想象以下组件:
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
<p>Hello</p>
</div>
);
}
当count发生变化时:
App renders
↓
New React tree created
↓
Reconciliation
↓
Only count text differs
↓
Commit small DOM update
↓
Browser rendering
React并不一定会从头开始重新构建这些标记:
<div>
<button>...</button>
<p>Hello</p>
</div>
26. 虚拟DOM——建立正确的概念模型
人们常说React依靠虚拟DOM来加速浏览器性能,但这其实是一种简化的说法。
实际上,React会在内存中保存UI的内部描述,并通过比对机制来确定哪些部分发生了变化。
浏览器本身并不存在虚拟DOM的概念,它只处理HTML、DOM、CSS以及最终的像素内容。
从概念上讲,其工作流程如下:
React Component
↓
React Element Tree
↓
Reconciliation
↓
DOM Mutations
↓
Browser
↓
Pixels
虚拟 DOM 并非浏览器实际渲染的内容。
27. 重新渲染与重绘
这是两个截然不同的概念,不应混为一谈。
React 的重新渲染
指的是组件函数再次被执行——本质上就是 Component() 被调用。
浏览器的重绘
指的是由于某些视觉属性发生变化,浏览器会重新绘制屏幕上的像素——即 Paint 步骤。
因此请记住:React 的重新渲染并不等同于浏览器的重绘。
一个组件完全有可能再次被渲染,但生成的 DOM 结构依然相同。相反,浏览器也可能因为与 React 组件渲染无关的原因而进行重绘。
28. 内容重排与重绘
以这个例子为例:element.style.width = "500px";
更改元素的宽度通常会迫使布局重新计算:
Style
↓
Layout
↓
Paint
↓
Composite
现在再对比一下:element.style.backgroundColor = "red";
在这种情况下,浏览器一般无需重新进行几何计算:
Style
↓
Paint
↓
Composite
而对于某些变换或透明度变化,浏览器可能只需:
Composite
在优化需要大量渲染的动画或界面时,理解这种差异非常重要。
29. 将其应用到真实的 React 场景中
想象一个结构如下的典型电子商务产品页面:
Product Page
├── Navbar
├── Product Gallery
├── Product Information
├── Add To Cart
├── Reviews
└── Recommendations
假设用户点击了加入购物车按钮,该操作会更新购物车中的商品数量。
从概念上讲,事件发生的顺序可能如下:
User clicks "Add to Cart"
↓
Cart state updates
↓
React schedules update
↓
Relevant components render
↓
React reconciles new tree
↓
Only required DOM changes are committed
↓
Browser performs necessary rendering work
↓
Updated cart count appears
在结构良好的应用中,这种购物车数量更新不应不必要的强制重新构建整个用户界面。正因如此,关于组件边界、状态存储位置、记忆化以及整体渲染架构的决策才显得至关重要。
30. 当高成本渲染成为性能问题时
function ProductList({ products }) {
return products.map(product => (
<ExpensiveProductCard
key={product.id}
product={product}
/>
));
}
假设该列表需要渲染10,000个产品,而某些无关的状态变化会引发大量组件的冗余工作——性能可能会明显下降。
常见的缓解方法包括:
React.memo
+
Stable props
+
useMemo/useCallback where justified
+
Virtualization
+
Pagination
+
Code splitting
对于特别庞大的列表,虚拟化技术往往能发挥尤为显著的效果:
10000 items
↓
Only visible ~20-50 items rendered
31. 渲染与并发React的交互方式
较新版本的 React 具备更先进的调度功能。React 可以为不同的更新分配优先级,并在适当的情况下暂停或推迟渲染工作。
需要牢记的核心思维模型是:
Render work
↓
Can be scheduled
↓
Can be interrupted in modern React
↓
Eventually committed
这正是副作用不应存在于组件渲染逻辑中的原因:
function Component() {
fetch("/api/data"); // ❌ Side effect during render
return <div>Hello</div>;
}
相反,应使用 effect 钩子或合适的数据获取模式。理想情况下,渲染阶段应当保持纯函数特性。
32. 纯渲染的优势
从概念上讲,React 组件应表现得像一个纯函数:
UI = f(props, state)
在相同的属性和状态下,它应始终呈现相同的用户界面。例如:
function Greeting({ name }) {
return <h1>Hello {name}</h1>;
}
这种行为是可预测且安全的。
相比之下,在渲染逻辑中执行此类操作则存在问题:
function Component() {
localStorage.setItem("x", "1"); // ❌
fetch("/api"); // ❌
return <div>Hello</div>;
}
这会在 React 可能多次执行或以意外方式调度的阶段中引入副作用。
副作用应该放在其他地方——比如在效果处理函数、事件处理程序,或是框架提供的数据获取机制中。
33. 渲染生命周期——完整的思维模型
USER ACTION
│
▼
State / Props Update
│
▼
React Schedules Work
│
▼
┌──────────────┐
│ RENDER │
│ │
│ Components │
│ execute │
└──────┬───────┘
│
▼
Reconciliation
│
▼
┌──────────────┐
│ COMMIT │
│ │
│ DOM mutation │
└──────┬───────┘
│
▼
useLayoutEffect
│
▼
Browser Rendering
│
▼
Style Calculation
│
▼
Layout
│
▼
Paint
│
▼
Composite
│
▼
SCREEN
│
▼
useEffect
常见面试问题
Q1. 状态更新会立即更新 DOM 吗?
不会。状态更新只是为 React 安排了后续工作。React 会先渲染组件,将输出结果与之前的状态进行比对,之后才会真正执行所需的 DOM 更改。
setState
↓
Render
↓
Reconciliation
↓
Commit
↓
DOM
Q2. 每次渲染都会导致 DOM 更新吗?
不会。React 可以渲染一个组件,但判断其生成的 UI 描述与屏幕上已有的内容完全相同,这种情况下它就不会修改 DOM。
Render
↓
Compare
↓
No meaningful DOM difference
↓
No DOM mutation
Q3. 在 React 中重新渲染会强制浏览器重绘吗?
不会。React 的渲染与浏览器的绘制是两个独立的流程。
React Render ≠ Browser Paint
Q4. “协调”是什么意思?
协调是指 React 将新计算出的 UI 树与之前的版本进行比较,从而确定究竟哪些部分需要更改。
Q5. 在提交阶段会发生什么?
在提交阶段,React 会将所需的更改应用到宿主环境中——例如,在浏览器中更新实际的 DOM 节点。
Q6. useEffect 和 useLayoutEffect 有什么区别?
一个简单的记忆方法:
useLayoutEffect
→ after DOM commit, before paint
useEffect
→ after commit and generally after paint
当需要在用户看到新帧之前同步测量或调整布局时,请使用 useLayoutEffect。对于大多数不需要阻塞绘制过程的副作用,应使用 useEffect。
Q7:什么会触发组件的渲染?
常见的触发因素包括:初次挂载、状态更新、父组件重新渲染、属性变化、上下文更新,或是来自外部存储的更新。
Q8:如何优化渲染?[IMP]
不要默认就在所有地方使用 useMemo,应先找出真正的瓶颈所在。之后可以考虑以下方法:
1. 将状态放在靠近使用它的地方
避免出现状态位于顶层且所有子组件都依赖它的结构:
App
↓
Everything depends on state
prefer:
Feature
↓
Local state
在实际上并不需要全局状态时,应选择更紧凑的布局方式。
2. 有选择地缓存成本较高的组件
const ProductCard = React.memo(ProductCardComponent);
3. 注意可能破坏缓存机制的对象创建
不要像这样反复在线内创建新的对象:
<Child config={{ theme: "dark" }} />
如果Child被包裹在React.memo中且对象身份性很重要,那么你可能需要一个稳定的引用——但只有在真正出现问题时才去解决这个问题。
4. 虚拟化长列表
不要一次性渲染所有10,000个DOM节点,而应仅渲染当前可见的节点。
5. 拖延加载高成本功能
const AdminDashboard = lazy(() => import("./AdminDashboard"));
这样可以减少需要预先下载和执行的JavaScript代码量。
6. 采用对浏览器更友好的渲染技术
尽可能使用 transform 和 opacity 等成本较低的属性来实现动画效果,避免在每一帧都强制进行高成本的布局重新计算。