深入解析JSX是如何通过Babel或SWC等编译器转化为JavaScript可执行代码的,以及React.createElement在运行时如何提取属性、处理子节点并构建轻量级ReactElement虚拟DOM对象的全流程。
JSX是怎么编译成JavaScript的?createElement做了什么?
时间:2026-11-13
浏览:332
文章目录
React.createElement,现代自动模式下转为 _jsx);而在运行时,createElement 接收元素类型、属性和子节点参数,剥离 key 与 ref、挂载默认属性并格式化子元素,最终返回一个描述界面结构的轻量级纯 JavaScript 对象——即 React 虚拟 DOM 节点(ReactElement)。
一、JSX为什么需要编译?编译管线如何工作?
ECMAScript规范本身并不包含类似 <div>Hello</div> 这样的标记语法。如果直接把包含JSX的代码交给浏览器引擎(如V8、SpiderMonkey),解析器在遇到不符合JS语法的标签左尖括号时,会立刻抛出 SyntaxError: Unexpected token '<' 错误。
因此,JSX本质上是一种针对开发者的语法糖。它允许开发者像写HTML模板一样声明组件结构,但所有JSX代码在交付浏览器或Node.js执行前,都必须经过构建工具(例如Babel、SWC、esbuild或TypeScript编译器)的处理。这一静态编译过程主要分为三个连续步骤:
- 词法与语法分析(Parsing):编译器词法分析器(Lexer)将JSX源代码字符串切分为词法单元(Token),随后语法分析器(Parser)将其构建为抽象语法树(AST),其中的节点会被标记为
JSXElement、JSXOpeningElement、JSXAttribute等专用类型。 - 节点转换(Transformation):编译插件(如
@babel/plugin-transform-react-jsx)遍历AST。当遇到JSXElement时,根据编译策略将其替换为对应的函数调用AST节点(即CallExpression)。 - 代码生成(Code Generation):编译器将转换后的全新AST重新串联并打印为普通的JavaScript脚本文件。
二、JSX编译结果的演进:经典运行时与自动运行时
在实际项目中,JSX会被编译成具体的哪段代码?这取决于编译器配置的“运行时模式”(Runtime Mode)。React生态在此经历了一次关键演进:从React 17之前的经典模式(Classic Runtime)升级为了React 17及之后的自动模式(Automatic Runtime)。理解这两种模式的差异,是弄清JSX编译本质的关键。
1. 经典运行时(Classic Runtime)
在经典模式下,编译器规则非常直接:只要看到一个标签,就直接翻译成对 React.createElement 的调用。
// 编译前:JSX
function App() {
return <div className="card"><span>Hello</span></div>;
}
// 经典模式编译后:
function App() {
return React.createElement(
"div",
{ className: "card" },
React.createElement("span", null, "Hello")
);
}
这种模式存在一个强限制:即使代码中完全没有直接写过 React.xxx,文件顶部也必须显式导入 import React from 'react'。因为编译产物直接访问了全局或作用域内的 React 变量。如果没有导入,运行时就会报 ReferenceError: React is not defined。
2. 现代自动运行时(Automatic Runtime)
为了解决多余导入的问题并提升性能,React 17与Babel官方联合推出了新转换器。编译器在识别到JSX时,会自动注入轻量级的内部运行时函数:
// 编译前:JSX(顶部无需写 import React from 'react')
function App() {
return <div className="card"><span>Hello</span></div>;
}
// 自动模式编译后:
import { jsx as _jsx } from "react/jsx-runtime";
function App() {
return _jsx("div", {
className: "card",
children: _jsx("span", { children: "Hello" })
});
}
3. 两种运行时的关键差异对比
| 对比维度 | 经典模式(Classic Runtime) | 自动模式(Automatic Runtime) |
|---|---|---|
| 调用目标 | React.createElement(type, config, ...children) |
_jsx(type, props, key) |
| 作用域依赖 | 必须手动 import React from 'react' |
编译器自动按需引入 react/jsx-runtime |
| 子节点传递 | 从第3个参数开始作为不定长剩余参数展开 | 直接收拢在第2个参数 props.children 中 |
| Key的处理位置 | 与其他属性混在第2个参数 config 中 |
从props中抽离,作为单独的第3个参数传入(静态推导更优) |
| 打包与运行开销 | 难以精简无用导出,且每次展开children产生微小额外内存 | 更易进行Tree-shaking,且无需运行时动态做arguments切片 |
三、createElement做了什么?深入内部执行机制
不管是哪种模式,运行时最终的核心目标都是创建并返回一个虚拟DOM对象。以 React.createElement(type, config, ...children) 为例,许多初学者误以为它会在这一步创建真实的浏览器 HTMLElement(如 document.createElement),但实际上它完全不接触任何DOM API。
React.createElement 在内部主要执行了以下4项核心任务:
1. 属性过滤与特殊字段分离(提取 key 与 ref)
传入的第2个参数 config 是包含了该标签上声明的所有属性对象。但并非所有属性都能原封不动地留在 props 中。React 需要把用于内部调度和引用的两个保留属性单独提取出来:
key:用于虚拟DOM Diff算法识别节点身份,避免就地复用错误。ref:用于持有底层DOM节点或组件实例的引用。
createElement 会检查 config 中是否存在合法(非 undefined)的 key 和 ref,将它们分别赋给内部独立变量,而不会让它们混入最终暴露给组件的 props 对象中。这也是为什么在子组件内部通过 props.key 或 props.ref 读取不到对应属性的原因。
2. 复制常规 Props 与合并 defaultProps
遍历 config 的自有属性(过滤掉原型链属性以及 key、ref、__self、__source 等保留字段),将普通属性浅拷贝进全新的 props 字典对象中。
如果传入的 type 是一个包含 defaultProps 的组件(常见于Class组件或挂载了静态defaultProps的函数组件),React 会检查此时 props 中的每个字段。只要某个属性的值严格等于 undefined,就会使用 type.defaultProps 对应的默认值填充它。
3. 子节点(children)的归一化打包
从函数的第3个参数开始,后面所有参数都是子节点(...children)。createElement 会根据参数数量做归一化处理:
- 如果只有一个子参数(
childrenLength === 1),直接将该节点赋值给props.children(例如单个字符串或子ReactElement); - 如果有多个子参数(
childrenLength > 1),则创建一个数组容纳它们,并将该数组赋值给props.children; - 若没有子参数,则不会在
props上写入children字段(保持为 undefined)。
4. 组装并返回标准的 ReactElement 纯对象
最后一步,调用工厂函数返回一个纯 JavaScript 对象。为了便于直观理解,以下是 createElement 关键逻辑的精简伪代码实现:
function createElement(type, config, ...children) {
let propName;
const props = {};
let key = null;
let ref = null;
// 1. 提取特殊保留字段 key 与 ref
if (config != null) {
if (config.ref !== undefined) {
ref = config.ref;
}
if (config.key !== undefined) {
key = '' + config.key; // 统一转换为字符串
}
// 浅拷贝其余普通属性
for (propName in config) {
if (
Object.prototype.hasOwnProperty.call(config, propName) &&
propName !== 'key' &&
propName !== 'ref' &&
propName !== '__self' &&
propName !== '__source'
) {
props[propName] = config[propName];
}
}
}
// 2. 格式化子节点 children
const childrenLength = children.length;
if (childrenLength === 1) {
props.children = children[0];
} else if (childrenLength > 1) {
props.children = children;
}
// 3. 合并 defaultProps 默认属性
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
// 4. 返回标准 ReactElement 对象
return {
$$typeof: Symbol.for('react.element'), // 安全标识符
type: type,
key: key,
ref: ref,
props: props,
_owner: ReactCurrentOwner.current
};
}
四、为什么是纯JS对象?$$typeof 与安全性设计
阅读上述结构可以发现,ReactElement 只是一个嵌套普通的 JavaScript 对象字面量(Plain Old JavaScript Object)。它具有两个极大的优势:
- 极度轻量与高跨平台性:相比于真正的浏览器 DOM 节点(拥有数百个实例方法和事件监听属性),纯对象占用极少内存,且能够在 Node.js 服务端、React Native 移动端或 Web Worker 中无差别运行。
- 为高效 Diff 提供统一契约:协调器(Reconciler)只需要比较两棵轻量对象树上的
type、key和props,便能精确算出最小变更,并批量更新到宿主环境。
关键安全机制:为什么必须有 $$typeof: Symbol.for('react.element')?
在Web应用中,常见的服务端漏洞是允许用户提交任意JSON数据并直接持久化存储。如果用户恶意提交了一个伪造的虚拟DOM对象结构(例如伪造 { type: 'script', props: { src: 'xss.js' } }),客户端在没有校验的情况下若直接渲染该对象,就会造成严峻的跨站脚本攻击(XSS)。
由于标准的 JSON 规范完全不支持 Symbol 数据类型,黑客无法在 JSON 报文中包含真实的 JavaScript Symbol。React 在每次执行 createElement 时,都会打上内置的 Symbol.for('react.element') 烙印;在渲染时一旦发现传入对象缺失合法的 Symbol 签名,React 就会拒绝将其作为节点渲染,从而彻底阻断了恶意伪造对象的注入。
五、常见开发误区与边界情况
1. 为什么自定义组件必须大写字母开头?
Babel/SWC 在将 JSX 转为 AST 时,采用了一套非常纯粹的命名约定:
- 以小写字母开头的标签(如
<div>、<span>):编译器认为这是原生 HTML/SVG 内置标签,直接将其转换为字符串字面量传递,即React.createElement("div")。 - 以大写字母开头的标签(如
<MyButton>、<Header>):编译器认为这是一个自定义组件,将其转换为对应的标识符引用,即React.createElement(MyButton)。
如果你将自定义组件命名为 <myComponent />,编译后会变成 React.createElement("myComponent"),浏览器在渲染阶段会尝试寻找名为 myComponent 的原生HTML标签,最终导致渲染异常且无法执行你的组件函数。
2. 为什么条件渲染中的 false、null 不会被渲染为标签?
在写 {isOpen && <Modal />} 时,如果 isOpen 为 false,编译后等价于:
React.createElement("div", null, false && React.createElement(Modal, null));
// 实际上传入 createElement 的第3个参数就是布尔值 false
createElement 照常接收了这个 false 并将其放入 props.children。但在随后的 React 渲染协调阶段(Reconciliation),React 将 null、undefined 和 boolean(true/false)视为合法的“空节点”,在生成真实 DOM 时直接跳过它们,因此界面上不会留下任何痕迹。
总结
回顾全文,JSX 到最终屏幕呈现的本质可以精炼为两句话:
- 编译器的职责:把类似 HTML 的 JSX 树静态解析为普通的 JavaScript 函数调用(在经典模式下为
React.createElement,自动模式下为_jsx),从而绕过浏览器的语法限制。 - createElement 的职责:在运行时对标签属性进行规整,把
key和ref抽离到最外层,规范化props.children,并贴上防篡改的$$typeof标签,最终产出一个标准、轻量且可跨平台的ReactElement纯数据对象。
弄清楚了这两层协作关系,开发者在面对诸如首字母大小写规则、组件重渲染机制、Key 的作用域以及无用 React 导入报错等问题时,便能从底层编译与执行原理上一目了然地定位并解决问题。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。