第 1 章

JSX 本质与编译过程

第1章:JSX 本质与编译过程

JSX 不是 HTML,也不是模板语言——它是一种语法糖,最终被编译工具链转换成普通的 JavaScript 函数调用。理解这一转换过程,是真正掌握 React 的第一步。

本章核心问题:JSX 在编译后到底变成了什么?新旧变换有什么区别? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

JSX 是什么:从语法到语义

当你写下这段代码时:

const element = <h1 className="title">Hello, React</h1>;

你实际上是在使用一种由 Facebook 发明、后被纳入 Babel 生态的语法扩展。JSX 不属于 ECMAScript 规范,浏览器原生无法解析它。它必须经过编译器处理,变成浏览器能够执行的 JavaScript。

JSX 的设计哲学是将 UI 结构的表达能力与 JavaScript 的逻辑能力统一在同一个文件中。与此同时,它刻意保留了类 HTML 的外观,使得组件结构直观可读。但这种"直观"是一种表象——JSX 的底层是函数调用,而函数调用具有完整的 JavaScript 语义。

旧版变换:createElement 时代(React 16 及之前)

在 React 17 之前,JSX 的编译目标是 React.createElement。这就是为什么每一个使用 JSX 的文件顶部都必须写:

import React from 'react';

即使你在代码里从未显式调用 React,编译后的代码也依赖它。来看一个典型的转换:

原始 JSX:

function Welcome({ name }) {
  return (
    <div className="welcome">
      <h1>Hello, {name}</h1>
      <p>Welcome to React</p>
    </div>
  );
}

Babel 旧版转换输出:

function Welcome({ name }) {
  return React.createElement(
    "div",
    { className: "welcome" },
    React.createElement("h1", null, "Hello, ", name),
    React.createElement("p", null, "Welcome to React")
  );
}

React.createElement 的签名是:

React.createElement(type, props, ...children)

这个函数返回的是一个React 元素——一个普通的 JavaScript 对象,描述了你想渲染的内容。它不是 DOM 节点,只是虚拟 DOM 树上的一个描述节点。

新版变换:jsx() 工厂函数(React 17+)

React 17 引入了全新的 JSX 变换,这是一次对编译工具链的重大改进。新变换有两个核心目标:

  1. 消除对 React 命名空间的隐式依赖,让每个 JSX 文件不再需要手动导入 React
  2. 优化运行时性能,将静态和动态 props 的处理分离

新变换将编译目标从 React.createElement 改为从 react/jsx-runtime 自动导入的 _jsx_jsxs 函数。

Babel 新版变换输出:

import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";

function Welcome({ name }) {
  return _jsxs("div", {
    className: "welcome",
    children: [
      _jsxs("h1", {
        children: ["Hello, ", name]
      }),
      _jsx("p", {
        children: "Welcome to React"
      })
    ]
  });
}

注意两个关键差异:

新旧变换的配置方式

在 Babel 中,通过 @babel/preset-reactruntime 选项控制:

{
  "presets": [
    ["@babel/preset-react", {
      "runtime": "automatic"
    }]
  ]
}

"automatic" 启用新变换,"classic" 保留旧行为。SWC 的配置类似:

{
  "jsc": {
    "transform": {
      "react": {
        "runtime": "automatic"
      }
    }
  }
}

Vite(基于 esbuild + SWC/Babel)默认使用新变换。Create React App 4.0+ 也默认启用。


Level 2 · 它是怎么运行的(3-5年经验)

React 元素的内部结构

调用 React.createElement 后,实际上得到的是类似这样的对象:

{
  $$typeof: Symbol(react.element),
  type: "div",
  key: null,
  ref: null,
  props: {
    className: "welcome",
    children: [
      {
        $$typeof: Symbol(react.element),
        type: "h1",
        props: { children: ["Hello, ", name] },
        // ...
      },
      {
        $$typeof: Symbol(react.element),
        type: "p",
        props: { children: "Welcome to React" },
        // ...
      }
    ]
  },
  _owner: null,
}

注意 $$typeof 字段。它是一个 Symbol,用于防止 XSS 攻击——JSON 无法序列化 Symbol,所以如果攻击者通过用户输入注入了一个看起来像 React 元素的对象,React 会拒绝渲染它,因为该对象不会有正确的 $$typeof

SWC 与 Babel:编译速度的权衡

传统上 React 项目使用 Babel 做 JSX 转换。Babel 是基于 JavaScript 实现的,灵活但速度有限。SWC(Speedy Web Compiler)是用 Rust 编写的替代品,在大型项目中可带来 10-70 倍的编译速度提升。

两者在 JSX 转换的语义上完全一致——输出的 JavaScript 代码功能等价。差异主要在:


Level 3 · 规范怎么定义的(资深)

React 19 的变化

React 19 在 JSX 层面做了几项值得关注的调整。

ref 不再是特殊 prop

在 React 18 及之前,ref 是一个特殊属性,无法通过 props.ref 访问。函数组件需要用 forwardRef 包裹才能接收 ref:

// React 18:需要 forwardRef
const Input = forwardRef((props, ref) => {
  return <input ref={ref} {...props} />;
});

React 19 将 ref 作为普通 prop 传递,forwardRef 不再必要:

// React 19:直接接收
function Input({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

这一变化在编译层面意味着 ref 现在会被包含在 jsx() 的 props 参数中,而非单独处理。

key 的处理方式

key 仍然是特殊的——它永远不会出现在 props 对象中,而是作为 jsx() 函数的独立第三参数传递(在新变换中):

// JSX:<li key={item.id}>{item.name}</li>
_jsx("li", { children: item.name }, item.id)
//                                  ^^^^^^^ key 作为第三参数

这是因为 key 是 React 协调(reconciliation)算法使用的元数据,不应该对组件可见。

新版 JSX 变换与 React Compiler 的关系

新版 JSX 变换(jsx() 工厂)不仅是开发体验的改善(不再需要 import React),也是架构层面的优化——它为未来的编译器优化(比如 React Compiler / React Forget)奠定了基础,使得静态结构分析和自动记忆化成为可能。


Level 4 · 边界与陷阱(所有人)

编译后验证:理解你的工具输出

养成一个好习惯:当你遇到难以理解的渲染行为时,查看编译后的输出。可以使用 Babel REPL(babeljs.io/repl)或本地执行:

npx babel --presets @babel/preset-react src/Component.jsx

或使用 SWC CLI:

npx @swc/cli compile src/Component.jsx

理解编译输出能帮助你回答这类问题:

常见陷阱:混用新旧变换

如果项目中部分依赖使用旧版变换(React.createElement),而主项目使用新变换(jsx()),两者在运行时是兼容的——React 会正确处理两种格式的元素对象。但在以下场景需要注意:

  1. monorepo 中的 Babel 配置不一致:确保所有 package 使用相同的 runtime 设置,否则可能出现某些包需要 import React 而其他包不需要的混乱。
  2. 第三方库未更新:一些老旧的 React 组件库可能仍然依赖 React.createElement,在 tree-shaking 时可能导致 React 被意外保留在 bundle 中。
  3. TypeScript 的 jsx 编译选项:确保 tsconfig.json 中的 jsx 设置与 Babel/SWC 配置一致(react-jsx 对应新变换,react 对应旧变换)。
本章评分
4.7  / 5  (108 评分)

💬 留言讨论