news 2026/10/24 10:21:08

JSX与ReactElement:从编译原理到性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JSX与ReactElement:从编译原理到性能优化

开头

做前端这些年,几乎每天都在写 JSX,但真正被问到“JSX 到底是个什么、ReactElement 又是怎么一回事”的时候,能讲清楚的人其实不多。大多数同学的认知停留在“JSX 是 React 的模板语法”这个层面,甚至面试时也只会说“JSX 最后会被 Babel 转成 React.createElement 调用”。这话没错,但离“核心解析”还差得很远。

JSX 不是模板,也不是 HTML。它是 JavaScript 的一种语法扩展,本质上是React.createElement的语法糖。你写的每一段 JSX,在编译阶段都会被转换成一个个普通的 JavaScript 对象,这个对象就是 ReactElement。ReactElement 是 React 应用中最基础的数据单元,组件树也好、Fiber 树也好,追根溯源都是从 ReactElement 开始的。这篇文章我会从 JSX 的编译机制、ReactElement 的数据结构、与 Fiber 的关系、再到实际开发中的排查经验,一层层拆开来讲。

内容比较硬核,但我会尽量用实际代码和踩坑记录来讲,适合对 React 有一定使用经验、想深入理解框架运行原理的同学,也适合正在准备前端进阶面试的朋友,后面每一节都可以当作面试题的答题素材来用。

1. 内容整体设计与思路拆解

先聊一个很多人忽略的问题:为什么 React 要搞 JSX 这么一层东西,而不是直接让开发者用React.createElement写代码?

1.1 从“手写 createElement”到 JSX 的演进逻辑

在最早期,React 团队确实鼓励开发者直接调用React.createElement。但如果你亲手写过就知道,那玩意儿可读性非常差。举个例子,下面这段代码:

React.createElement( 'div', { className: 'container', onClick: handleClick }, React.createElement('h1', null, '标题'), React.createElement('p', { style: { color: 'red' } }, '正文内容') );

如果层级再深两层,括号一多,代码就完全没法看了。JSX 出现之后,同样一段结构变成了:

<div className="container" onClick={handleClick}> <h1>标题</h1> <p style={{ color: 'red' }}>正文内容</p> </div>

这段代码的声明式程度明显高出一大截,嵌套关系一目了然,心智负担也低得多。JSX 的定位就是:用接近 HTML 的声明式写法,描述 UI 元素的树形结构,然后在编译期把它统一转译成 JavaScript 能理解的对象字面量形式。

这个设计和 Vue 的模板语法有本质区别。Vue 的模板是在运行时由编译器解析成渲染函数的,而 JSX 是纯粹的编译期语法糖,浏览器根本不认识 JSX,最终跑在浏览器里的只有转换后的 JavaScript。这就是为什么说“JSX 是语法糖”而不是“模板引擎”。

1.2 ReactElement 在整个 React 架构中的位置

要理解 ReactElement,关键在于分清 React 世界里的几层概念:

  • ReactElement:描述“我想在屏幕上看到什么”的普通对象,是 React 应用的最小描述单元。
  • React Component:函数组件或类组件,接收 props 返回 ReactElement。
  • Fiber Node:React 内部真正用来调度、协调、渲染的工作单元,是 Fiber 架构中的节点。

ReactElement 是“描述”,Fiber Node 是“执行”。React 拿到组件返回的 ReactElement 之后,会基于它创建 Fiber 节点,然后才进入协调、渲染、提交的流程。

简单类比一下:ReactElement 相当于你给装修公司画的户型效果图,Fiber 是施工队手里的施工计划。效果图只描述最终长什么样,施工计划才决定每一步怎么干、先干哪、哪些可以并行。

1.3 为什么必须理解这两个概念

实际开发中,不理解 ReactElement 带来的问题是很具体的。比如你写过一个通用组件,想在渲染前拦截一些 props 或者动态修改子节点,如果不知道 JSX 编译产物是一个对象,你就很难意识到可以直接对 ReactElement 做遍历和修改。再比如做性能优化,很多人习惯在 render 里直接创建新的 JSX 片段,结果每次渲染都是新对象,配合 React.memo 优化失效,排查起来一脸懵——其实就是没理解“元素对象的不变性”这个关键性质。

理解 JSX 与 ReactElement,是打通 React “语法层→描述层→执行层”的第一道关口。

2. JSX 编译原理与转换规则

JSX 能跑起来,靠的是编译器在背后做转换。转换规则不复杂,但要吃透,得把各种边界情况都覆盖到。

2.1 Babel 视角下的 JSX 转换过程

先看最基本的转换规则。JSX 的转换核心是一个函数调用映射关系:

JSX 写法转换后的调用
<div>文本</div>jsx('div', { children: '文本' })
<MyComponent prop="值" />jsx(MyComponent, { prop: '值' })
<div a="1" b={expr} />jsx('div', { a: '1', b: expr })
<div {...rest} />jsx('div', rest)

在 React 17 之前,Babel 用的转换函数是React.createElement,转换结果长这样:

React.createElement('div', { className: 'container' }, '文本')

React 17 之后,官方推荐使用react/jsx-runtime里的jsx/jsxs/jsxDEV函数,转换结果变成:

import { jsx as _jsx } from 'react/jsx-runtime'; _jsx('div', { className: 'container', children: '文本' })

这个变化很关键。老转换方式要求文件里必须显式引入 React,因为编译产物直接调用了React.createElement。而新的 runtime 方式把转换函数做成独立模块,组件文件里不再需要import React from 'react',这也是 React 17 之后新项目里可以“不引入 React 也能写 JSX”的原因。

2.2 属性、子节点、表达式与 Fragment 的编译细节

接下来看几个容易出错的编译细节。

属性名的处理。JSX 中的属性会被原样转成对象的 key,但不是所有属性名都照搬。比如class必须写成className,for必须写成htmlFor。这一层是 React 对 DOM 属性的规范化处理,转换器不会帮你自动纠正这些名字。如果写错了,控制台会直接报 Warning,但页面不一定崩,这种隐性问题在大型项目里排查起来相当费时间。

子节点的处理。单个子节点会变成children字段,多个子节点会变成children数组。这里有个冷知识:转换后的 children 数组并不总是稳定顺序的,编译器会尽量复用现有的childrenprop,而不是每次新建数组对象。但在你直接写 JSX 的代码里,每次渲染都会得到一个新的 children 数组,这就是 React.memo 对元素做浅比较时意外失效的根源之一。

表达式的编译。花括号里的内容会被当作普通 JavaScript 表达式求值,然后作为属性值传入:

<div count={count + 1} name={getName()} />

编译结果大致是:

_jsx('div', { count: count + 1, name: getName() })

表达式求值发生在运行时,JSX 编译本身不执行表达式,只是把它们原样嵌入到函数调用参数里。

Fragment 的编译。不少人以为<React.Fragment>和<>是完全等价的,实际上它们编译目标函数不同,但语义一致。<>的编译结果是<Fragment>组件的调用,只是 Babel 会优先解析短语法:

<> <li>1</li> <li>2</li> </>

编译后:

_jsx(Fragment, { children: [_jsx('li', { children: '1' }), _jsx('li', { children: '2' })] })

2.3 JSX 中的表达式限制与动态拼接

JSX 中只能写表达式,不能写语句。if/else、for 循环都不能直接写在花括号里。很多新手的做法是:

{ if (visible) return <span>显示</span> }

这行直接报语法错误。正确做法是先计算好再放进 JSX:

{ visible && <span>显示</span> }

或者是利用 IIFE:

{ (() => { if (visible) { return <span>显示</span>; } return null; })() }

IIFE 写法虽然能用,但可读性很差,建议抽成变量或者在 return 之前处理好逻辑,JSX 里只做展示。

2.4 手写一个极简转换器来加深理解

为了把“JSX 是语法糖”这个概念彻底刻进脑子里,我建议你在本地用 Babel 单独编译一段 JSX 看看输出。

准备工作:随便建一个空目录,装个@babel/core和@babel/preset-react,然后写一个几行的脚本:

const babel = require('@babel/core'); const code = `<div className="box" onClick={handleClick}> <span>Hello</span> </div>`; const result = babel.transformSync(code, { presets: [ ['@babel/preset-react', { runtime: 'automatic' }] ] }); console.log(result.code);

跑一下就能看到,原先的 JSX 代码被转换成_jsx('div', { className: 'box', onClick: handleClick, children: _jsx('span', { children: 'Hello' }) })。我再把runtime改成'classic'跑一次,得到的就是React.createElement(...)的结果。

这种亲手观察编译产物的过程,比看十篇文章都管用。

3. ReactElement 的核心数据结构与源码级拆解

JSX 编译完之后,得到的是一个 ReactElement 对象。ReactElement 到底长什么样?源码里定义得很清楚。

3.1 ReactElement 对象上到底有哪些字段

ReactElement 在源码中通过ReactElement函数创建,返回的对象结构大致如下(基于 React 18/19 简化版):

{ $$typeof: Symbol.for('react.element'), type: 'div', key: null, ref: null, props: { className: 'box', children: ... }, _owner: null, _store: { validated: false }, _self: null, _source: null }

逐个字段说:

  • $$typeof:Symbol.for('react.element')。这个字段是 React 用来标识“这是一个合法 ReactElement”的标记,同时也是一道安全防线。因为 JSON 序列化时 Symbol 会丢失,所以远程传入的数据永远无法伪造出合法的 ReactElement,从而防止 XSS 类型的注入攻击。这一点值得多说一句,很多人不知道 ReactElement 上还有这种安全设计。
  • type:元素类型。可以是原生 DOM 标签字符串('div'、'span'),也可以是组件函数或类。
  • key:用于列表 diff 的稳定标识,不传就是null。
  • ref:引用对象,用来获取真实 DOM 节点或组件实例。
  • props:包含 JSX 上写的所有属性,外加children。
  • _owner:记录哪个组件创建了这个元素,主要给 devtools 用,生产环境为 null。
  • _store/_self/_source:开发模式下的辅助字段,用于错误提示和源码定位。

3.2$$typeof为什么是 Symbol:关于安全设计的简单延伸

React 对$$typeof检查非常严格,因为 ReactElement 在服务端渲染场景里经常被序列化成字符串传输。如果你能通过JSON.parse造出一个对象,把$$typeof写成字符串'Symbol(react.element)',React 内部是直接拒绝的。因为只有真正的 Symbol 值才能通过Object.is检查。

就是说,普通对象可以被 JSON 伪造出来,但带有 Symbol 的 ReactElement 不能。React 这么做,约束的是“对象必须是在当前运行时由 React 自己创建的”这一条规则。理解这个设计,以后你再看任何“传入自定义对象冒充 ReactElement”的 hack 代码,都会自觉多留个心眼。

3.3 createElement 与 jsx 函数的内部逻辑对比

再往下看一层,创建 ReactElement 的核心函数。React 17 之前是createElement,之后是jsx函数。两者做的事情类似:

  • 解析config,把key、ref从 config 中单独拎出来,放到元素对应字段上;
  • 其余属性归入props;
  • 处理children,把多余参数收集成 children 数组;
  • 对defaultProps做默认值填充(只处理 undefined);
  • 最终调用ReactElement函数创建对象。

两者主要区别在于:createElement会把key和ref留在 config 里进行判断,而jsx在参数解析时就明确拿走这两个字段,逻辑更清爽,性能也略好。

给出一段简化版createElement的核心逻辑,方便对照理解:

function createElement(type, config, ...children) { let key = null; let ref = null; const props = {}; if (config != null) { if (hasValidKey(config)) { key = config.key; } if (hasValidRef(config)) { ref = config.ref; } for (const propName in config) { if (propName !== 'key' && propName !== 'ref' && !hasOwnProperty.call(config, propName)) { props[propName] = config[propName]; } } } const childrenLength = arguments.length - 2; if (childrenLength === 1) { props.children = children[0]; } else if (childrenLength > 1) { const childArray = Array(childrenLength); for (let i = 0; i < childrenLength; i++) { childArray[i] = children[i]; } props.children = childArray; } if (type && type.defaultProps) { const defaultProps = type.defaultProps; for (const propName in defaultProps) { if (props[propName] === undefined) { props[propName] = defaultProps[propName]; } } } return ReactElement(type, key, ref, props); }

从这段代码里可以直接说出三个面试常见考点:

  1. key和ref不会进入props,所以在组件内部用this.props.key或直接在函数组件里props.key是拿不到东西的。
  2. defaultProps只对undefined生效,如果传了null,默认值不会触发。
  3. 多个子节点会被包成数组,但单个子节点保持原样,这就是为什么props.children有时候是对象、有时候是数组,兼容代码要判断Array.isArray。

3.4 手写一个 ReactElement 查看工具

在项目里想看 JSX 编译出来的 ReactElement 真实内容,你可以临时写一个小组件把它打出来:

function ElementInspector({ element }) { return <pre>{JSON.stringify(element, null, 2)}</pre>; } function App() { const element = <div className="x">Hello</div>; return ( <> <ElementInspector element={element} /> </> ); }

不过JSON.stringify会把$$typeof的 Symbol 漏掉,看不到完整结构。想看全貌就打开 React DevTools 的 Components 面板,选中元素,右侧会直接展示该元素的 props、type 和 key,比打印更直观。

4. JSX/ReactElement 与渲染链路的关系:从元素到真实 DOM

弄清楚了 ReactElement 对象本身,下一个问题就是:React 怎么把这个对象变成页面上的 DOM?

4.1 元素、组件、Fiber、DOM 的四层映射关系

React 的渲染链路可以概括成四层:

  1. JSX 经编译产生 ReactElement 对象。
  2. React 根据 ReactElement 创建对应的 Fiber 节点。
  3. 通过协调(Reconciliation)过程构建/更新 Fiber 树。
  4. 根据 Fiber 树创建或更新宿主环境(DOM、字符串、原生组件等)中的实例。

组件渲染的过程,本质上是“ReactElement 不断被展开”的过程:一个函数组件被调用,返回一个 ReactElement;React 发现这个 ReactElement 的type是函数,就继续调用它,再拿到下一层 ReactElement;直到type变成字符串(原生标签),才创建真实的 DOM 节点。

如果拿工厂流水线类比:ReactElement 是订单,组件函数是加工车间,Fiber 是工序节点,DOM 是最终产品。订单可以很薄(描述信息),但车间必须按订单流程往下走。

4.2 为什么每次渲染 ReactElement 都会被重新创建

这是 React 开发者最常忽略的性能盲区。JavaScript 对象是引用类型,但每次 render/函数组件执行,JSX 表达式都会重新求值,生成全新的 ReactElement 对象。即使内容完全相同,前后两个对象的引用地址也不一样。

这个特性直接影响React.memo和PureComponent的优化效果。看一个经典例子:

function Child() { return <div>child</div>; } function Parent() { const [count, setCount] = useState(0); return ( <div> <Child /> <button onClick={() => setCount(count + 1)}>点击</button> </div> ); }

Parent 每次 setCount 重新渲染时,<Child />都会创建一个全新的 ReactElement 对象,props 引用变了,即使 Child 包了React.memo,浅比较发现 props 的引用不同,默认也还是会重新渲染。

但注意,这里 React 对无 props 的函数组件做了优化:如果props是空对象({}),React 会跳过浅比较,直接复用上一次的渲染结果。所以上面的 Child 如果包了 memo,实际上不会重复渲染。真正需要警惕的是传了内联对象或内联函数的场景:

function Child({ config }) { return <div>{config.text}</div>; } const MemoChild = React.memo(Child); function Parent() { const [count, setCount] = useState(0); return ( <div> <MemoChild config={{ text: 'hello' }} /> <button onClick={() => setCount(count + 1)}>点击</button> </div> ); }

config={{ text: 'hello' }}每次渲染都是新对象,ReactElement 上的 props 引用变了,memo 完全失效。这就是很多人说“我包了 memo 但没效果”的经典原因之一,和 ReactElement 的对象特性直接相关。

4.3 key 在协调过程中如何作用于 ReactElement

key 是 ReactElement 上的一个重要字段,专门给协调过程用的。React 在 diff 前后两次渲染的同层子节点时,优先通过 key 判断节点的“身份”是否发生变化。

没有 key 时,React 按索引顺序对比子节点,一旦列表前后顺序变了,可能导致大量 DOM 的复用错乱,表现为「输入框状态串了」「组件内部 state 没复位」等诡异问题。

有 key 时,React 就能精确识别哪个元素被移动、删除或新增,保持组件实例的稳定。这里有一个容易踩的坑:key 不要用数组索引。用索引当 key,本质上和“不传 key + 按顺序对比”差别不大,尤其是列表项会发生插入、删除、排序时,key 就失去了存在意义。

正确用法是使用业务数据里的唯一标识,比如 id 或者某个稳定字段:

<ul> {list.map(item => ( <li key={item.id}>{item.name}</li> ))} </ul>

4.4 从 ReactElement 到 Fiber 的转换过程简述

React 在协调阶段会调用reconcileChildren,为 ReactElement 匹配对应的 Fiber 节点。匹配逻辑大概是:

  • 如果 ReactElement 的type和当前 Fiber 的type相同,则复用 Fiber(update 流程)。
  • 如果不同,则标记为删除并新建 Fiber(mount 流程)。
  • 如果新增 ReactElement 且无对应 Fiber,就新建。

这个过程中,ReactElement 扮演的角色是“期望状态”,Fiber 是“当前状态”,协调就是让 Fiber 树收敛到 ReactElement 描述的形态。key也会参与到新老节点的对比,没有 key 的节点则按 sibling 顺序硬碰。

这些内容往深了讲能写一整本书,但这篇文章最重要的结论是:React 每次渲染都从新的 ReactElement 开始,Fiber 在协调后得到新的状态,最终由渲染器提交到真实 DOM。

5. 常见问题与排查技巧实录

最后这部分,我把自己在实际项目中遇到过的、和 JSX/ReactElement 直接相关的几个问题整理成速查表,每一个都真实踩过坑。

问题现象根因排查思路与解法
组件能被渲染,但 console 里大量keyWarning列表渲染没有传 key 或 key 不唯一给每个子元素加稳定 key,检查是否有重复值
React.memo优化不生效,组件频繁重渲染props 里每次传入新对象/新函数,ReactElement 的 props 引用变化用useCallback、useMemo稳定引用;或者抽公共常量
使用JSON.stringify(props)打印组件参数时看不到$$typeofJSON 序列化会忽略 Symbol用 React DevTools 查看,或者直接打印对象本身
props.children有时是数组有时是对象,导致.map报错ReactElement 单子节点不包数组统一处理:React.Children.toArray(props.children)或用Children.map
defaultProps不生效,组件里显示 undefined手动传了null而不是 undefineddefaultProps只处理 undefined,null会被原样传递
把对象直接放在 JSX 里渲染,报“Objects are not valid as a React child”React 不能直接渲染普通对象,只有 ReactElement 和字符串/数字可以对象需要先转成JSON.stringify或拆分字段渲染

5.1 定位问题前先确认 ReactElement 是否合法

排查这类问题,第一件事是确认“这个对象到底是不是合法的 ReactElement”。如果是从接口拿到的数据,或者从某个工具库中转出来的对象,极可能缺少$$typeof字段,这个对象放到 JSX 里直接渲染,就会触发上面表格里的那个报错。

一个快速检查的办法:

function isReactElement(value) { return ( typeof value === 'object' && value !== null && value.$$typeof === Symbol.for('react.element') ); }

如果$$typeof对不上,就别往 JSX 里塞。要么转换数据结构,要么用自定义组件包一层。

5.2 动态生成 JSX 时的三个建议

用函数生成 JSX 片段,比如写一个“根据类型渲染不同表单控件”的工具,这类场景最容易因为对 ReactElement 理解不透彻而出 bug。我实际经验里有三条建议:

  1. 批量生成的元素必须带稳定 key,即使这段 JSX 只有展示作用,也能避免未来插入新元素时的状态错乱。
  2. 不要直接修改已有的 ReactElement 的 props,那是不可能的,它是只读的。想改内容,就用React.cloneElement生成新元素。
const wrapped = React.cloneElement(child, { onClick: () => console.log('点击') });

cloneElement是保留 key、ref 并合并新 props 的标准做法。

  1. 如果要在父组件里直接访问“子组件渲染出的 ReactElement”,那办不到。父组件只能拿到子组件创建的 ReactElement,子组件内部返回的树是在 Fiber 层展开的,父组件这边拿不到。

5.3 关于调试:如何快速查看一段 JSX 的编译产物

排查编译类问题时,不要靠猜,直接用 Babel 在线环境或者本地脚本把 JSX 转出来看。

本地起一个 Node 脚本最方便,代码我在前面已经给过。改runtime配置可以对比新旧转换结果,改preset-react还能看不同 React 版本的转换差异。看完编译结果,很多“为什么这里不能写 if”的疑问会瞬间消失,因为你会看到 JSX 里的语句确实没有任何合法的调用位置可以塞进去。

5.4 一个容易被忽略的坑:对象类型的 children

有一种场景很隐蔽——children 里有空字符串、false、null、undefined。这些值在编译后的 ReactElement 里仍然保留:

<div> {false} {null} {undefined} {'a'} </div>

编译后,children是一个数组:[false, null, undefined, 'a']。React 在渲染时会自动忽略 falsy 值(除了 0 和空字符串之外,注意:0会被渲染,''不会显示但也不会报错),但如果你对props.children做遍历或者赋值,这些值会真实存在,干扰判断。

所以我处理 children 时,习惯先过滤:

const visibleChildren = React.Children.toArray(children).filter(Boolean);

避免后面逻辑因为false或null产生不可预期的行为。

6. 实际渲染链路中的性能优化视角

理解了 ReactElement 每次刷新都会重新生成,性能优化的核心就变成“如何让 React 尽可能复用旧 Fiber,减少重渲染与 DOM 操作”。

6.1 稳定引用是关键中的关键

凡是传给子组件的 props,尽量保证引用在多次渲染之间不变。具体来说:

  • 函数用useCallback包裹。
  • 对象/数组用useMemo包裹。
  • 常量提升到组件外部,不参与每次渲染。
  • 列表的 key 用稳定业务标识,而不是索引。

背后的原理就是:React.memo对比新旧props时,第一件事就是比较每个 prop 的Object.is值。只有每一项都相同,才会跳过子组件重渲染。而 JSX 本身就是“生成新对象”的过程,如果 props 里混入了新对象引用,memo 的浅比较几乎必然失手。

6.2 ReactElement 的不可变性带来的设计约束

ReactElement 是不可变对象,React 内部依赖这个特性做优化:当前的 Fiber 对比的是上一轮的 ReactElement 描述,如果描述对象不变,React 可以跳过大量计算。

但因为每次渲染都会新建 ReactElement,这个“不变性”其实是靠比较字段内容来保证的,而不是比较对象引用。所以不可变性是“设计约定”,不是“天然保证”。理解这一点后,你在做组件拆分、props 设计时,就会自动避免“把计算搬到渲染过程里”的做法。

6.3 大型列表与动态组件的优化示范

动态生成子组件时,最廉价的优化是稳定 key + 稳定 props:

function List({ items, onSelect }) { return ( <ul> {items.map(item => ( <ListItem key={item.id} item={item} onSelect={onSelect} /> ))} </ul> ); }

onSelect如果来自父组件的useCallback,则引用稳定;如果直接写在父组件体内,则每次渲染新函数,所有 ListItem 都会受影响。这种细节,普通文档不会专门提醒,但实际性能差异可能非常明显。

6.4 经验:不要过度优化

说了这么多,最后必须提一句,不是所有地方都值得用 memo/useCallback。ReactElement 创建的成本其实很低,真正昂贵的是长列表的 diff 和频繁更新的大子树。小范围组件、低频率更新的 UI,就算每次重新渲染,影响也微乎其微。

优化的前提是先测量,再动手。DevTools 的 Profiler 面板能直接展示每个组件在 commit 阶段的渲染时间和原因,先看数据,再决定要不要缓存、要不要拆分。

7. 从实战角度拆解 React 17 与 React 18/19 下的 JSX 生态变化

聊完核心原理,这块补充一些版本演进中的实际影响,避免你在旧项目迁移时踩坑。

