JSX与ReactElement:从语法编译到真实DOM渲染的完整原理
发布时间:2026/10/24 10:21:14 锦皓数字建站

React 写久了JSX 早就成了肌肉记忆。可你有没有在某个瞬间突然卡壳我在 JSX 里写了一个div classNamecardHello/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 classNameboxHello/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 classNamebox keydemoHello/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 keya ref{nodeRef} titlexx /经过编译后 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 不是正确的 SymbolReact 在渲染时就绝不信任它直接不允许当合法元素处理。这是 React 在早期版本出过安全问题后补上的防御机制。理解这一点“为什么 ReactElement 里要放一个 symbol”就不再是死记硬背的面试题。3. 从 JSX 到 ReactElement 的完整链路拆解3.1 手写一个简化版 createElementReact 源码里的 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} namex / Child namex {...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别只在文档里看。随便写一行 JSXconsole.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 使用不再需要包裹 forwardRefjsx-runtime 也在持续演进。技术会变化但“JSX 编译成函数调用 - 函数调用返回 ReactElement - React 用数据结构驱动渲染”这条主线不会变。把主线理解透了新版本出来的时候你只需要看看增量变化不至于每次都要重新学一遍。我最近一次排查一个诡异的渲染问题最终的定位线索就藏在 props.children 的单节点判断里。所以我还是那句话JSX 和 ReactElement 这套东西越早动手打印、越早把函数调用链看清后面写 React 就越有底。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。