JSX 与 Virtual DOM
写在前面:为什么理解 JSX 和 Virtual DOM 对 React 工程师重要
用 React 写页面时,每个人都写过 <div className="container">{content}</div> 这样的代码。但有多少人真正理解这个写法背后发生了什么?
JSX 不是 HTML,也不是模板字符串。它是 JavaScript 的一种语法扩展,经过编译后变成 React.createElement() 调用。理解这一点,才能理解为什么 React 的错误提示有时候指向编译后的代码而不是你写的 JSX,才能理解 key prop 为什么重要,才能理解为什么某些模式下 JSX 的性能表现和直觉不一样。
而 Virtual DOM 是 React 用来解决「直接操作 DOM 慢」这个问题的抽象。它不是 React 独有的概念,很多其他框架也用到了类似思路。但 React 的实现有自己独特的设计选择——Fiber 架构、分层 diff、两棵树对比——这些设计选择直接影响了你写代码的方式和性能优化的方向。
JSX 本质:语法糖还是新语言
JSX 编译过程
先从一个最简单的 JSX 例子开始:
const element = <div className="title">Hello</div>;
这行代码不是合法 JavaScript。浏览器不认识它。必须经过编译,才能变成合法 JavaScript。
Babel 默认的编译结果是把 JSX 编译成 React.createElement() 调用:
const element = React.createElement(
'div',
{ className: 'title' },
'Hello'
);
React.createElement() 返回的是一个普通 JavaScript 对象,叫 React Element。它描述了你想要在屏幕上看到的内容:
{
type: 'div',
props: {
className: 'title',
children: 'Hello'
},
key: null,
ref: null,
// ...
}
所以 JSX 的本质是:一种让你用声明式语法描述 UI 的语法糖,编译后变成 createElement 调用,生成描述 UI 的普通 JavaScript 对象。
为什么不直接用 createElement
React.createElement() 是可用的,完全可以直接写:
const element = React.createElement(
'div',
{ className: 'title' },
'Hello'
);
JSX 只是让这个过程更易读。但「更易读」不只是减少括号——它让你能用接近 HTML 的语法表达 UI 结构,同时保留了 JavaScript 的全部表达能力(变量、表达式、函数调用都可以用在 JSX 里)。
JSX 中的表达式
JSX 里可以嵌入任何 JavaScript 表达式,用 {} 包裹:
const name = 'World';
const isLoggedIn = true;
const element = (
<div>
<h1>{name}</h1>
{isLoggedIn && <button>Logout</button>}
{!isLoggedIn && <button>Login</button>}
</div>
);
表达式里可以放函数调用、运算、三元表达式,但不能放语句(if/for/switch 不能直接放在 {} 里)。
这导致了一个常见的 JSX 风格问题:当你想根据条件决定是否渲染某个元素时,用 && 运算符容易出错。比如 count && <Component /> 在 count 为 0 时会渲染 0 而不是什么都不渲染。正确做法是 count > 0 && <Component /> 或者用三元表达式 count ? <Component /> : null。
什么不能放在 JSX 里
JSX 有几个容易出错的地方。
className 而不是 class。因为 class 是 JavaScript 保留字,JSX 属性遵循 DOM API 的命名惯例:className、htmlFor、onClick、onChange。
CSS 样式是对象不是字符串:
// 错误
<div style="color: red; fontSize: 14px">text</div>
// 正确
<div style={{ color: 'red', fontSize: '14px' }}>text</div>
注释要用 {} 包裹:
<div>
{/* 这是注释 */}
{/*
这是多行注释
*/}
content
</div>
JSX 与 React 17 的新编译策略
React 17 引入了一个重要的改变:新的 JSX 变换不再需要文件顶部有 import React from 'react'。
这是因为新的编译策略会直接把 JSX 变成对 jsx(小写)-runtime 函数的调用,而不是 React.createElement:
// 新的编译结果(React 17+)
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('div', { className: 'title', children: 'Hello' });
这带来了两个实际好处:不再需要为了 JSX 写 import React from 'react',以及编译产物体积更小。这个变化对写代码没有直接影响,但理解了编译原理之后,遇到奇怪的错误提示或者要配置 babel-plugin-react-intl 时,会清晰很多。
Virtual DOM:DOM 操作的成本问题
直接操作 DOM 为什么慢
在说 Virtual DOM 之前,先理解为什么直接操作 DOM 是一个性能陷阱。
浏览器的 DOM 是由 C++ 实现的一个树形结构。当 JavaScript 修改 DOM 时,浏览器需要做这些事情:解析 HTML(如果是从头创建)、计算新几何信息(如果改了样式影响布局)、重新绘制(repaint)、重新合成(reflow)。其中重新计算布局(reflow)是最贵的操作,因为它可能影响整棵树。
// 每次修改都触发一次 reflow
for (let i = 0; i < 100; i++) {
el.style.left = `${i}px`; // 触发 reflow
}
正确的做法是合并修改,只触发一次 reflow:
// 一次性修改
el.style.cssText = 'left: 99px'; // 只触发一次 reflow
// 或者先 display:none,改完再显示
但真实应用场景更复杂。你不是修改一个元素,而是根据数据变化更新整个界面。如果用 jQuery 或者原生 DOM API,你得自己决定「哪个节点需要更新」「怎么更新」,这很快就会变成一坨难以维护的命令式代码。
Virtual DOM 的核心思路是:引入一个 JS 层的虚拟 DOM 树,用 JS 计算应当发生的最小变更,然后一次性应用到真实 DOM。
虚拟 DOM 不是什么
虚拟 DOM 不是 Shadow DOM——后者是 Web Components 规范的一部分,是一种封装机制。
虚拟 DOM 也不是快于所有直接 DOM 操作的银弹。它实际上比手写的最小 DOM 操作要慢,因为它多了一层计算。但在真实应用中,手写最小 DOM 操作几乎不可能做到,所以虚拟 DOM 的「不够最优但足够好」往往比「理论上最优但写不出来」更实用。
React Element 与 DOM Element
React Element 和 DOM Element 是两个完全不同的东西。
React Element 是普通 JS 对象,描述 UI 长什么样:
const element = {
type: 'div',
props: {
className: 'container',
children: 'Hello'
}
};
DOM Element 是浏览器 DOM 树里的节点,是真实渲染到屏幕上的东西。
React 的工作是把 React Element 变成 DOM Element,并插入到 DOM 树里。这个过程叫 mounting。当数据变化时,React 根据新的 React Element 生成新的 UI,diff 出变更,只把必要的部分应用到真实 DOM——这个过程叫 updating。
flowchart LR
A["JSX<br/>React.createElement()"] --> B["React Element<br/>(普通 JS 对象)"]
B --> C{数据变化?}
C -->|首次渲染| D["Mount<br/>创建 DOM 节点"]
C -->|更新| E["Reconcile<br/>Diff + Patch"]
D --> F["真实 DOM<br/>渲染到屏幕"]
E --> F
Reconciliation 与 Diff 算法
当组件的 state 或 props 变化时,React 需要决定如何更新 DOM。这个过程叫 reconciliation。React 15 及之前使用简单的递归 diff,React 16 引入了 Fiber 架构改变了这件事。
React 15 的栈协调器
React 15 的 diff 算法是递归的。组件树层层对比,递归完成之前无法中断。这在 UI 复杂的应用里会造成性能问题——如果一次更新产生了很多变化,浏览器可能会在协调期间失去响应。
React 16 的 Fiber 架构
Fiber 是 React 16 引入的新协调引擎。它的核心改变是把递归的协调过程拆成了可中断的工作单元。
每个 React Element 在 Fiber 架构里对应一个 Fiber 节点。Fiber 节点比 React Element 包含更多信息:副作用标记(effect tags)、指向下一个待处理 Fiber 的指针、优先级信息。
Fiber 架构的关键概念是工作循环:
flowchart TD
A["Render Phase<br/>可中断"] --> B{"还有<br/>工作单元?"}
B -->|是| C["处理一个<br/>Fiber 单元"]
C --> D{"被高优先级<br/>打断?"}
D -->|是| E["保存状态<br/>让出主线程"]
D -->|否| B
B -->|否| F["Commit Phase<br/>不可中断"]
E --> G["继续处理"] --> B
F --> H["真实 DOM<br/>更新完成"]
render phase 可以被打断和恢复,这意味着 React 可以优先处理用户输入等高优先级更新,把低优先级更新放后面。
这解释了为什么 useTransition 和 useDeferredValue 能让 React 保持响应——它们标记某些更新为可中断的低优先级工作。
两棵树的 diff 策略
React diff 算法的复杂度是 O(n),这是通过一系列假设达到的:
假设一:不同类型的元素产生不同的树。 如果一个元素从 <div> 变成 <span>,React 会销毁旧的 DOM 节点并创建新的,不做比较。
假设二:通过 key prop 标记哪些子元素在变化。 列表渲染时,每个元素应该有稳定的 key。React 用 key 来判断哪些元素是新增、删除还是移动的。
// 不用 key —— 列表项重新渲染可能有问题
{list.map(item => <li>{item.name}</li>)}
// 用 key —— React 知道哪些项没变
{list.map(item => <li key={item.id}>{item.name}</li>)}
key 的作用范围是兄弟节点之间,不需要全局唯一。key 的值也不应该是数组索引——如果列表会排序或删除,用索引做 key 会导致 diff 错误、性能差、数据错乱。
假设三:只对比同层节点,不跨层级移动。 React diff 只比较同层节点,如果节点被移到了不同父节点,React 会当作删除+新建处理。这也是为什么 React 官方建议把 DOM 树保持合理扁平。
diff 算法的一个具体例子
假设一个列表从 [A, B, C] 变成了 [A, C, B]:
不用 key(按索引对比):
- 索引0:A → A(没变)
- 索引1:B → C(变了,替换内容)
- 索引2:C → B(变了,替换内容)
React 认为 B 和 C 都变了,实际上只是交换了位置。
用 key:
- key=1:A → A(没变)
- key=2:B → B(没变)
- key=3:C → C(没变)
React 知道只需要把 B 和 C 交换位置,DOM 操作从「替换两个节点内容」变成「交换两个节点位置」。
为什么不总是用 index 作为 key
很多人图方便用数组索引作为 key,这在某些场景下会导致 bug。
比如一个输入框列表,用户在第二个输入框里输入了内容,然后往列表头部新增了一个元素:
// 用了索引做 key
{items.map((item, index) => (
<input key={index} defaultValue={item} />
))}
新增后,原来的 index=1 变成了 index=0,index=2 变成了 index=1。React diff 发现 key=1 的节点内容变了(输入框的值),就会更新 DOM。用户本来在第二个输入框输入的内容,出现在了新的第二个位置(而原来第一个变成了新的第二个)。
这是 React 的 diff 算法在 key 基础上的比较——节点内容比较的是 value 不是 DOM 本身。正确的做法是用唯一 id 作为 key。
真实 DOM 更新到底做了什么
当 React 的 diff 算法算出需要更新某个 DOM 节点时,它做的不是直接 innerHTML 替换,而是一系列精细的 DOM API 调用:
// 如果一个文本节点内容变了
node.textContent = newText;
// 如果一个属性变了
domNode.setAttribute('class', newClass);
domNode[propertyName] = newValue;
// 如果样式变了
domNode.style.color = newColor;
// 如果事件处理器变了
domNode.removeEventListener('click', oldHandler);
domNode.addEventListener('click', newHandler);
React 17 之前,事件处理是挂在 document 上的(通过事件委托),React 17 改成了挂在 root DOM 节点上,减少了事件委托的层级。这些细节看起来很底层,但理解它们对调试 React 应用很有帮助——比如为什么直接用 event.stopPropagation() 可能不是你想要的效果(因为事件已经经过了 React 的合成事件系统)。
面试中的表达
JSX 和 Virtual DOM 是两个容易问到深度的问题。展示系统理解的说法:
JSX 本质是语法糖,经过编译变成
React.createElement()调用,生成描述 UI 的普通 JavaScript 对象。Virtual DOM 是这层对象树,它让 React 可以在 JS 层计算最小 DOM 变更,而不是每次数据变化都重新操作 DOM。React 16 的 Fiber 架构把协调过程拆成了可中断的工作单元,使得 React 可以在渲染期间响应高优先级交互。Diff 算法基于三个假设(不同类型元素不同树、key 标记稳定性、同层对比)实现了 O(n) 复杂度,其中 key 的作用是让 React 准确判断列表元素是否真的发生了变化而不是被移动或重新排序。
延展阅读
- React Docs: JSX In Depth — React 官方对 JSX 的深入讲解
- React Docs: Reconciliation — React 的协调算法文档
- A Complete Guide to useEffect — Dan Abramov 深度解析 useEffect,其中对 React 的渲染模型有深入讨论
- Lin Clark: A Cartoon Intro to Fiber — Facebook 工程师 Lin Clark 写的 Fiber 架构图文介绍
- React Fiber Architecture (GitHub) — 更深入的 Fiber 架构设计文档