7.1 React 17 引入 jsx-runtime 的真实原因

React 17 之前,Babel 编译 JSX 需要React.createElement,文件头部必须 import React。这带来两个问题:

  1. 每个模块都依赖全局的 React 变量,某些环境下容易被干扰。
  2. 编译器生成的代码体积偏大,且createElement内部逻辑冗余。

React 17 在包内新增了react/jsx-runtime模块,把转换函数独立出来。Babel 在编译时自动插入import { jsx } from 'react/jsx-runtime',不再要求手动引入 React。从 JSX 转换产物看,新调用方式有几个明显优势:

  • 函数名更短,产物体积更小。
  • 参数设计更直接,省去...children的参数收集性能损耗。
  • runtime 的模块化让未来 React 可以对这层做独立的优化。

7.2 React 18/19 中对 ReactElement 与 JSX 是否有破坏性变化

React 18 在服务端渲染(SSR)架构上做了不少调整,但 ReactElement 的核心结构没有变,JSX 的编译规则也没变。到了 React 19,原先在React命名空间下导出的若干类型被移到了react包以外,对 JSX 本身的写法影响不大,但如果你使用了类似JSX.Element这种全局命名空间类型,在 React 19 的类型定义里会感受到区别。

实际业务中,受影响更多的是组件类型定义写法和 children 的声明方式,而不是 JSX 的运行时行为。对绝大多数项目来说,旧写的 JSX 代码升级到 18/19 基本平滑,主要排查点在于第三方库的兼容。

7.3 版本演进中唯一的“隐性坑”

React 的 JSX 编译规则从 17 到 19 没有大的变化,但 Babel 插件的配置会在项目升级时出现问题。如果你看到类似“'jsx' is not exported from 'react/jsx-runtime'”的错误,基本是 Babel 插件版本和 React 版本不匹配。

排查顺序:

  1. 确认@babel/preset-react的版本,React 17+ 用 7.9.0 以上的版本。
  2. TypeScript 项目检查tsconfig.json的jsx字段,React 17+ 推荐"jsx": "react-jsx"。
  3. 用npx tsc --showConfig查看最终生效配置,排除配置文件被覆盖。

这几个步骤能解决绝大多数 JSX 编译异常。

8. 扩展:手写一个极简 ReactElement 解析器

为了把前面所有内容串起来,最后再带大家写一个极简版“ReactElement 解析到 HTML 字符串”的玩具代码。不涉及 Fiber,不涉及协调,只做最基本的“递归解析 + 渲染”,但足以验证你对 JSX 结构的理解是否正确。

8.1 实现思路

思路很简单:递归遍历 ReactElement 树,type是字符串则渲染成 HTML 标签,type是函数则调用它拿到下一层 ReactElement 再继续。children可能是字符串、数字、数组或 null,分别处理。

8.2 核心代码

function renderToString(element) { if (element == null || typeof element === 'boolean') { return ''; } if (typeof element === 'string' || typeof element === 'number') { return String(element); } if (Array.isArray(element)) { return element.map(renderToString).join(''); } if (typeof element.type === 'function') { return renderToString(element.type(element.props)); } const { type, props } = element; const children = props.children; let html = `<${type}`; for (const [key, value] of Object.entries(props)) { if (key === 'children') continue; html += ` ${key}="${value}"`; } html += '>'; html += renderToString(children); html += `</${type}>`; return html; } // 使用示例 const element = ( <div className="container"> <h1>Hello</h1> <span>World</span> </div> ); console.log(renderToString(element));

输出大概是:

<div class="container"><h1>Hello</h1><span>World</span></div>

这段代码虽然玩具级,但如果你能独立写出来,说明你已经弄清楚:ReactElement 是什么、props 怎么流动、children 怎么递归、函数组件怎么展开。

8.3 写这个玩具的收获

真正动手写一遍之后,你会自然理解 React 源码里很多设计选择:比如为什么type要区分字符串和函数,为什么props.children要做归一化,为什么 React 不能直接渲染普通对象。用一个小小的实现,反过来验证框架的核心抽象,比背十道面试题都扎实。

我个人每次带新人时,都会让他们先写一遍这个“渲染器”,再回来看 React 源码,理解速度和深度完全是两个档次。

