手搓 React 源码:从 JSX 到真实 DOM 的核心链路解析
很多前端开发者在使用 React 时,常常会被其庞大的生态和复杂的概念包围:虚拟 DOM、Fiber 架构、Diff 算法、Hooks 等等。有时候我们会产生一种错觉,认为 React 是一个黑盒,神秘且难以触及。
但实际上,React 的核心思想非常纯粹。如果我们剥离掉性能优化、状态管理和生命周期等高级特性,React 的本质其实就是一组 JavaScript 函数和对象。
今天,我们不谈复杂的架构,而是通过一个名为 Didact(意为“教学用的 React")的微型实现,来还原 React 最底层的工作流程。我们将一步步拆解:JSX 是如何变成 JavaScript 对象的?虚拟 DOM 是如何被创建并最终渲染到页面上的?
一、入口:JSX 到底是什么?
在 React 项目中,我们习惯这样写代码:
const element = (
<div style="background:salmon">
<h1>hello React</h1>
</div>
);
这段代码看起来像 HTML,但浏览器是无法直接识别它的。如果直接在控制台运行,会报语法错误。这就是 JSX(JavaScript XML)。
1.1 编译器的魔法
JSX 本质上是 JavaScript 的语法扩展。在代码运行之前,它必须经过编译器(如 Babel)的转换。在早期的 React 版本或我们今天的微型实现中,JSX 会被编译成 React.createElement 的函数调用。
为了让 Babel 知道使用哪个函数来转换 JSX,我们需要在文件顶部添加特殊的注释指令:
/** @jsxRuntime classic */
/** @jsx Didact.createElement */
这两行注释告诉 Babel:“当你遇到 JSX 标签时,请把它转换成 Didact.createElement 函数调用”。
经过 Babel 编译后,上面的 JSX 代码实际上变成了这样:
const element = Didact.createElement(
'div',
{ style: 'background:salmon' },
Didact.createElement('h1', null, 'hello React')
);
看到这里的转换,你就会明白:JSX 只是语法糖,其核心是函数调用。 这让我们可以在 UI 结构中自由地使用 JavaScript 的逻辑能力,比如变量、循环和判断,而不受模板语法的限制。
二、构建虚拟 DOM:createElement 函数
既然 JSX 最终调用了 createElement,那么这个函数的职责就非常明确了:它负责接收标签信息,并返回一个描述 UI 结构的普通 JavaScript 对象,也就是我们常说的“虚拟 DOM"(Virtual DOM)。
2.1 函数的定义
让我们看看 createElement 的核心逻辑:
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
// 核心:处理子节点,确保它们也是虚拟 DOM 对象
children: children.map(child =>
typeof child === 'object' ? child : createTextElement(child)
),
},
}
}
这个函数接收三个主要部分:
- type:标签的类型,比如
'div'、'span',或者是自定义组件的函数。 - props:标签上的属性,比如
style、className等。 - children:标签内部的内容,通过剩余参数
...children收集。
2.2 为什么要统一子节点结构?
注意代码中的 children.map 部分。这里做了一个非常重要的处理:归一化(Normalization)。
在 JSX 中,子节点可以是另一个标签(对象),也可以是一段文本(字符串)。
- 如果子节点是对象(如
<h1>),它已经是虚拟 DOM 了,直接保留。 - 如果子节点是文本(如
'hello React'),我们需要调用createTextElement把它也包装成一个对象。
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
},
}
}
设计考量:
为什么要费这个力气把文本也变成对象?
这是为了后续处理的统一性。在渲染阶段(render),如果我们不统一结构,就需要写大量的 if-else 来判断当前节点是文本还是元素。通过将所有节点都统一为 { type, props } 的结构,渲染函数就可以用同一套递归逻辑处理所有节点,大大简化了代码复杂度。
2.3 虚拟 DOM 的形态
执行完 createElement 后,我们得到的 element 对象大致长这样:
{
type: 'div',
props: {
style: 'background:salmon',
children: [
{
type: 'h1',
props: {
children: [
{ type: 'TEXT_ELEMENT', props: { nodeValue: 'hello React', children: [] } }
]
}
}
]
}
}
这就是虚拟 DOM 树。它完全在内存中,由 V8 引擎管理,创建和修改的开销非常小。此时,页面上还没有任何内容,我们只是生成了一份“UI 设计图”。
三、渲染到页面:render 函数
有了虚拟 DOM 树,下一步就是把它变成浏览器能识别的真实 DOM 节点。这就是 render 函数的职责。
3.1 创建真实节点
render 函数接收两个参数:虚拟 DOM 元素 element 和 容器 container。
function render(element, container) {
// 1. 根据 type 创建不同的真实 DOM 节点
const dom =
element.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(element.type);
// ... 后续逻辑
}
这里体现了多态的思想:
- 如果是
TEXT_ELEMENT,调用createTextNode。 - 如果是普通标签(如
'div'),调用createElement。
3.2 属性挂载与过滤
创建好节点后,需要将虚拟 DOM 上的属性同步到真实 DOM 上。
const isProperty = key => key !== 'children';
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name];
});
细节解析:
这里有一个关键的过滤操作 key !== 'children'。
为什么要把 children 过滤掉?因为 children 是 React 虚拟 DOM 用来描述层级关系的属性,它不是真实 DOM 节点的属性。你不能在 DOM 上设置 div.children = ... 来添加子元素,而是需要通过 appendChild。如果不过滤,可能会导致错误或无效的属性赋值。
3.3 递归挂载子元素
最后,我们需要处理子节点。由于虚拟 DOM 是树状结构,这里自然需要使用递归。
element.props.children.forEach(child => render(child, dom));
container.appendChild(dom);
}
逻辑非常清晰:
- 遍历当前元素的所有子节点。
- 对每个子节点递归调用
render,并将当前创建的真实 DOM (dom) 作为容器传入。 - 当所有子节点处理完毕后,将当前
dom挂载到父级容器上。
通过这种深度优先的递归,整棵虚拟 DOM 树就被完整地映射到了真实 DOM 树上。
不过需要特别说明的是:
当前版本的 render 函数采用了同步递归的方式处理 DOM 节点。这种实现虽然直观,但在性能上存在瓶颈。由于 JavaScript 单线程的特性,一旦开始递归渲染,主线程就会被独占,直到整棵虚拟 DOM 树遍历完成。如果组件层级较深或节点数量庞大,这个过程会导致页面无法响应用户交互,产生明显的卡顿。这并非生产级框架的最终方案,在后续的源码迭代中,我们会引入任务调度机制,将渲染任务拆分为可中断的时间片,从而避免长任务阻塞主线程。目前的代码旨在理清核心流程,性能优化将是接下来重点突破的方向。
四、核心流程复盘
结合上面的分析,我们可以梳理出 React 最基础的工作流:
- 编写阶段:开发者编写 JSX 代码,享受声明式 UI 的便利。
- 编译阶段:Babel 将 JSX 转换为
createElement函数调用。 - 构建阶段:
createElement执行,生成内存中的虚拟 DOM 树(JS 对象)。 - 渲染阶段:
render函数遍历虚拟 DOM 树,递归创建真实 DOM 节点并挂载。
这个过程体现了 React 的声明式编程思想:你只需要告诉 React 界面“应该是什么样子”(通过 JSX 描述状态),而不需要关心“如何一步步修改 DOM"(命令式操作)。DOM 的打理由框架在底层完成。
五、结语
手写源码的目的,不是为了造轮子去替代 React,而是为了“祛魅”。
当我们看到 createElement 只不过是一个返回对象的函数,render 只不过是一个递归操作 DOM 的过程时,React 就不再神秘了。它是一层优秀的抽象,将复杂的 DOM 操作封装在简单的 API 之下。
理解了这一层,再去学习 Fiber、Diff、Concurrent Mode 等高级特性时,你就会明白它们是为了解决什么具体问题而诞生的。源码阅读之路漫长,但从这几十行代码开始,我们已经迈出了理解现代前端框架底层原理的关键第一步。
希望这篇文章能为你揭开 React 面纱的一角。如果在阅读过程中有疑问,欢迎在评论区交流探讨。