本文只抓一个问题:React 到底在解决什么问题?答案不是"简化 DOM 操作",而是把 UI 更新从命令式的逐步操作变成声明式的状态映射。理解这个范式转移,是后续所有文章的前提。

同一个需求,两种写法

需求:页面上有一个计数器,点击按钮数字加一。

jQuery 写法:

1
2
3
4
5
6
7
8
9
10
// jQuery:告诉浏览器"怎么做"
$(document).ready(function() {
let count = 0;
$('#counter').text(count);

$('#increment').click(function() {
count++;
$('#counter').text(count); // 手动把新值写到 DOM
});
});

React 写法:

1
2
3
4
5
6
7
8
9
10
11
// React:告诉框架"应该是什么样"
function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}

表面上看,两段代码做的事情完全一样——点击按钮,数字加一。但它们的工作方式有根本区别。

jQuery 的代码是命令式的:程序员精确描述了"当事件发生时,找到 DOM 节点,把它的文本内容替换成新值"。状态(count 变量)和 DOM 是两个独立的东西,需要程序员手动保持同步。

React 的代码是声明式的:程序员只描述了"UI 应该长什么样"——count 是几,span 里就显示几。至于怎么找到那个 span、怎么更新它的文本、要不要重新创建节点——这些全部由 React 决定。

计数器太简单,两种方式的差距不明显。当 UI 复杂到一定程度,命令式方法的问题就会暴露出来。

命令式 DOM 操作在复杂 UI 下的崩溃

考虑一个稍微真实一点的场景:一个待办事项列表,支持添加、删除、标记完成、过滤(全部/已完成/未完成)、拖拽排序。

命令式方式需要处理的 DOM 操作包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
添加一项:
→ 创建 <li> 节点
→ 插入到列表末尾
→ 更新计数器
→ 如果当前过滤条件是"已完成",判断是否需要隐藏新项

删除一项:
→ 找到对应的 <li> 节点
→ 从 DOM 移除
→ 更新计数器
→ 如果列表为空,显示"无待办"提示

标记完成:
→ 找到对应 <li> 节点
→ 添加删除线样式
→ 更新已完成计数器
→ 如果当前过滤条件是"未完成",隐藏该节点

切换过滤条件:
→ 遍历所有 <li> 节点
→ 根据每个节点的完成状态决定显示/隐藏
→ 更新过滤按钮的激活状态

拖拽排序:
→ 监听 dragstart/dragover/drop 事件
→ 移动 DOM 节点到目标位置
→ 更新内部数组的顺序
→ 确保过滤状态下排序也正确

每一个操作都需要程序员手动找到正确的 DOM 节点,精确执行正确的变更。更麻烦的是操作之间会互相影响——标记完成后如果当前过滤条件是"未完成",还需要隐藏那个节点。删除一项后如果列表变空了,还需要显示空状态提示。

状态和 DOM 之间的同步代码量与状态的组合数成正比增长。当应用有 N 种状态和 M 种 DOM 元素时,最坏情况下需要 N×M 个同步路径。

React 对同一个需求的处理方式截然不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function TodoList() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');

const filtered = todos.filter(todo => {
if (filter === 'done') return todo.done;
if (filter === 'undone') return !todo.done;
return true;
});

return (
<div>
<FilterButtons current={filter} onChange={setFilter} />
{filtered.length === 0 ? (
<p>无待办</p>
) : (
<ul>
{filtered.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => toggleTodo(todo.id)}
onDelete={() => deleteTodo(todo.id)}
/>
))}
</ul>
)}
<p>{todos.filter(t => !t.done).length} 项未完成</p>
</div>
);
}

添加、删除、标记完成、过滤——所有操作只需修改 todosfilter 状态。React 根据新状态重新计算整个 UI 应该是什么样,然后自动找出需要变更的 DOM 节点并执行更新。程序员不需要写任何 DOM 同步代码。

核心抽象:UI = f(state)

