JSX 与 ReactElement 的本质

第五编 · 第二章:JSX 与 ReactElement 的本质的深入分析


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 渲染机制的第一步。