Appearance
第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.createElement 到 jsx()。
这不仅仅是 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')
);注意几个关键特征:
- children 作为额外参数传入:第三个及之后的参数都是子元素,这意味着
createElement必须使用arguments对象或 rest 参数来收集它们。 - 必须在作用域中存在
React:编译后的代码直接引用了React.createElement,所以即使你的组件代码里看似没有用到React,你也必须写import React from 'react'——否则运行时会报React is not defined。 - 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 处理上的差异: