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 处理上的差异:

基于 VitePress 构建