前端进阶必读:手写 React 源码,彻底搞懂虚拟 DOM 机制

0 阅读7分钟

手搓 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)
            ),
        },
    }
}

这个函数接收三个主要部分:

  1. type:标签的类型,比如 'div''span',或者是自定义组件的函数。
  2. props:标签上的属性,比如 styleclassName 等。
  3. 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);
}

逻辑非常清晰:

  1. 遍历当前元素的所有子节点。
  2. 对每个子节点递归调用 render,并将当前创建的真实 DOM (dom) 作为容器传入。
  3. 当所有子节点处理完毕后,将当前 dom 挂载到父级容器上。

通过这种深度优先的递归,整棵虚拟 DOM 树就被完整地映射到了真实 DOM 树上。

不过需要特别说明的是:

当前版本的 render 函数采用了同步递归的方式处理 DOM 节点。这种实现虽然直观,但在性能上存在瓶颈。由于 JavaScript 单线程的特性,一旦开始递归渲染,主线程就会被独占,直到整棵虚拟 DOM 树遍历完成。如果组件层级较深或节点数量庞大,这个过程会导致页面无法响应用户交互,产生明显的卡顿。这并非生产级框架的最终方案,在后续的源码迭代中,我们会引入任务调度机制,将渲染任务拆分为可中断的时间片,从而避免长任务阻塞主线程。目前的代码旨在理清核心流程,性能优化将是接下来重点突破的方向。

四、核心流程复盘

结合上面的分析,我们可以梳理出 React 最基础的工作流:

  1. 编写阶段:开发者编写 JSX 代码,享受声明式 UI 的便利。
  2. 编译阶段:Babel 将 JSX 转换为 createElement 函数调用。
  3. 构建阶段createElement 执行,生成内存中的虚拟 DOM 树(JS 对象)。
  4. 渲染阶段render 函数遍历虚拟 DOM 树,递归创建真实 DOM 节点并挂载。

这个过程体现了 React 的声明式编程思想:你只需要告诉 React 界面“应该是什么样子”(通过 JSX 描述状态),而不需要关心“如何一步步修改 DOM"(命令式操作)。DOM 的打理由框架在底层完成。

五、结语

手写源码的目的,不是为了造轮子去替代 React,而是为了“祛魅”。

当我们看到 createElement 只不过是一个返回对象的函数,render 只不过是一个递归操作 DOM 的过程时,React 就不再神秘了。它是一层优秀的抽象,将复杂的 DOM 操作封装在简单的 API 之下。

理解了这一层,再去学习 Fiber、Diff、Concurrent Mode 等高级特性时,你就会明白它们是为了解决什么具体问题而诞生的。源码阅读之路漫长,但从这几十行代码开始,我们已经迈出了理解现代前端框架底层原理的关键第一步。

希望这篇文章能为你揭开 React 面纱的一角。如果在阅读过程中有疑问,欢迎在评论区交流探讨。