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 包含了传递给这个节点的所有属性,包括 classNameonClick、样式对象,以及 children

keyref 是两个特殊属性。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 渲染机制的第一步。