JSX 不是 HTML
第一次写 JSX 的时候,很多人会觉得这不就是 HTML 吗?在 JavaScript 里写 HTML,语法很像,用起来也差不多。
但这个类比是有误导性的。JSX 和 HTML 是完全不同的两种东西。
HTML 是浏览器能直接解析的标记语言,浏览器遇到 <div> 就知道创建一个 DOM 元素,遇到 class="foo" 就知道给这个元素加上这个类名。
JSX 什么都不是。浏览器不认识 JSX,JavaScript 引擎也不认识 JSX。JSX 只是 JavaScript 的一种语法扩展,它必须经过编译才能变成 JavaScript 运行。
也就是说,你在 JSX 里写的 <div className="foo">Hello</div>,必须经过编译才能变成浏览器能理解的东西。这个编译通常由 Babel 或者 TypeScript 编译器完成。
JSX 编译后是什么
把 JSX 编译一下,你会得到这样的结果:
// 你写的
const element = <div className="foo">Hello</div>;
// 编译后(默认的 React JSX 变换)
const element = React.createElement(
'div',
{ className: 'foo' },
'Hello'
);
React.createElement 返回的是一个普通 JavaScript 对象。这个对象描述了一个 UI 节点,我们叫它 ReactElement。
const element = {
type: 'div',
props: {
className: 'foo',
children: 'Hello'
},
key: null,
ref: null,
};
这就是 ReactElement 的本质:一个用来描述 UI 的普通 JavaScript 对象。它不是 DOM 节点,不是组件实例,只是一个包含"类型"和"属性"和"子元素"的结构化描述。
为什么要设计成对象
为什么要把 UI 描述成一个 JavaScript 对象,而不是直接创建 DOM 节点?
因为对象是 JavaScript 运行时可以操控的东西。把 UI 变成对象之后,JavaScript 可以对它做任何操作:比较、缓存、传递、修改。这些操作在 DOM 上很慢或者做不到,在 JavaScript 对象上却很高效。
这个设计是 Virtual DOM 机制的基础。React 保存了一份"当前 UI"的 JavaScript 对象描述,当状态变化时,它会创建一个"新的 UI"的对象描述,然后对比这两个对象,找出需要更新的最小差异,最后才去操作真实 DOM。
这个 diff 过程是在 JavaScript 层的,比较的是对象而不是 DOM 节点,所以速度比直接操作 DOM 快得多。
React 17 的新编译策略
React 17 引入了一个重要的变化:新的 JSX 变换不再需要文件顶部有 import React from 'react'。
原因是什么呢?之前的编译方式是把 JSX 编译成 React.createElement 调用,所以每次用到 JSX 都需要 React 在作用域里。新的编译方式是直接引用一个 jsx-runtime 函数:
// React 17+ 的编译结果
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('div', { className: 'foo', children: 'Hello' });
这样有几 个好处:不需要为了 JSX 写 import React,编译产物体积更小,因为不再需要包含 React.createElement 的引用。
理解这个变化对调试有帮助。如果你遇到奇怪的 JSX 报错,错误信息指向的是编译后的代码而不是你写的 JSX,知道了编译原理之后就不容易懵。
ReactElement 的结构
一个 ReactElement 对象包含几个关键属性:
type 描述了这个节点的类型。可以是一个字符串(表示 HTML 标签如 'div'、'span'),也可以是一个函数或者 class(表示 React 组件)。
props 包含了传递给这个节点的所有属性,包括 className、onClick、样式对象,以及 children。
key 和 ref 是两个特殊属性。key 用来帮助 React 识别列表中的稳定节点,ref 用来直接访问 DOM 节点。它们不在 props 里,而是单独的属性。
children 在 ReactElement 里是怎么存的
ReactElement 的 props.children 可以有几种形式:
字符串:'Hello' 这样的字符串会直接存在 children 里。
一个 ReactElement:当 JSX 写成了 <div><span>inner</span></div> 的时候,div 的 children 就是代表 span 的那个 ReactElement 对象。
多个 ReactElement:当有多个子节点时,children 是一个数组:[element1, element2, element3]。
这就是为什么 React 能用 Array.map 来渲染列表:
function List({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
每个 <li> 对应一个 ReactElement,这些 ReactElement 组成的数组被存在外层 <ul> 的 props.children 里。React 会负责把这个数组转换成真实的 DOM 列表。
ReactNode 是什么
React 用 ReactNode 这个类型来表示"任何可以作为 React 组件输出的东西"。它可以是:
- ReactElement(一个 UI 节点)
- 字符串或数字(被渲染成文本节点)
- 布尔值或 null(不渲染任何东西)
- ReactNode 数组
props.children 的类型通常就是 ReactNode,这就是为什么 <div>{condition && <span>content</span>}</div> 这种写法能 work:condition && <span> 这个表达式的结果是布尔值和 ReactElement 之一,React 会忽略布尔值,只渲染 ReactElement。
JSX 的条件渲染和列表渲染
基于对 ReactElement 的理解,JSX 里的条件渲染和列表渲染就不难理解了。
条件渲染利用了 JavaScript 的短路求值特性:
{isLoggedIn && <LogoutButton />}
{isLoggedIn ? <LogoutButton /> : <LoginButton />}
列表渲染利用了 JavaScript 的 map 方法:
{items.map(item => <Item key={item.id} {...item} />)}
两种写法的共同点是:最终都是在描述"应该有什么样的 ReactElement",而不是"怎么创建 DOM 节点"。这是声明式 UI 的体现。
常见的一个误解
很多人刚开始用 React 的时候,会觉得 useState 或者 setState 会直接更新页面。这个理解不准确。
准确的理解是:setState 会触发组件重新渲染,生成新的 ReactElement 树,React 会把新的 ReactElement 树和当前的 DOM 对比(diff),然后只更新发生变化的那部分 DOM。
也就是说,setState 更新的是"ReactElement",ReactElement 更新后自动触发的是"Reconciliation"(调和),调和之后才是 DOM 更新。
把 setState 等同于"更新 DOM",忽略了中间的 Reconciliation 步骤,是很多 React 性能问题的根源。
这一章想说的
JSX 不是 HTML,而是 JavaScript 的语法扩展,经过编译变成 React.createElement 调用,生成一个普通的 JavaScript 对象——ReactElement。
ReactElement 是描述 UI 的结构化对象,包含 type、props、key、ref 四个核心属性。它是 Virtual DOM 机制的基础:React 保存 UI 的 JavaScript 对象描述,在状态变化时对比新旧描述,只把差异应用到真实 DOM 上。
理解 JSX 和 ReactElement 的本质,是理解 React 渲染机制的第一步。