React元素创建原理:从JSX到虚拟DOM的完整链路
我一直觉得React里有一个特别容易被忽视、但又特别值得花时间搞清楚的基础概念就是元素创建。前几天一个准备跳槽的朋友被问到“React的createElement到底做了什么”他跟我说知道JSX是语法糖但一说到编译产物长什么样、为什么元素对象里有个$$typeof、更新的时候React是怎么拿着元素对象去做对比的就卡壳了。这个问题其实非常典型因为React元素创建这件事表面上就是写几行JSX但底层牵扯到虚拟DOM结构、不可变性设计、更新调度、批处理机制甚至面试里那些“为什么列表要写key”“为什么props不能直接改”的问题全都从这里延伸出来。这篇文章我打算把React元素创建从头到尾拆开讲一遍先看元素对象在内存里到底是什么再说JSX怎么被编译成createElement调用然后结合React 18的更新批处理机制讲清楚元素是怎么驱动界面刷新的最后聊几个实际开发中很容易踩的坑和高频面试考法。无论你是刚学React的新手还是用React写了两三年业务、想系统梳理一遍的老手这篇文章应该都能给你一些新的收获。1. 元素创建的本质先搞清楚React元素到底是个什么结构1.1 元素不是一个“东西”而是一个普通对象很多刚接触React的人会把“元素”和“组件”混在一起觉得元素就是页面上的一个DOM节点或者是组件渲染出来的视觉效果。实际上React元素就是一个普普通通的JavaScript对象它只是一个对“你想让界面长什么样”的描述不包含任何实例、方法或者状态。我们平时写一段JSXconst element div classNameboxHello React/div;这段代码经过编译之后实际执行的是const element React.createElement(div, { className: box }, Hello React);而createElement执行完返回的对象长这样const element { $$typeof: Symbol.for(react.element), type: div, key: null, ref: null, props: { className: box, children: Hello React }, _owner: null };我当年第一次看到这个结构的时候还挺惊讶的——原来React元素就这对就这。一个type表示元素类型一个props承载所有属性key和ref是React内部调度时用的特殊字段$$typeof用来标记这是一个React元素。type字段可以是一个字符串比如div、span对应原生DOM标签也可以是一个函数或者类对应我们自定义的组件。当type是组件函数时React会在渲染阶段调用这个函数拿到它返回的元素树再继续往下解析。props里除了我们自己传入的属性还有一个特殊的children字段。createElement把第三个及之后的参数都收进children里——如果只有一个子元素children就是那个元素本身如果有多个子元素children就变成一个数组。这个设计是一个典型的“归一化”处理React内部处理children时就可以少做很多类型判断。1.2 $$typeof一个容易被忽略但极其关键的身份标记元素对象里的$$typeof字段平时很少有人注意到但它其实承担着一个非常重要的安全职责——防止XSS攻击。这个字段是一个Symbol类型的值Symbol.for(react.element)。为什么用Symbol因为Symbol类型不能被JSON序列化也不能被普通的字符串对象伪造。攻击者如果想构造一个假的React元素对象通过JSON.parse传来的数据里是不可能包含Symbol值的。React在渲染的时候会检查元素对象的$$typeof是否合法。如果这个对象是JSON反序列化来的、没有正确的Symbol标记React就会直接拒绝把它当作React元素来渲染从源头上切断了一条XSS攻击路径。以前React官网文档里提到过一个漏洞场景如果服务端返回的JSON里包含一个带有__proto__或类似危险字段的对象用户又把这份JSON塞进了某个期望接收React元素的props里攻击者就可能借机注入恶意内容。$$typeof这个机制就是专门堵这个口子的。这里插一句做React开发的人一定要记住React元素是“不可变”的。创建完之后它的type、props、key、ref都不应该被修改。你可以在render函数里反复创建新的元素对象但不能去改已经创建好的对象。这个不可变性约束是整个React更新机制的基石后面讲更新流程的时候会反复提到。2. JSX与createElement从语法糖到底层调用的完整链路2.1 JSX不是模板引擎它只是一层语法糖很多从Vue转过来的同学一开始会拿JSX和Vue的模板语法做对比觉得两者都是“写界面结构”。但从设计思路上它们有本质区别Vue模板是编译期做优化的模板语法JSX则完全是在JavaScript里构建元素树它没有自己的运行时语义最终会被编译成普通的JavaScript函数调用。所谓语法糖意思就是JSX本身并不是React的一部分也不是浏览器能直接运行的代码。它是Babel这类编译器提供的一种转换规则——把看起来像HTML的标签语法转换成React.createElement调用。举个例子// 你写的JSX const el ( div classNamewrapper h1标题/h1 p段落内容/p /div );Babel编译之后的代码大致是const el React.createElement( div, { className: wrapper }, React.createElement(h1, null, 标题), React.createElement(p, null, 段落内容) );理解这个过程之后很多React的“怪现象”就说得通了。比如为什么JSX里写注释要用{/* ... */}因为JSX本质上是JavaScript表达式注释也得符合JavaScript语法。再比如为什么className不是class因为class是JavaScript的保留字在对象属性里直接写class容易出问题React干脆用className做了替代。还有一个常见疑问既然JSX会被编译成createElement那我们是不是可以不写JSX、直接写createElement可以完全没问题。React官方文档里就保留着这种写法React.createElement在运行时仍然是一个正常的APIJSX只是更直观的“快捷方式”。但实际开发中几乎没人直接写createElement因为嵌套层级一多代码可读性会立刻崩塌。2.2 手写一个简化版createElement理解它的内部逻辑为了把createElement的活儿彻底搞明白我曾经照着React源码的逻辑自己写过一个简化版本。虽然省略了很多边界处理但核心流程是一模一样的function createElement(type, config, ...children) { let key null; let ref null; const props {}; // 从config里提取key和ref其余属性都放进props 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 propName ! __self propName ! __source) { props[propName] config[propName]; } } } // 处理children单个和多个分开处理 const childrenLength children.length; if (childrenLength 1) { props.children children[0]; } else if (childrenLength 1) { props.children children; } // 如果type是组件函数且定义了defaultProps在这里合并默认值 if (type type.defaultProps) { const defaultProps type.defaultProps; for (const propName in defaultProps) { if (props[propName] undefined) { props[propName] defaultProps[propName]; } } } return { $$typeof: Symbol.for(react.element), type, key, ref, props, _owner: null }; }这个实现里有几个细节值得单独说。第一key必须被转成字符串。React源码里就是通过 config.key来做这个转换的因为key在React内部统一按字符串处理。所以如果你写key{1}React内部拿到的是字符串1。这个转换虽然简单但对后面协调Reconciliation阶段的key匹配是有影响的必须保证前后渲染时key的一致性。第二config里的key和ref是被单独抽出来的不会出现在props里。这意味着组件内部用this.props.key是拿不到key的key只对React的协调过程有意义。很多新手在这里容易犯迷糊。第三defaultProps的合并发生在创建元素对象时而不是组件渲染时。所以defaultProps的默认值一旦在元素创建时被写入props后面再修改组件的defaultProps已经创建好的元素对象不会跟着变。2.3 为什么我会建议你偶尔看看Babel编译后的产物说实话在真实业务开发中使用JSX几乎不需要关心Babel是怎么编译的。但如果你遇到下面这些场景花五分钟看一眼编译产物会很有帮助调试某个“明明写了组件却不更新”的问题时排查某个自定义组件没有接收到children时或者写了一个高阶组件、想搞清楚ref传递为什么如此麻烦时。我看过编译产物之后的感受是JSX远比想象中“直接”。它没有做任何额外的事情没有模板解析、没有输出字符串、没有运行时渲染指令只是一层层嵌套的函数调用返回一棵纯粹的对象树。这棵树就是虚拟DOM的前身React拿到它之后才能开始后续的调度和渲染工作。而且这个编译过程是确定性的同样的JSX一定会编译成同样的代码这让React的更新有了可预测性。哪怕你写的组件再复杂追根溯源每一个节点都是从createElement这一个入口创建的。提示如果你用React 17Babel还有一个新的JSX转换插件jsx-runtime它不再把JSX编译成React.createElement而是编译成jsx函数。这个变化主要是为了让React不依赖全局的React对象也顺手减少了一点包体积但最终生成的元素结构和createElement是一致的底层逻辑没有变化。3. 从元素到界面元素创建在React 18更新流程中的关键位置3.1 一次render从元素到DOM到底经历了什么理解了元素创建之后接下来要搞清楚一个更宏观的问题React拿着这棵元素树是怎么把它变成真实DOM的。如果给这个过程画一条线大致是四个阶段创建元素树、调度更新、协调对比、提交DOM变更。创建元素树就是我们前面讲的内容组件render时返回一棵由createElement构成的树。这棵树是“当前想呈现成什么样”的描述。接着是调度更新。React手里可能有多个更新任务比如一次点击事件里调用了两次setStateReact要把它们合并成一次更新最后只做一次渲染。这个调度过程在React 18里变得更加精细后面专门讲。然后是协调对比。React把那棵新元素树和上一次渲染的旧元素树放在一起做对比找出“哪里变了”。这个阶段的专业名字叫Reconciliation也是diff算法真正执行的地方。最后是提交阶段。React把差异转换成具体的DOM操作插入、更新、删除一次性提交到浏览器完成界面的更新。换句话说React元素是贯穿整个更新链路的核心数据结构。你每调一次setState本质上都是触发了一次新的元素树创建然后React拿新树和旧树做对比算出最小更新范围。这里就引出了React一个非常重要的设计选择为什么不能直接操作DOM因为直接操作DOM很贵。在现代前端应用中一个页面可能有上千个节点如果每次数据变化都把全部节点重建一遍性能会非常难看。React用元素树做中间层先在内存里完成对比和计算只提交真正需要变更的部分等于把“哪些地方要改动”这件事的成本从昂贵的DOM操作转移到了相对廉价的JavaScript对象对比上。3.2 React 18的批处理机制元素创建被“合并”的幕后推手说到React 18就绕不开批处理Batching机制。这个机制本质上就是多个状态更新发生在同一次事件循环里React把它们合并起来只触发一次重新渲染也就是只创建一次新的元素树。在React 18之前批处理只在React自己的事件系统里生效。比如你在onClick里连写三个setStateReact会把这三次更新合并成一次渲染。但在setTimeout、Promise回调、原生事件监听器里React是没法做批处理的——每次setState都会触发一次独立的重新渲染。这在性能上是一个明显的短板尤其是当你在异步函数里连续更新多个状态时会导致组件频繁重渲染。React 18用createRoot创建应用根节点后情况就变了。React官方给出的说法是“自动批处理”——所有更新不管在哪个上下文里触发默认都会自动批处理。// React 18之前setTimeout里不批处理会触发两次渲染 setTimeout(() { setCount(c c 1); setFlag(f !f); }, 0); // React 18自动批处理只触发一次渲染 const root createRoot(document.getElementById(root)); root.render(App /); setTimeout(() { setCount(c c 1); setFlag(f !f); }, 0);这个变化的底层逻辑很有意思。批处理的核心是“推迟”元素树的构建和对比——React先把所有更新收集起来放在同一个调度批次里然后在这个批次结束时统一创建新的元素树并交给协调阶段处理。批处理能成立恰恰是因为元素对象本身只是普通对象创建成本很低、可以随时重建所以React才能放心大胆地把多个更新“攒着”一起处理而不担心中间状态丢失。需要特别说明的是批处理并不是React 18才有的新概念它是从React早期版本就存在的机制。React 18的进步在于把它从“React事件处理器内”扩展到了“所有场景”。同时React也提供了不批处理的出口flushSync。你把更新包在flushSync里React会强制同步刷新一次跳过批处理合并。import { flushSync } from react-dom; flushSync(() { setCount(c c 1); }); // 到这里DOM已经更新完3.3 React 18批处理机制与并发特性的配合聊批处理的时候很多文章会把它和React 18的并发特性混在一起说这里要分清楚批处理解决的是“多次更新合并成一次”的问题并发特性解决的是“不同更新任务之间如何调度优先级”的问题。React 18引入的startTransition就是一个专门用来标记“低优先级更新”的API。它背后体现的是这样的思路某些状态更新比如输入框里实时搜索不需要立刻反映到界面上可以延迟处理而另一些更新比如输入框本身的文字变化必须立刻响应。React在创建元素树的时候会根据更新的优先级决定先渲染哪一棵树、后渲染哪一棵树。import { startTransition, useState } from react; function SearchPage() { const [keyword, setKeyword] useState(); const [list, setList] useState([]); const handleChange (e) { const value e.target.value; setKeyword(value); // 紧急更新立即处理 startTransition(() { setList(filterList(value)); // 非紧急更新可以延迟 }); }; // ... }在这个例子里输入框的文字更新是紧急的必须立刻让用户看到列表筛选结果可以稍微晚一点出来React会根据当前浏览器的忙碌程度决定何时处理。所有任务都基于元素树的构建和对比只是这些树被排进了不同的优先级队列。这让我觉得React元素创建这个知识点实际上贯穿了React从Reconciliation到并发调度的所有核心机制。只把它当成JSX的编译产物来理解视野还是窄了。4. 元素创建在实战中的关键技巧与常见陷阱4.1 key的正确打开方式协调环节的“身份证”聊React元素逃不开key。前面提到我们手写createElement时把key从config里单独抽出来存储它不进props而是放在元素对象自己的key字段上。React在协调阶段对比新旧两棵元素树时用type和key共同判断一个节点是否属于同一个组件。用数组渲染列表的场景最典型const items [ { id: 1, name: 苹果 }, { id: 2, name: 香蕉 }, ]; // 推荐用稳定的业务id做key ul {items.map(item ( li key{item.id}{item.name}/li ))} /ul // 不推荐用数组下标做key ul {items.map((item, index) ( li key{index}{item.name}/li ))} /ul为什么index不好因为key的作用是让React能在“同一位置”识别出“同一个元素”。当列表在头部插入、删除、排序时index会跟着变。React拿着新树的index key去匹配旧树发现对不上号就会把整个列表后面的节点全部卸载重建这既浪费性能还可能引发组件状态错乱——比如一个输入框组件因为key变了被React当成“新元素”内部的state被全部重置。我负责的一个后台管理系统里出现过一次表格行内组件状态串位的问题排序功能把列表顺序打乱后输入框里填的内容跑到别的行去了。当时排查了很久最后发现就是select数据那儿用了index做keyReact复用错了组件实例。把key换成业务唯一id后问题立刻消失。还有一个容易被忽略的点key只需要在兄弟节点之间保持唯一不需要全局唯一。而且key对于协调只在这一层级有效React不会拿不同层级的key做匹配。4.2 元素不可变性与性能优化为什么不能随意修改props我们知道React元素创建之后不应该被修改。这个约束看起来简单实际开发中却很容易踩雷。最常见的错误写法是这样的function BadComponent({ data }) { // 错误直接修改元素对象的props data.name 新名字; return div{data.name}/div; }如果你把外部传入的props对象直接改掉React的纯度约定就被破坏了。组件的职责是“根据输入返回元素描述”而不是“修改输入”。一旦破坏了它下次更新时新旧树对比就会拿到不一致的数据渲染结果会变得不可预测。这种不可变性也是React性能优化机制的基础。React.memo、useMemo、useCallback它们判断“要不要重新渲染组件”的基本逻辑都依赖props引用是否变化。每次父组件重新创建元素树时如果传递的子组件props仍然保持同一个引用React就能跳过子组件的重新渲染。const Child React.memo(function Child({ count }) { console.log(Child重新渲染了); return div{count}/div; }); function Parent() { const [count, setCount] useState(0); const [other, setOther] useState(0); return ( button onClick{() setCount(count 1)}count/button button onClick{() setOther(other 1)}other/button Child count{count} / / ); }在这个例子里只要other变化而count没变Child就会被React.memo挡住不会重新渲染因为在创建新的元素树时Child的props引用没有变。这就是不修改元素对象、保持元素结构稳定的直接好处。4.3 手写元素与React组件的边界问题React组件是函数或类React元素是它们返回的对象。很多人一开始不太理解两者的区别举个例子就容易明白了。组件是“生产元素的工厂”元素是“工厂生产出来的产品”。工厂本身可以很复杂——它可能有自己的逻辑、状态、事件处理甚至调用其他工厂但产品必须是一个纯粹的对象只是用来描述界面长什么样。所以你在组件里可以写function App() { const [count, setCount] useState(0); const element div当前数量{count}/div; return element; }但你不能把组件本身和元素混为一谈。比如// 错误把组件函数当成元素直接用 const App () divHello/div; const element { App }; // 正确创建出元素 const element App /;更进一步说如果你拿到一个React元素对象你也不能直接调用它的type方法来触发渲染——因为那只是拿到一个普通函数调用没有经过React的调度和协调流程。React元素必须通过ReactDOM.render或者createRoot传入React的渲染管线才会真正变成界面。5. React元素创建的高频面试题与答题思路React元素创建是React社区面试中出镜率极高的话题。我梳理了几个典型的考题列出它们背后的考察点顺便给出一个可以直接用的回答思路。面试问题主要考察点答题思路JSX和createElement有什么区别底层原理、编译产物JSX是语法糖编译为createElement调用createElement返回React元素对象React元素和React组件有什么区别对React模型的整体理解元素是描述界面的普通对象组件是生产元素的函数/类返回元素树为什么列表渲染不要用index做key协调原理、diff算法key决定节点复用index会在增删排序时变化造成状态错乱和性能浪费props为什么不能直接修改不可变性、性能优化修改props破坏纯函数约定导致新旧树对比数据不一致React.memo等优化机制失效React 18的自动批处理是什么更新机制、React 18新特性所有更新默认合并避免重复渲染flushSync可强制同步刷新createElement返回的对象是什么结构元素结构、XSS安全$$typeof、type、key、ref、props$$typeof用Symbol标记身份防XSS这些题看起来问法不一样但答案都会汇聚到同一个根上React元素是一个不可变的普通对象它描述了界面应当呈现的结构React拿着这棵树做调度、对比、提交。把这个底层模型吃透了面试官换什么问法都不怕。还有一个值得提的考察点是元素创建与XSS安全的联动。面试官可能问“服务端返回的JSON能不能直接作为React的children渲染”。答案是不能盲目信任。React元素有$$typeof这个Symbol标记防止伪造但在代码里如果你自己手写了一个对象塞进childrenReact内部在做子节点处理时仍有一套算法判断——它会把对象和数组单独处理检查$$typeof的类型是不是合法的React元素类型。所以社区里一致的建议是所有外部数据都要经过序列化、校验、转义之后才能交给React渲染不要把未处理的数据直接拼进JSX。React 18还有一个容易被面试官问到的小点createRoot和ReactDOM.render的区别。本质上是因为React 18把渲染入口从ReactDOM.render换成了createRoot这个变化让自动批处理得以在并发特性下生效。使用老旧渲染方式会弹出警告因为旧入口没有走新的调度流程批处理行为也不一致。这个变化对元素创建的影响在于根节点创建方式变了但元素构建和更新的底层机制没有变化。写在最后的小建议React元素创建这个知识点说难其实不难说简单又确实牵动了React整个运行机制的方方面面。我最开始用React写业务的时候也一度因为看不懂虚拟DOM而焦虑过后来发现一个比较有效的学习方法把JSX写出来之后再在浏览器里手动改成多层嵌套的createElement调用感受一下两者在结构上的对应关系然后自己写一个简化版的createElement试着跑起来最后再去看协调和批处理的源码。这个过程会让很多“React为什么这么设计”的疑问自动消散。如果你正在准备React相关的面试我建议你不要只背面试题答案而是从元素创建入手把这条线捋一遍——从JSX编译到元素结构从元素结构到协调流程从协调流程到批处理和并发特性。这条线捋顺了React的骨架你基本就把握住了。如果你在实践中有遇到什么关于React元素的怪问题也欢迎在评论区聊聊我看到了会尽量回复。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →