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 处理上的差异:
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' }),
],
});变化是根本性的:
- import 由编译器自动注入:开发者不再需要手写
import React from 'react'。编译器会自动在文件顶部插入对react/jsx-runtime的导入。 - children 作为 props 的一部分传入:不再使用 rest 参数,children 直接放在 props 对象中。这消除了运行时对 children 数量的判断逻辑。
- 区分 jsx 和 jsxs:单个 child 使用
jsx(),多个 children 使用jsxs()。这让运行时可以跳过"children 是否为数组"的检查。 - 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);
}深度洞察:从
createElement到jsx()的变迁,体现了 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 的预分类:通过
jsxvsjsxs的区分,运行时可以直接知道 children 的结构,跳过不必要的类型检查。 - 去除 defaultProps:新 Transform 不再在运行时处理 defaultProps(对于函数组件),这为后来 React 19 正式废弃函数组件的 defaultProps 做了铺垫。