JSX与ReactElement:从语法编译到真实DOM渲染的完整原理
React 写久了,JSX 早就成了肌肉记忆。可你有没有在某个瞬间突然卡壳:我在 JSX 里写了一个<div className="card">Hello</div>,它到底是怎么一步步变成页面上的 DOM 的?面试被问到 ReactElement 是什么,脑子里只有模糊印象的人其实不少。JSX 和 ReactElement 是同一枚硬币的两面:JSX 是开发者写的语法,ReactElement 是 React 内核真正消费的数据结构。这篇文章不打算停留在“JSX 是语法糖”这种层面,我会把编译产物展开给你看、把 ReactElement 的每个字段掰开揉碎、再把从 createElement 到 Fiber 再到真实 DOM 的链路梳理一遍,最后聊聊我实际开发中踩过的几个相关坑。适合已经写过一些 React 组件、想真正搞懂底层原理的前端开发者,对准备面试的人来说,这更是绕不开的考点。
1. 换个视角看 JSX:它不是模板,是函数调用
1.1 模板与 JSX 的思维差异
可能有很多人是先学 Vue 这类模板语法再接触 React 的,我也是从那条路线走过来的。模板语法给你的直觉是:HTML 是一种独立的标记语言,里面用特殊的指令去控制逻辑,比如 v-if、v-for,然后在运行时有一个解析器去解读这些指令。这种模式在浏览器里确实跑得通,但它把“模板”和“逻辑”分成了两个世界。
JSX 完全不同。它看起来像 HTML,但没有独立语法、没有指令系统,也不需要在运行时解析字符串。JSX 本身是 JavaScript 的一种语法扩展,编译时直接转换为普通的函数调用。你可以把 JSX 理解为“为了少写几个函数的快捷记号”,并不是 React 发明的一套模板语言。这一点如果没想通,后面看编译产物、看源码都会觉得隔着一层纱。
1.2 编译这一步到底干了什么
用一段最简单的 JSX 来演示:
const element = <div className="box">Hello</div>;在经典 Babel 配置下(React 17 之前),编译结果是:
const element = React.createElement("div", { className: "box" }, "Hello");在 React 17 之后通常启用新的 JSX transform,结果变成:
const element = _jsx("div", { className: "box", children: "Hello" });注意两个产物的共同点:标签名 div 变成了第一个参数;属性集合变成了一个对象;子节点文本 Hello 被放进了 children 字段。区别只在于调用的是 React.createElement 还是 react/jsx-runtime 里注入的 _jsx 函数。
为什么会有两套转换模式?经典模式要求每个文件顶部显式 import React,编译器才有地方挂 React.createElement;这个要求在 hooks 时代显得很累赘,还要担心打包时把整个 React 对象引进来影响 tree-shaking。React 17 因此引入了新的自动运行时,Babel 或 TypeScript 在编译时会把 _jsx、_jsxs 这些函数自动 import 进来,开发者在代码里几乎感受不到它们的存在,但产物更干净,体积也有优化空间。
1.3 一个可以亲手验证的转换实验
光看结论容易被忘掉,我建议你亲手做一次验证。方法很简单:创建一个 Vite + React 项目,随便写一个组件,然后打开 node_modules 里已经构建好的产物,或者用 Babel 的在线工具贴一段 JSX 看输出。重点看三处:type 是字符串还是变量引用、props 对象里有哪些字段、children 是单个值还是数组。我每次做这个实验,一打印完编译产物,很多人第一反应都是“原来我之前写的 JSX 根本就不是模板”。
2. ReactElement 的数据结构到底长什么样
2.1 控制台里的真实 ReactElement
理论讲清楚后,我们直接打印一份真实的 ReactElement 看看。下面这段代码在任意 React 项目里都能跑:
const el = <div className="box" key="demo">Hello</div>; console.log(el);浏览器控制台会输出类似这样的对象:
{ $$typeof: Symbol.for("react.element"), type: "div", key: "demo", ref: null, props: { className: "box", children: "Hello" } }还有一个 _owner 字段和 _store 字段,主要是调试和追踪用的内部信息,不必每个都记。核心的五个字段是 $$typeof、type、key、ref、props。你可以把 ReactElement 理解成一张“施工图纸”:React 拿到这张图纸,才知道要创建什么类型的节点、挂上什么属性、塞什么子节点。整张图纸是一个完全普通、可以被 JSON.stringify 部分序列化的对象(除了 $$typeof 是 Symbol)。
2.2 type、props、key、ref 的分工
type 决定“创建什么”。它主要有几种取值:原生标签的字符串,比如 "div"、"span"、"input";函数组件或类组件,取的是函数引用本身;还有 Fragment 等特殊节点。type 是字符串时比较单纯,React 只要根据字符串去创建对应的 DOM 元素即可;type 是函数时,React 会把 props 传进去执行函数,拿到返回的 ReactElement 后再继续向下解析。类组件和函数组件的差异也在这里体现:React 对函数组件直接调用函数、对类组件则先实例化再调用 render 方法。无论哪种,最终拿到的都是一个 ReactElement 描述对象。
key 和 ref 不会进入 props。JSX 里写的<Child key="a" ref={nodeRef} title="xx" />,经过编译后 key 和 ref 被单独提取出来,剩下的 title 才会进入 props。这直接解释了为什么在组件内部去读 props.key 永远拿不到值——它不是被“偷偷隐藏”了,而是压根没放进去。
props 承载其余一切。className、style、事件监听函数、自定义属性,全都在这个对象里平铺,children 也是 props 的一个普通属性。React 没有给 children 单独开一个字段,因为把它放进 props 后,所有子组件的接收逻辑就可以统一走 props 通道。
2.3 $$typeof 为什么是一个 Symbol
每次打印 ReactElement 都会看到一坨 Symbol.for("react.element"),很多人不知道这是干嘛的。它的核心作用是防伪。因为在浏览器环境里,React 需要接收服务端渲染出的数据,如果服务端返回的数据是 JSON,那么 JSON 无法表示 Symbol 类型。如果某一个注入点被利用、有人伪造了一个普通对象冒充 ReactElement,只要它的 $$typeof 不是正确的 Symbol,React 在渲染时就绝不信任它,直接不允许当合法元素处理。这是 React 在早期版本出过安全问题后补上的防御机制。理解这一点,“为什么 ReactElement 里要放一个 symbol”就不再是死记硬背的面试题。
3. 从 JSX 到 ReactElement 的完整链路拆解
3.1 手写一个简化版 createElement
React 源码里的 createElement 做了大量边界处理,我在这里按同样的核心逻辑写一个简化版,足够用来理解底层链路:
function createElement(type, config, ...children) { const props = {}; let key = null; let ref = null; if (config != null) { if (config.key !== undefined) key = config.key; if (config.ref !== undefined) ref = config.ref; for (const propName in config) { if (propName !== "key" && propName !== "ref") { props[propName] = config[propName]; } } } const childrenLength = children.length; if (childrenLength === 1) { props.children = children[0]; } else if (childrenLength > 1) { props.children = children; } return { $$typeof: Symbol.for("react.element"), type, key, ref, props }; }这里最值得琢磨的是 props.children 的赋值逻辑:单节点时直接赋值,多节点时变成数组。这个设计看起来很随意,其实是刻意为之。如果总是包成数组,那使用者读 children 时每次都要做一层解包判断;但如果总是摊平,多子节点场景又没法表达“我有一组子节点”。所以源码选择“常见情况优先”——单子节点直接给你,多子节点才聚合为数组。真实 React 源码内部处理比这复杂,还会处理嵌套 children 的 flatten,但骨架就是这个。
3.2 children 的单节点与多节点处理
正因为有上面这个设计,所以日常开发中会看到一种经典问题:自己封装一个容器组件,内部想对 children 做遍历,于是写成了children.map(...)。结果使用方只传一个子节点时直接抛错,因为这个场景下 children 是单个元素对象,对象没有 map 方法。
React 提供了 React.Children 系列工具来帮你统一处理这种差异。React.Children.toArray(children)会将单节点、多节点、嵌套数组统一归一化成一维数组;React.Children.map则可以直接遍历而不关心 children 的原始形态。在写通用组件、进行子节点重排或过滤时,别手动判断 Array.isArray,直接用工具方法最稳。
3.3 从 ReactElement 到真实 DOM 的接力
ReactElement 本身只是一个描述对象,什么界面都不会产生。真正干活的是 React 的调度与渲染系统。在 React 18+ 的并发架构下,ReactElement 会在协调阶段被转换成 Fiber 节点,Fiber 会记录 type、props、flags、alternate 等字段,形成一个可中断、可恢复的链表树。协调完成后,提交阶段会在宿主环境里执行真实创建或更新:浏览器端调用 document.createElement、setProperty、appendChild 等 DOM 操作,把 Fiber 上的差异落到底层。
这里可以做一个类比:ReactElement 是餐厅菜单,Fiber 是后厨工单,真实 DOM 是最终端上桌的菜。整条链路上你能完整看到全貌、能逐字段分析的部分,就是最前面的 JSX 编译产物和 ReactElement 结构。再往深处走就是渲染器内部的事情了,但对于日常调试来说,理解到 Fiber 这一步已经足够。
初次渲染和更新时,这条链路也有差别。初次渲染时,React 会根据 ReactElement 从头构建整个 Fiber 树,再一次性提交到 DOM;更新时则通过 setState 或组件重新渲染生成新的 ReactElement,与旧 Fiber 树做 diff,把变化的节点打上标记,最后提交。无论哪一种,React 拿到的输入都是 ReactElement 或嵌套的 ReactElement 集合。
4. JSX 语法细节里的隐藏规则
4.1 大小写、注释、布尔值这些“小事”
JSX 里有一个非常简单但很多人一开始会翻车的规则:自定义组件的标签必须大写开头。<div>会被编译成jsx("div", ...),而<Card>会被编译成jsx(Card, ...),前者是字符串 type,后者是变量引用。你如果用<card>去写一个自定义组件,Babel 会把它当成字符串,React 自然找不到你想要的组件,页面上一片空白或者默默渲染出一个未知标签。
注释也得注意:JSX 中不能写<!-- -->这种 HTML 注释,要写{/* 注释内容 */}。花括号内是表达式区域,注释必须放在这里。另外,对表达式求值是 false、null、undefined、true 的结果不会被渲染出来,但数字 0 会被渲染成 "0"。这就引出了下面要说的短路问题。
4.2 属性展开的顺序问题
使用展开运算符传参是 JSX 的常见操作:<Child {...defaultProps} />。这类写法的编译产物其实是一个对象拷贝加合并的过程。如果你同时写固定属性和展开属性,顺序会决定最后值由谁说了算:
<Child {...defaultProps} name="x" /> <Child name="x" {...defaultProps} />第一行的 name 最终一定是 "x",因为它写在展开之后,后面覆盖前面;第二行的 name 优先级则取决于 defaultProps 对象里是否包含 name 字段。这个规则和普通对象展开一致,我只在遇到“覆盖默认值”场景时用过几次,但理解它的机制能帮你避免“为什么默认 props 把显式传的值覆盖了”这种困惑。
4.3 Fragment 的编译真相
写列表或多节点结构时,我们经常会用到<>...</>或<Fragment>...</Fragment>。编译后它们会被转换为jsx(Fragment, { children: ... }),这里的 Fragment 实际上是 react 包里导出的一个特殊对象,type 不是字符串而是符号对象。React 在协调阶段遇到 Fragment 这种 type 时不会创建真实 DOM 节点,而是直接把它的 children 拿出来挂到父级,从而避免多包一层 div。
这个细节在实际开发中有用:当你把 Fragment 作为子节点数组的 key 载体时,可以给<Fragment key={item.id}>加 key,因为 React 知道 Fragment 是“虚拟容器”,key 可以正常参与 diff,真实 DOM 不会多出节点。
4.4 花括号里的表达式禁区
在 JSX 的{}里可以写任何 JavaScript 表达式,但不能直接写 if、for 这样的语句。很多刚转 React 的人会下意识写:
{ if (visible) <Text /> }这会在编译期直接报错,因为 if 是语句不是表达式。正确的做法是用三元运算符、逻辑运算符,或者把逻辑抽到函数里。这解释了为什么 JSX 社区里大量出现 IIFE 模式:
{ (() => { if (visible) return <Text />; return null; })() }这种写法的确能跑,但可读性糟糕,更好的方案是把这个分支逻辑拆成组件或常量。理解“表达式 vs 语句”的边界,是掌握 JSX 的关键一步。
5. 日常开发中的高频踩坑实录
5.1 key 警告的根源与正确姿势
“Each child in a list should have a unique key prop” 大概是 React 项目里出现频率最高的控制台警告。它的根源在 diff 算法:React 需要通过 key 来判断列表项的身份,数据更新时才能精准复用旧节点。没有 key 时 React 只能按 index 一一对应,顺序一变,节点可能被整体重建,状态和 DOM 都跟着出问题。
正确写法是在 map 返回的最外层元素上写 key,并且 key 要相对列表稳定。用 index 作为 key 只有在列表纯粹静态、不增删不改序时才安全,否则很容易引发奇怪的渲染问题。另外还要记住 key 不会被传给组件,props.key 永远是 undefined,不要在组件内部依赖它。
5.2 && 条件渲染的 0 值坑
JSX 里{count && <Badge />}是很多新手喜欢写的条件渲染,但 count 为 0 时页面会出现一个 0。这就是表达式求值结果的直接渲染:JSX 不会对 0 做特殊过滤,null、false、undefined 会被跳过,0 不会。我实际遇到过页面角落“飘着一个 0”的反馈,排查到后面才发现是商城购物车角标的数量初始值造成。
修法很简单:{count > 0 && <Badge />}或者{count ? <Badge /> : null}。这件事提醒我,所有返回数字的表达式在做 JSX 条件渲染前,最好显式转成布尔值。
5.3 children 结构不同引发的判断问题
第三个高频问题来自 children 的数据结构不确定。封装一个类似 Tabs 的组件时,如果内部用props.children[0]去取第一个子节点,那当使用方只传一个 Tab 时就会出错,因为此时 children 是单个元素而不是数组。React 之所以保留这种“单节点不包数组”的设计,是为了减少开发中最常见场景的解包开销,代价就是使用方偶尔得做归一化处理。
我的建议是:凡是需要对 children 进行遍历、计数、重排的代码,一律先用React.Children.toArray或者React.Children.map处理,不要直接假设它是数组。这个原则我在写了很多通用容器组件后才真正意识到有多重要。
5.4 样式对象与驼峰命名的别扭
在 JSX 里写样式时必须传对象,而不是 CSS 字符串:<div style={{ color: 'red', fontSize: '14px' }} />。这里的 fontSize 不能用连字符,因为 JSX 会直接把它作为对象属性,用连字符的属性名需要加引号才能正常传递。实际开发中常见的问题是复制一段 CSS 进来,忘了把 background-color 改成 backgroundColor,界面样式死活起不来。React 在底层给 style 赋值时也会做一些兼容性处理,比如数字属性自动加 px,但是带单位、带 vendor 前缀的属性还是要遵循驼峰书写的约定。
6. 一些从实践中沉淀下来的个人经验
讲完原理和坑,我再啰嗦几句实操经验。
第一个经验:一定要亲手打印一次 ReactElement,别只在文档里看。随便写一行 JSX,console.log 出来,把 type、props、children 都看一遍。看完之后,很多“XSS 为什么要用 Symbol 防伪”“key 为什么不进 props”这类问题就不需要死记了。
第二个经验:多对比新旧 JSX transform。把 tsconfig 或 Babel 配置里的 jsx 字段从 "react" 改成 "react-jsx",重新构建一次,观察产物变化。你会清楚地看到 _jsx 来自哪里、children 如何被放进了 props。这种对比实验比读十篇文章都有效。
第三个经验:关注 React 19 之后的变化。React 19 里函数组件的 ref 可以直接作为普通 prop 使用,不再需要包裹 forwardRef,jsx-runtime 也在持续演进。技术会变化,但“JSX 编译成函数调用 -> 函数调用返回 ReactElement -> React 用数据结构驱动渲染”这条主线不会变。把主线理解透了,新版本出来的时候你只需要看看增量变化,不至于每次都要重新学一遍。
我最近一次排查一个诡异的渲染问题,最终的定位线索就藏在 props.children 的单节点判断里。所以我还是那句话:JSX 和 ReactElement 这套东西,越早动手打印、越早把函数调用链看清,后面写 React 就越有底。