Skip to content

第11章 JSX 编译与代码转换

本章要点

  • 从 React.createElement 到 jsx():两代编译目标的架构差异与演进动机
  • 新 JSX Transform 的设计哲学:为什么不再需要 import React
  • Babel 插件 @babel/plugin-transform-react-jsx 的编译流程与 AST 变换细节
  • react/jsx-runtime 与 react/jsx-dev-runtime 的运行时实现
  • TypeScript 中 JSX.Element、JSX.IntrinsicElements 与泛型组件的类型推导机制
  • 自定义 JSX pragma 与 jsxImportSource:跨框架兼容的底层原理
  • createElement 与 jsx() 在 key 提取、children 处理、defaultProps 方面的关键差异

每一个 React 开发者都写过 JSX。但很少有人停下来思考一个根本性的问题:浏览器不认识 JSX,那它是怎么运行的?

答案藏在编译器里。JSX 不是 JavaScript 的语法扩展——它是一种需要被编译的 DSL(Domain-Specific Language)。当你写下 <Button onClick={handleClick}>提交</Button> 时,Babel 或 TypeScript 编译器会将它转换为一个函数调用。这个函数调用的目标,在 React 的历史上经历了一次意义深远的变革:从 React.createElementjsx()

这不仅仅是 API 名字的变化。这次变革改变了编译器的输出格式、消除了对 React 导入的强制依赖、优化了运行时性能、重新定义了 key 和 ref 的处理方式,甚至影响了 TypeScript 的类型推导策略。理解这一变革,不仅能让你在配置工具链时不再困惑,更能让你洞察 React 团队在"编译时 vs 运行时"这条战线上的长期战略——从 JSX Transform 到 React Compiler,编译时优化的思想一脉相承。

下图展示了 JSX 从源代码到最终 React Element 的完整转换链路:

本章将带你深入 JSX 编译的每一个环节:从 Babel 插件的 AST 变换,到运行时函数的源码实现,再到 TypeScript 的类型体操。我们不仅要知道 "what",更要理解 "why"。

11.1 JSX → React.createElement → jsx():两代编译目标

11.1.1 第一代:React.createElement 的时代

从 2013 年 React 诞生到 2020 年 React 17,JSX 的编译目标一直是 React.createElement。这个函数的签名如下:

typescript
function createElement(
  type: string | ComponentType,
  props: Record<string, any> | null,
  ...children: ReactNode[]
): ReactElement;

一段简单的 JSX:

tsx
const element = (
  <div className="container">
    <h1>Hello</h1>
    <p>World</p>
  </div>
);

会被 Babel(使用 @babel/plugin-transform-react-jsx,runtime 设为 "classic")编译为:

typescript
const element = React.createElement(
  'div',
  { className: 'container' },
  React.createElement('h1', null, 'Hello'),
  React.createElement('p', null, 'World')
);

注意几个关键特征:

  1. children 作为额外参数传入:第三个及之后的参数都是子元素,这意味着 createElement 必须使用 arguments 对象或 rest 参数来收集它们。
  2. 必须在作用域中存在 React:编译后的代码直接引用了 React.createElement,所以即使你的组件代码里看似没有用到 React,你也必须写 import React from 'react'——否则运行时会报 React is not defined
  3. key 和 ref 混在 props 中传入:它们被当作普通 props 传给 createElement,由函数内部负责提取。

让我们看看 createElement 的核心实现:

typescript
// packages/react/src/ReactElement.js(简化版)
function createElement(type, config, ...children) {
  let propName;
  const props: Record<string, any> = {};
  let key: string | null = null;
  let ref = null;

  if (config != null) {
    // 从 config 中提取 key 和 ref
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    if (hasValidKey(config)) {
      key = '' + config.key;
    }

    // 将剩余属性复制到 props 中
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName) // key, ref, __self, __source
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // 处理 children
  if (children.length === 1) {
    props.children = children[0];
  } else if (children.length > 1) {
    props.children = children; // 数组
  }

  // 处理 defaultProps
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  return ReactElement(type, key, ref, undefined, undefined, ReactCurrentOwner.current, props);
}