结尾

回到最初那个问题:JSX 和 ReactElement 是两回事,但又是一套完整链路的两端。JSX 是写给人看的声明式描述,ReactElement 是机器能读的结构化数据,中间靠编译器转换连接,后面靠 Fiber 调度执行。日常开发中可能你不会直接操作 ReactElement,但一旦遇到性能优化失效、children 类型异常、动态渲染报错,回到“这个东西本质上是一个对象”这个原点,很多问题都会迎刃而解。

我在实际项目里体会最深的一点是:理解 ReactElement 的创建时机和引用稳定性,是做好 React 性能优化的前提。很多组件重渲染问题,最终都能归结到“每次渲染都传递了新引用”这一个原因上,而这个原因就写在 ReactElement 的 props 里。

如果你正打算深入 React 源码,我建议你先别急着读 Fiber 那部分,先在 Babel 里编译几段 JSX、手写一个解析器、再看 React 的createElement/jsx函数,把 ReactElement 这层地基打牢了再往上走。看源码时你会发现,React 世界里的大部分逻辑,本质上都是在处理“如何把一批 ReactElement 高效地变成真实 UI”这件事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 10:09:22

把“skills”当作项目来经营:一套技能管理系统搭建指南

1. 起点&#xff1a;一次面试复盘让我重新理解“skills”这个词很多人把 skills 当成简历上的一排关键词&#xff0c;Java、Python、项目管理、沟通协作&#xff0c;填得满满当当&#xff0c;好像在超市货架上摆商品。但真正用这些技能去解决问题的时候&#xff0c;情况完全不是…

作者头像 李华
网站建设 2026/10/24 10:07:34

网约车打车小程序:实时调度、状态机与WebSocket位置服务

简介&#xff1a;一套面向微信小程序开发学习者的仿滴滴打车出行应用源码&#xff0c;聚焦网约车核心流程的拆解与复现&#xff0c;适合已掌握小程序基础、希望理解地图定位、订单流转与页面组织的中级开发者参考。压缩包为 rar 格式&#xff0c;共 151 个文件、约 652KB&#…

作者头像 李华
网站建设 2026/10/24 10:04:57

Linux操作系统实验全链路:环境搭建、/proc观测与进程调度实战

简介&#xff1a;西南交通大学操作系统实验报告&#xff0c;是一份获得95分&#xff08;A&#xff09;的高分课程成果&#xff0c;面向操作系统课程学习者、Linux初学者及需要撰写实验报告的高校学生。报告基于2020-2021学年软件工程专业教学要求&#xff0c;完整记录多个核心实…

作者头像 李华
网站建设 2026/10/24 9:50:51

字节Agent中台后端技术栈与高并发实践

1. 字节Agent中台后端开发的技术栈解析作为字节跳动Agent中台的后端开发岗位&#xff0c;技术栈选择体现了典型的高并发、低延迟系统设计思路。Golang作为主力开发语言&#xff0c;其轻量级协程&#xff08;goroutine&#xff09;和原生并发支持能够很好地应对Agent中台的高吞吐…

作者头像 李华
网站建设 2026/10/24 9:44:34

匿名代号揭面两天后就开源:M3.1 Flash 的发布节奏为什么这么赶

匿名代号揭面两天后就开源&#xff1a;M3.1 Flash 的发布节奏为什么这么赶 【免费下载链接】MiniMax-M3.1-Flash MiniMax-M3.1-Flash 是一款原生多模态混合专家&#xff08;MoE&#xff09;模型&#xff0c;面向编程、智能体工作流、计算机使用和网络安全等场景。它支持文本、图…

作者头像 李华
网站建设 2026/10/24 9:44:18

教学计划编制中的AOV网拓扑排序算法与C语言实现避坑指南

简介&#xff1a;教学计划编制问题论文与源程序&#xff0c;是一份聚焦图论与拓扑排序应用的课程设计资料&#xff0c;面向学习数据结构、算法分析与程序设计的学生及自学者。文档以教学计划的科学排定为例&#xff0c;说明如何将课程先修关系抽象为AOV网&#xff0c;并采用广度…

作者头像 李华