React 的核心抽象可以压成一个公式:

1
UI = f(state)

组件是函数 f,接收状态作为输入,返回 UI 描述作为输出。每次状态变化时,React 重新调用函数 f,得到新的 UI 描述,然后计算出需要对真实 DOM 做的最小变更。

1
2
3
state₁  →  f(state₁)  →  UI₁  ─┐
├─ diff → DOM 变更
state₂ → f(state₂) → UI₂ ─┘

这个模型有三个关键性质。

第一,可预测性。给定相同的 state,f 总是返回相同的 UI 描述。组件函数应该是纯函数——不读写外部变量,不直接操作 DOM,不在渲染过程中发起网络请求。这意味着任何时刻的 UI 都可以由当前 state 完全确定,不依赖于"之前执行过哪些 DOM 操作"的历史。

第二,组合性。f 可以在内部调用其他函数(子组件),形成组件树。整棵树仍然满足 UI = f(state) 的关系——根组件的状态决定了整棵树的输出。

1
2
3
4
5
6
7
8
App(state)
├── Header(state.user)
├── TodoList(state.todos, state.filter)
│ ├── FilterButtons(state.filter)
│ ├── TodoItem(state.todos[0])
│ ├── TodoItem(state.todos[1])
│ └── ...
└── Footer()

第三,增量更新。React 不会每次状态变化都销毁整个 DOM 重新创建。它比较前后两次 f 的输出(虚拟 DOM diff),只对真实 DOM 执行必要的最小变更。这就是 reconciliation(协调)过程。

虚拟 DOM:不是为了快,是为了可编程

虚拟 DOM 是 React 实现 UI = f(state) 的手段。但虚拟 DOM 经常被误解为"React 快是因为虚拟 DOM 快"。

虚拟 DOM 是一棵 JavaScript 对象树,描述了 UI 应该长什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// JSX
<div className="container">
<h1>Hello</h1>
<p>World</p>
</div>

// 编译后等价于
React.createElement('div', { className: 'container' },
React.createElement('h1', null, 'Hello'),
React.createElement('p', null, 'World')
)

// 返回的虚拟 DOM 对象
{
type: 'div',
props: {
className: 'container',
children: [
{ type: 'h1', props: { children: 'Hello' } },
{ type: 'p', props: { children: 'World' } }
]
}
}

每次状态变化,React 重新调用组件函数,生成一棵新的虚拟 DOM 树,然后和上一棵树做 diff,算出最小变更集,最后批量应用到真实 DOM。

1
2
3
4
5
6
7
8
9
10
11
12
状态变化


重新调用组件函数 → 新虚拟 DOM 树

├── diff(对比旧树)


变更列表(patches)


批量更新真实 DOM(commit)

这个过程比直接操作真实 DOM 多了一层——构建虚拟 DOM 和 diff 都有计算开销。单纯从性能角度看,精心手写的命令式 DOM 操作一定比虚拟 DOM diff 快,因为手写代码可以精确知道哪个节点需要变更,不需要 diff。

虚拟 DOM 的真正价值不在速度,在于可编程性。它把 DOM 更新从"程序员决定怎么改"变成"框架自动算出怎么改"。程序员只需要声明目标状态,React 负责计算从当前 DOM 到目标 DOM 的最短路径。这和数据库的查询优化器类似——SQL 只声明"要什么数据",优化器决定"怎么取数据"。

Fiber:从同步渲染到可中断渲染

早期的 React(16 之前)使用递归方式遍历组件树做 diff,这个过程是同步的——一旦开始就不能中断,必须遍历完整棵树才能释放主线程。如果组件树很大(几千个节点),一次渲染可能占用主线程几十毫秒,导致动画卡顿、输入延迟。

React 16 引入了 Fiber 架构,把递归遍历改成了链表遍历,使得渲染过程可以中断和恢复。

1
2
3
4
5
6
7
8
9
10
11
递归遍历(React 15):         链表遍历(React 16+ Fiber):

