JSX 本质与编译过程
第1章:JSX 本质与编译过程
JSX 不是 HTML,也不是模板语言——它是一种语法糖,最终被编译工具链转换成普通的 JavaScript 函数调用。理解这一转换过程,是真正掌握 React 的第一步。
本章核心问题:JSX 在编译后到底变成了什么?新旧变换有什么区别? 读完本章你将理解:
- JSX 表达式在编译后变成函数调用,React 元素只是普通 JS 对象
- React 17 新版 JSX 变换的核心改进及其配置方式
- SWC 与 Babel 的权衡,以及 React 19 在 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)
type:字符串(原生 DOM 元素)或组件函数/类props:属性对象,null表示无属性children:零个或多个子节点,可以是字符串、数字、其他 React 元素,或这些类型的数组
这个函数返回的是一个React 元素——一个普通的 JavaScript 对象,描述了你想渲染的内容。它不是 DOM 节点,只是虚拟 DOM 树上的一个描述节点。
新版变换:jsx() 工厂函数(React 17+)
React 17 引入了全新的 JSX 变换,这是一次对编译工具链的重大改进。新变换有两个核心目标:
- 消除对
React命名空间的隐式依赖,让每个 JSX 文件不再需要手动导入 React - 优化运行时性能,将静态和动态 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"
})
]
});
}
注意两个关键差异:
_jsxvs_jsxs:jsx用于单子节点或无子节点,jsxs用于多子节点。这个区分让运行时能够避免不必要的数组包装判断。children作为 props 字段:新变换将children直接内联到 props 对象,而非作为独立参数。
新旧变换的配置方式
在 Babel 中,通过 @babel/preset-react 的 runtime 选项控制:
{
"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 代码功能等价。差异主要在:
- 插件生态:Babel 的插件生态更丰富,SWC 的插件系统尚在成熟中
- 调试信息:Babel 在某些场景下生成更详细的 source map
- 构建工具集成:Next.js 13+ 默认使用 SWC,Vite 可通过
@vitejs/plugin-react-swc切换
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
理解编译输出能帮助你回答这类问题:
- 为什么条件渲染
{condition && <Component />}有时会渲染数字0? 因为0是 falsy 但合法的 React 子节点。 - 为什么不能在 JSX 属性中使用语句? 因为属性值会成为函数参数,只能是表达式。
- 为什么 JSX 必须有一个根元素? 因为
return只能返回一个值,而React.createElement的调用就是那个值——Fragment<>...</>本质上是_jsx(Fragment, { children: [...] })。
常见陷阱:混用新旧变换
如果项目中部分依赖使用旧版变换(React.createElement),而主项目使用新变换(jsx()),两者在运行时是兼容的——React 会正确处理两种格式的元素对象。但在以下场景需要注意:
- monorepo 中的 Babel 配置不一致:确保所有 package 使用相同的
runtime设置,否则可能出现某些包需要import React而其他包不需要的混乱。 - 第三方库未更新:一些老旧的 React 组件库可能仍然依赖
React.createElement,在 tree-shaking 时可能导致 React 被意外保留在 bundle 中。 - TypeScript 的
jsx编译选项:确保tsconfig.json中的jsx设置与 Babel/SWC 配置一致(react-jsx对应新变换,react对应旧变换)。