这段代码揭示了 createElement 的三个性能问题:

问题一:每次调用都要遍历 config 来提取 key 和 ref。 key 和 ref 不是普通的 prop,它们会被 React 内部消费而不会传递给组件。但在 createElement 中,它们和其他 props 混在同一个对象里传入,函数必须用循环和条件判断来分离它们。这个工作每次渲染都在做,完全是可以移到编译时的。

问题二:children 通过 rest 参数传入,需要额外处理。 多个 children 作为独立参数传入,函数内部要判断 children 的数量并决定是直接赋值还是创建数组。

问题三:defaultProps 的处理在运行时进行。 每次 createElement 被调用,都需要检查组件是否定义了 defaultProps,并在 props 中填充默认值。

这三个问题共同指向一个结论:createElement 让运行时承担了太多本该在编译时解决的工作。

下图对比了 createElement 与 jsx() 在 key/children 处理上的差异:

11.1.2 第二代:jsx() 与新 JSX Transform

2020 年 10 月,React 团队发布了 React 17,其中最重要的改变之一就是新 JSX Transform。同样的 JSX:

tsx
const element = (
  <div className="container">
    <h1>Hello</h1>
    <p>World</p>
  </div>
);

在新 Transform 下(@babel/plugin-transform-react-jsx,runtime 设为 "automatic")会被编译为:

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

const element = _jsxs('div', {
  className: 'container',
  children: [
    _jsx('h1', { children: 'Hello' }),
    _jsx('p', { children: 'World' }),
  ],
});

变化是根本性的:

  1. import 由编译器自动注入:开发者不再需要手写 import React from 'react'。编译器会自动在文件顶部插入对 react/jsx-runtime 的导入。
  2. children 作为 props 的一部分传入:不再使用 rest 参数,children 直接放在 props 对象中。这消除了运行时对 children 数量的判断逻辑。
  3. 区分 jsx 和 jsxs:单个 child 使用 jsx(),多个 children 使用 jsxs()。这让运行时可以跳过"children 是否为数组"的检查。
  4. key 从 props 中提取到独立参数:如果元素有 key,它会作为第三个参数传入,而不是混在 props 里。
typescript
// 有 key 的情况
<li key={item.id}>{item.name}</li>

// 编译为
_jsx('li', { children: item.name }, item.id);

让我们看看 jsx() 的源码实现,对比 createElement 的差异:

typescript
// packages/react/src/jsx/ReactJSXElement.js(简化版)
function jsx(type, config, maybeKey) {
  let propName;
  const props: Record<string, any> = {};
  let key: string | null = null;
  let ref = null;

  // key 由编译器作为独立参数传入
  if (maybeKey !== undefined) {
    key = '' + maybeKey;
  }

  // 仍然需要检查 config 中的 key(兼容动态 key 的场景)
  if (hasValidKey(config)) {
    key = '' + config.key;
  }

  if (hasValidRef(config)) {
    ref = config.ref;
  }

  // 复制 props——注意 children 已经在 config 中了
  for (propName in config) {
    if (
      hasOwnProperty.call(config, propName) &&
      !RESERVED_PROPS.hasOwnProperty(propName)
    ) {
      props[propName] = config[propName];
    }
  }

  // 注意:没有 defaultProps 的处理!
  // React 19 中 defaultProps 已被废弃(函数组件)

  return ReactElement(type, key, ref, undefined, undefined, ReactCurrentOwner.current, props);
}

深度洞察:从 createElementjsx() 的变迁,体现了 React 团队一个持续多年的设计哲学——将工作从运行时移向编译时。这个思路在后来的 React Compiler 中达到了巅峰。JSX Transform 可以看作 React 编译时革命的第一枪:它证明了编译器可以承担更多责任,让运行时更轻更快。