App App ──→ Header ──→ h1
/ | \ │
H T F ↓
| |\ | TodoList ──→ TodoItem ──→ TodoItem
h1 I I │

一旦开始,不能中断 Footer

可以在任何节点暂停,让出主线程

Fiber 架构把渲染过程分成两个阶段:

渲染阶段(render phase):遍历组件树,调用组件函数,构建新的虚拟 DOM,计算 diff。这个阶段可以中断——React 可以在处理完一个 Fiber 节点后检查是否有更高优先级的任务(比如用户输入),如果有就暂停渲染,先处理高优先级任务,然后再回来继续。

提交阶段(commit phase):把 diff 结果一次性应用到真实 DOM。这个阶段是同步的,不能中断——部分更新 DOM 会导致用户看到不一致的中间状态。

1
2
3
4
5
6
7
渲染阶段(可中断)                提交阶段(同步)
┌─────────────────────┐ ┌──────────────────┐
│ 调用组件函数 │ │ 批量更新真实 DOM │
│ 构建虚拟 DOM │ → │ 执行 useEffect │
│ 计算 diff │ │ 调用 ref 回调 │
│ (可以暂停让出主线程) │ │ (不可中断) │
└─────────────────────┘ └──────────────────┘

Fiber 架构为 React 18/19 的并发特性奠定了基础。startTransition 把某些更新标记为"可中断的低优先级",useDeferredValue 延迟低优先级值的计算——这些特性都依赖 Fiber 的可中断渲染能力。

React 19:从客户端框架到全栈渲染系统

React 19(2024-12 稳定)把 React 从一个纯客户端 UI 库扩展为一个横跨服务端和客户端的渲染系统。几个标志性变化:

Server Components 允许组件在服务端渲染,且永远不发送 JavaScript 到客户端。这类组件可以直接访问数据库、读取文件系统、调用内部 API,渲染结果以 HTML 和特殊的 RSC 协议格式发送给客户端。

1
2
3
4
5
6
7
传统 React(纯客户端):
浏览器下载 JS → 执行 JS → 生成 DOM → 显示

Server Components(React 19):
服务端执行组件 → 发送渲染结果 → 客户端接收并水合

└─ 服务端组件的 JS 永远不发送到浏览器

Server Components 不是传统 SSR(服务端渲染)的升级版。传统 SSR(Next.js 早期的 getServerSideProps)把整个页面在服务端渲染成 HTML,然后客户端下载全部 JavaScript 做水合(hydration)——所有组件的 JS 仍然要发送到浏览器。Server Components 是组件级的分治——某些组件只在服务端渲染,它们的 JS 代码永远不出现在客户端 bundle 中。

Server Actions 把表单提交和数据变更从 REST API 调用简化为函数调用。一个标记了 'use server' 的函数可以直接在客户端组件中调用,框架自动处理序列化、网络传输和错误处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 服务端 action
async function addTodo(formData) {
'use server';
const text = formData.get('text');
await db.todos.create({ text });
}

// 客户端组件直接调用
function AddTodoForm() {
return (
<form action={addTodo}>
<input name="text" />
<button type="submit">添加</button>
</form>
);
}

React Compiler(v1.0 于 2025-10 稳定)在构建时自动插入 memoization 代码,大部分场景下不再需要手动编写 useMemouseCallbackReact.memo。编译器分析组件函数的依赖关系,自动决定哪些计算结果需要缓存、哪些组件可以跳过重渲染。

React 19.2(2025-10)引入了 <Activity> 组件(保活隐藏 UI 而不丢失状态)、useEffectEvent(把事件逻辑从 useEffect 依赖中解耦)、React Performance Tracks(Chrome DevTools 中的 React 专用性能分析轨道)。

这个系列会从基础开始逐步深入到这些新特性。代码示例默认基于 React 19 的行为,涉及 19.2 新特性时会明确标注。

实验:在浏览器中观察 React 的渲染行为

打开任何一个 React 应用(可以用 StackBlitz 快速创建:https://stackblitz.com/edit/vitejs-vite-react-ts),安装 React DevTools 浏览器扩展,然后进行以下实验。

实验一:观察组件树

打开 React DevTools 的 “Components” 标签页,展开组件树。每个组件对应一个函数,组件树反映了函数之间的调用关系。点击某个组件,右侧面板显示它的 props 和 state。

实验二:观察重渲染

打开 React DevTools 的设置,勾选"Highlight updates when components render"。然后操作页面——每次组件重渲染时,它的边框会闪烁。观察哪些组件在状态变化时重新渲染了、哪些没有。

修改一个父组件的状态,观察子组件是否重渲染。默认情况下,父组件重渲染时所有子组件都会重渲染——即使子组件的 props 没有变化。这是 React 的默认行为,也是 React Compiler 试图优化的核心场景。

实验三:观察提交阶段

打开 React DevTools 的 “Profiler” 标签页,点击录制,操作页面,停止录制。Profiler 显示每次"提交"(commit)的时间线——每次 commit 对应一次真实 DOM 更新。注意观察:一次用户操作可能触发多次 render,但 React 会把它们批量合并成一次 commit。

模式提炼

React 的 UI = f(state) 模型体现了一个通用设计模式:用声明式描述替代命令式操作。

在命令式模型中,程序员描述"从状态 A 到状态 B 需要执行哪些步骤"。步骤数量与状态转换的组合数成正比。当系统有 N 种状态时,最坏情况下有 N×(N-1) 种转换路径,每条路径需要单独编写。

在声明式模型中,程序员只描述"给定状态 X,输出应该是什么"。框架(或运行时)自动计算从当前输出到目标输出的最短路径。无论系统有多少种状态,描述函数只有 N 个(每种状态一个映射),不会出现 N² 爆炸。

这个模式在软件工程中反复出现:

  • SQL 声明要什么数据,查询优化器决定怎么取。
  • Kubernetes YAML 声明要什么资源状态,控制器循环把集群推向目标状态。
  • Terraform 声明要什么基础设施,引擎计算 diff 并执行变更。
  • React 声明要什么 UI,reconciler 计算 DOM diff 并执行更新。

共同点是引入一个中间层(优化器、控制器、reconciler),把"目标状态描述"翻译成"最小变更操作"。代价是中间层本身有计算开销和抽象泄漏,优势是把 O(N²) 的手动同步降低到 O(N) 的声明式描述。

工程迁移表

React 概念 Vue 3 对应 Svelte 对应 一般编程模型 核心差异
组件函数 setup() + template .svelte 文件(编译时组件) 纯函数 React 组件在每次渲染时重新调用,Vue setup 只执行一次
JSX(createElement 返回值) 虚拟 DOM(h 函数返回值) 无虚拟 DOM(编译时生成 DOM 操作) 中间表示(IR) Svelte 在编译期直接生成命令式 DOM 操作代码
useState ref() / reactive() let 声明(编译器追踪) 可观察值(Observable) Vue 的 ref 是响应式代理,Svelte 靠编译器追踪赋值语句
虚拟 DOM diff 虚拟 DOM diff(类似) 无 diff(编译期静态分析) diff 算法 Svelte 在编译期知道哪些 DOM 节点受哪些变量影响
Fiber(可中断渲染) 无对应(Vue 渲染是同步的) 无对应 协作式调度 React 独有,允许高优先级更新打断低优先级渲染
Server Components Nuxt 的服务端组件(实验性) SvelteKit 的 load 函数(概念类似) 远程过程调用 + 流式传输 React RSC 是组件级的服务端/客户端分治
React Compiler Vue 编译器(模板编译时优化) Svelte 编译器(整体设计即编译时) 提前编译优化(AOT) React Compiler 后加的,Vue/Svelte 从设计之初就是编译时框架

常见误解

误解一:React 快是因为虚拟 DOM 快。

修正:虚拟 DOM 不可能比精确的手动 DOM 操作快——它多了构建虚拟树和 diff 的开销。虚拟 DOM 的价值是把 DOM 更新从手动精确操作变成自动计算,让程序员只写声明式代码而不需要管 DOM 同步。在大多数真实应用中,这个开销完全可以接受。在极端性能场景下(比如每帧更新上千个节点的动画),可能需要绕过 React 直接操作 DOM。

误解二:React 是一个框架。

修正:React 自我定位为"库"(library),不是框架(framework)。React 本身只解决 UI 渲染这一件事——组件、状态、渲染、diff。路由、数据获取、构建工具、服务端渲染——这些都不在 React 库的范围内,需要搭配其他工具(React Router、TanStack Query、Vite、Next.js 等)。React 官方在 2025 年废弃了 Create React App 后,推荐使用 Next.js 或 Vite 作为项目起手工具。

误解三:每次 setState 都会导致整个页面重新渲染。

修正:setState 触发的是组件级别的重渲染,不是页面级别的。只有调用了 setState 的组件及其子组件会重新执行渲染函数。更准确地说,React 会重新调用该组件函数生成新的虚拟 DOM,然后只对变化的部分更新真实 DOM(提交阶段)。父组件重渲染时,子组件默认也会重渲染(即使 props 没变),但 React Compiler 或手动的 React.memo 可以跳过不必要的子组件渲染。

误解四:useEffect 是 componentDidMount 的替代品。

修正:useEffect 的心智模型不是生命周期(“组件挂载时执行”),而是同步(“把组件状态和外部系统保持同步”)。依赖数组不是"什么时候运行",而是"同步哪些值"。当依赖数组中的值变化时,useEffect 重新执行,是因为需要根据新值重新同步外部系统——而不是因为"组件更新了"。这个区别在后续讲 useEffect 的文章中会展开。

误解五:Server Components 就是 SSR(服务端渲染)。

修正:传统 SSR 把整个页面在服务端渲染成 HTML,然后客户端下载全部组件的 JavaScript 做水合——组件代码仍然发送到客户端。Server Components 是组件级的分治:标记为 Server Component 的组件只在服务端执行,它的 JavaScript 代码永远不出现在客户端 bundle 中。客户端只收到渲染结果(序列化的组件树),不收到代码。两者可以共存——一个页面中部分组件是 Server Component,部分是 Client Component。

练习

练习 1:命令式 vs 声明式思维

用纯 JavaScript(不用任何框架)实现一个 toggle 按钮:点击后在"ON"和"OFF"之间切换,同时改变背景色。写完后思考:如果需要增加第三种状态"STANDBY",代码需要改多少处?然后用 React 实现同样的功能,比较两者在扩展状态时的代码变更量。

练习 2:观察虚拟 DOM 和真实 DOM 的关系

在 React 应用中打开浏览器 DevTools 的 Elements 面板和 React DevTools 的 Components 面板并排查看。修改一个组件的状态,观察:Elements 面板中哪些 DOM 节点闪烁了(被修改),哪些没有闪烁。React 是更新了整个子树,还是只更新了变化的部分?

练习 3:渲染计数

创建一个包含父组件和两个子组件的简单应用。在每个组件函数开头加一行 console.log('render: ComponentName')。修改父组件的状态,观察控制台输出:哪些组件重新渲染了?然后给其中一个子组件加上 React.memo 包装,再次观察。

系列导航

序号 主题 状态
00 导读:React 不是模板引擎,是一个渲染调度系统 本文
01 JSX 与虚拟 DOM:从函数返回值到屏幕像素的完整路径 待写
02 组件与渲染:每次 setState 到底发生了什么 待写
03 单向数据流:props 向下、事件向上的设计意图 待写
04 组合优于继承:React 的组件设计哲学 待写

参考资料