11.1.3 createElement 与 jsx() 的七大差异

让我用一张完整的对比来总结两代编译目标的差异:

维度createElement (classic)jsx / jsxs (automatic)
导入方式开发者手动 import React编译器自动注入 import { jsx } from 'react/jsx-runtime'
children 传递作为第 3~N 个参数作为 props.children
单/多 children 区分运行时判断 arguments.length编译时区分 jsx() vs jsxs()
key 的传递混在 props 中作为独立的第三个参数
ref 的传递混在 props 中,运行时提取混在 props 中,运行时提取(React 19 中 ref 成为普通 prop)
defaultProps运行时在 createElement 中处理不处理(函数组件中已废弃)
__source / __self通过 Babel 插件注入到 props使用 jsxDEV() 作为独立参数传入

第七个差异值得深入展开。在开发模式下,旧 Transform 使用两个额外的 Babel 插件来注入调试信息:

typescript
// 旧模式(classic)的开发构建
React.createElement('div', {
  className: 'container',
  __source: { fileName: 'App.tsx', lineNumber: 10, columnNumber: 5 },
  __self: this,
});

而新 Transform 使用一个专门的开发模式函数 jsxDEV()

typescript
// 新模式(automatic)的开发构建
import { jsxDEV as _jsxDEV } from 'react/jsx-dev-runtime';

_jsxDEV('div', { className: 'container' }, undefined, false, {
  fileName: 'App.tsx',
  lineNumber: 10,
  columnNumber: 5,
}, this);

jsxDEV 的签名是:

typescript
function jsxDEV(
  type: ElementType,
  config: Record<string, any>,
  key: string | undefined,
  isStaticChildren: boolean,
  source: { fileName: string; lineNumber: number; columnNumber: number },
  self: any,
): ReactElement;

isStaticChildren 参数告诉 React 这个元素的 children 是否是静态的(即在 JSX 中直接写死的,而不是通过 map 等动态生成的)。这让 React 在开发模式下可以对 children 进行更精确的 key 验证。

11.2 新 JSX Transform 的设计动机与实现

下图展示了从 JSX Transform 到 React Compiler 的编译时革命演进路线:

11.2.1 三个设计动机

动机一:消除"幽灵导入"。

在旧模式下,每个包含 JSX 的文件都必须导入 React:

tsx
import React from 'react'; // 看起来没有用到,但不能删!

function Greeting() {
  return <h1>Hello</h1>;
}

ESLint 会提示 React is defined but never used,于是需要配置特殊的规则来忽略这个警告。这不仅给新手造成困惑("为什么要导入一个没用到的东西?"),还增加了 bundle size——即使是一个只返回 JSX 的纯展示组件,也必须把 React 拉进来。

新 Transform 彻底解决了这个问题。编译器负责注入正确的导入:

tsx
// 你写的代码
function Greeting() {
  return <h1>Hello</h1>;
}

// 编译后(编译器自动添加的导入)
import { jsx as _jsx } from 'react/jsx-runtime';

function Greeting() {
  return _jsx('h1', { children: 'Hello' });
}

动机二:为 React 的未来优化铺路。

React 团队在设计新 Transform 时,考虑到了一系列未来可能的优化:

  • key 的静态分析:当 key 作为独立参数传入时,编译器和运行时可以更容易地对 key 进行优化。例如,静态 key 可以在编译时内联,而不需要在运行时从 props 对象中提取。
  • children 的预分类:通过 jsx vs jsxs 的区分,运行时可以直接知道 children 的结构,跳过不必要的类型检查。
  • 去除 defaultProps:新 Transform 不再在运行时处理 defaultProps(对于函数组件),这为后来 React 19 正式废弃函数组件的 defaultProps 做了铺垫。

基于 VitePress 构建