手写 Mini React:从 JSX 到虚拟 DOM 再到 render,搞懂 React 底层原理

作者:不会敲代码1日期:2026/5/8

手写 Mini React:从 JSX 到虚拟 DOM 再到 render,搞懂 React 底层原理

引言:为什么要手写 React?

我日常写 React 组件很熟练:

1function App() {
2  return (
3    <div style={{ background: 'salmon' }}>
4      <h1>Hello React</h1>
5      <h2>Hello Didact</h2>
6    </div>
7  );
8}
9

但写完之后脑子里总有几个问题挥之不去:

  • JSX 明明不是合法的 JavaScript,浏览器是怎么认识它的?
  • 虚拟 DOM 到底长什么样?真的是个"对象树"吗?
  • render 之后发生了什么,JSX 怎么变成了页面上的真实 DOM?

理解 React 底层原理,最好的方式就是手写一个 Mini React。

我们这个项目的名字叫 Didact(模仿 React 的命名),它只有两个核心 API:createElementrender。代码总共不到 70 行,却完整复现了「JSX → 虚拟 DOM → 真实 DOM」的全链路。

话不多说,开始写代码。


第一篇:JSX —— 写在 JavaScript 里的 HTML

JSX 是语法糖,不是原生 JS

直接写 JSX 语法,浏览器是不认识的:

1const element = <h1 className="greeting">Hello React</h1>;
2

这段代码要在浏览器里跑,必须先编译。Babel 负责做这件事,它会把 JSX 标签转译成普通的函数调用。

实验:用 Babel 编译 JSX

jsx-babel-demo 目录下,我们做了最简单的实验。

.babelrc 只配置了一件事:

1{
2  "presets": ["@babel/preset-react"]
3}
4

input.js 就一行 JSX:

1const element = <h1 className="greeting">Hello React</h1>;
2

用 Babel CLI 编译:

1npx babel input.js -o output.js
2

编译后的 output.js 是这样的:

1const element = React.createElement(
2  "h1",
3  { className: "greeting" },
4  "Hello React"
5);
6

真相大白:JSX 标签被编译成了 React.createElement(type, props, ...children) 调用。

  • 第一个参数 "h1" — 标签名,对应 type
  • 第二个参数 { className: "greeting" } — 属性对象,对应 props
  • 第三个参数 "Hello React" — 子节点,对应 children

@babel/preset-react 默认调用 React.createElement,但我们可以通过 JSX Pragma 注释把它指向自己写的函数:

1/** @jsx Didact.createElement */
2

这样一来,Babel 编译 JSX 时就会调用 Didact.createElement 而不是 React.createElement,我们就成功替换掉了 React!


第二篇:createElement —— 构建虚拟 DOM 树

什么是虚拟 DOM?

虚拟 DOM 本质上就是一个朴素的 JavaScript 对象,用来描述一个 DOM 节点:

1{
2  type: "div",           // 节点类型:标签名 / 组件函数 / TEXT_ELEMENT
3  props: {
4    style: "...",        // 属性
5    children: [...]      // 子节点数组,每个子节点也是 VDOM 对象
6  }
7}
8

实现 createElement

看我们 Didact 的 createElement 源码:

1function createElement(type, props, ...children) {
2    return {
3        type,
4        props: {
5            ...props,
6            children: children.map(child =>
7                typeof child === 'object'
8                    ? child        // 已经是 VDOM 对象,直接保留
9                    : createTextElement(child)  // 文本/数字  统一封装
10            )
11        }
12    };
13}
14

它做的事情非常简单:

  1. 接收 typeprops 和任意个 ...children
  2. 遍历 children:区分两种子节点
    • typeof child === 'object' → 已经是子 element(VDOM 对象),原样放入数组
    • 否则是文本或数字 → 调用 createTextElement 统一封装
  3. 返回一个朴素的 JavaScript 对象 —— 这就是虚拟 DOM

文本节点的统一封装:createTextElement

1function createTextElement(text) {
2    return {
3        type: 'TEXT_ELEMENT',
4        props: {
5            nodeValue: text,
6            children: []
7        }
8    };
9}
10

文本节点 "Hello React" 变成了:

1{
2  type: "TEXT_ELEMENT",
3  props: {
4    nodeValue: "Hello React",
5    children: []
6  }
7}
8

为什么文本要单独封装? 统一数据结构 —— render 函数只需要处理一种协议:{ type, props },不需要区分字符串和对象。这是一种经典的「适配器」设计。

从 JSX 到 VDOM 树 —— 完整流程

下面这段 JSX:

1/** @jsx Didact.createElement */
2const element = (
3    <div style="background:salmon">
4        <h1>Hello React</h1>
5        <h2 style="text-align:right">Hello Didact</h2>
6    </div>
7);
8

Babel 编译后 → JavaScript 执行 → 最终产出的 VDOM 树:

1{
2  type: "div",
3  props: {
4    style: "background:salmon",
5    children: [
6      {
7        type: "h1",
8        props: {
9          children: [
10            { type: "TEXT_ELEMENT", props: { nodeValue: "Hello React", children: [] } }
11          ]
12        }
13      },
14      {
15        type: "h2",
16        props: {
17          style: "text-align:right",
18          children: [
19            { type: "TEXT_ELEMENT", props: { nodeValue: "Hello Didact", children: [] } }
20          ]
21        }
22      }
23    ]
24  }
25}
26

你写的 JSX 越复杂,这个对象嵌套就越深 —— 但结构始终是清晰的递归树。

这就是虚拟 DOM 的魅力:用一个 JS 对象树,完完整整地描述了整个 UI 结构。它不依赖浏览器,可以在任何 JavaScript 运行时中创建和操作。


第三篇:render —— 把虚拟 DOM 变成真实 DOM

有了 VDOM 树,下一步就是把它渲染到浏览器页面上。

实现 render

1function render(element, container) {
2    // 1. 根据 type 创建真实 DOM 节点
3    const dom =
4        element.type === 'TEXT_ELEMENT'
5            ? document.createTextNode('')
6            : document.createElement(element.type);
7
8    // 2.  props 中非 children 的属性挂载到 DOM 节点
9    const isProperty = key => key !== 'children';
10    Object.keys(element.props)
11        .filter(isProperty)
12        .forEach(name => {
13            dom[name] = element.props[name];
14        });
15
16    // 3. 递归渲染所有子节点
17    element.props.children.forEach(child => render(child, dom));
18
19    // 4. 挂载到父容器
20    container.appendChild(dom);
21}
22

render 函数四步走,每一步都对应一个清晰的动作:

步骤动作关键细节
1创建 DOM 节点根据 type 判断:TEXT_ELEMENT → createTextNode,否则 → createElement
2挂载属性过滤掉 children,把 style、className 等直接赋值到 DOM 上
3递归子节点对每个 child 调用 render(child, dom),深度优先遍历整棵 VDOM 树
4挂载到容器container.appendChild(dom) 把根节点插入页面

为什么过滤 children?

children 不是 DOM 属性,而是 VDOM 子节点数组。如果不过滤它,dom['children'] = [...] 会导致 DOM 属性污染甚至报错。

isProperty 这个工具函数虽然只有一行,但体现了关注点分离:props 里既有 DOM 属性(style、className),也有 VDOM 结构数据(children),渲染时必须区分对待。

挂载到页面

index.html 非常简单:

1<div id="root"></div>
2

index.js 中执行渲染:

1const container = document.getElementById('root');
2Didact.render(element, container);
3

执行后,页面上的 <div id="root"> 就变成了完整的 DOM 结构:

1<div id="root">
2  <div style="background:salmon">
3    <h1>Hello React</h1>
4    <h2 style="text-align:right">Hello Didact</h2>
5  </div>
6</div>
7

到此为止,Didact 就完成了 React 最核心的两步:描述 UI(createElement)渲染 UI(render)


第四篇:Didact 命名空间与 JSX Pragma

createElementrender 挂在一个命名空间对象下:

1const Didact = {
2    createElement,   // 创建虚拟 DOM
3    render,          // 渲染虚拟 DOM 到真实 DOM
4};
5

然后在文件顶部声明 JSX Pragma:

1/** @jsxRuntime classic */
2/** @jsx Didact.createElement */
3
  • @jsxRuntime classic — 告诉 Babel 使用经典 JSX 转换(编译成 函数调用,而不是新版的 jsx() 自动注入)
  • @jsx Didact.createElement — 告诉 Babel JSX 标签编译后调用 Didact.createElement 而不是 React.createElement

这就是替换 React 的关键两步

  1. 自己写一个 createElement 替代 React 的
  2. 自己写一个 render 替代 ReactDOM 的

你也可以把 Pragma 指向任何对象,只要它有一个叫 createElement 的方法即可。如果你把函数名改成 h,那 @jsx Didact.h 就能让 VDOM 对象变成 h("div", null) —— 和 Vue 的 render 函数写法如出一辙。


项目结构

整个项目包含两个子项目:

1source_code/build_own_react/
2├── readme.md              # 学习笔记:React 底层概念梳理
3├── didact-demo/           #  Mini React 运行时
4   ├── public/index.html  # 宿主 HTML,<div id="root">
5   ├── src/index.js       # Didact 核心代码 + JSX 示例
6   └── package.json       # 依赖 react-scripts(提供 Babel 编译)
7├── jsx-babel-demo/        #  JSX 编译实验
8   ├── input.js           # 一行 JSX 待编译
9   ├── .babelrc           # @babel/preset-react
10   └── package.json       # @babel/cli + @babel/core + preset
11

运行方式:

1# 1. JSX 编译实验:看 JSX  createElement 的转换
2cd jsx-babel-demo
3pnpm install
4npx babel input.js -o output.js
5
6# 2. Mini React 运行时:浏览器中渲染 VDOM
7cd didact-demo
8pnpm install
9pnpm start    # 打开浏览器看效果
10

运行结果

image.png

核心知识点总结

1. 虚拟 DOM 的本质

VDOM 就是一个描述 UI 结构的普通 JavaScript 对象

1{ type, props: { ...attributes, children: [...] } }
2

它有三个优点:

  • 轻量:纯对象无浏览器开销,创建销毁都很快
  • 跨平台:不依赖 DOM API,同一棵 VDOM 树可以渲染到浏览器 / Native / Canvas
  • 可 diff:可以用 === 对比两棵 VDOM 树,找到最小变更(React 的调和算法)

2. 递归渲染的特点与局限

当前 render深度优先递归,处理完一个节点及其所有子孙后才处理下一个兄弟节点。

局限:如果 VDOM 树很大,一次递归就会长时间占用主线程,导致页面卡顿。

这就是为什么 React 16 引入了 Fiber 架构 —— 把递归渲染拆成可中断的增量单元,浏览器可以在任务间隙插队处理用户交互。

3. 文本节点统一处理

createTextElement"Hello" 变成 { type: 'TEXT_ELEMENT', props: { nodeValue: "Hello", children: [] } },让文本节点和元素节点共享同一套协议。render 函数不需要 if (typeof element === 'string') 这样的类型判断分支。

4. Didact 体现了什么

Didact 虽然不到 70 行代码,但它保留了 React 设计哲学的骨架:

React 概念Didact 中的实现
JSX → createElementBabel + @jsx Didact.createElement
虚拟 DOMcreateElement 函数返回的对象
ReactDOM.renderrender 函数
文本节点特殊处理createTextElement
命名空间const Didact = { ... }

延伸思考:从 Didact 到真正的 React

写完 Didact,你可以顺着这几个方向继续深入 React 源码:

1. Fiber 架构:可中断的增量渲染

当前 render 是同步递归,Fiber 把渲染拆成一个个"工作单元",用 requestIdleCallback 在浏览器空闲时分片执行。这让 React 可以随时暂停渲染去响应用户输入。

2. 调和(Reconciliation):最小化 DOM 更新

Didact 每次 render 都全量重建 DOM。真实 React 会对比新旧两棵 VDOM 树(diff),只更新变化的部分。这个 diff 算法就叫 Reconciliation。

3. Hooks:函数组件的状态与副作用

Didact 只支持 JSX 元素,不支持组件函数。加上 useStateuseEffect 就构成了函数组件的运行时 —— 核心是一个全局的 fiber 指针 + 链表存储 hooks。

4. 批量更新与合成事件

React 18 的 createRoot 把多个 setState 合并成一次渲染。合成事件层抹平了浏览器差异。


写在最后

手写 Mini React 不是为了替代 React,而是为了亲手验证那些你用过无数次的 API 背后到底发生了什么。

当你看到 const element = <h1>Hello</h1> 这行代码时,脑子里能浮现出 Babel 编译 → createElement 调用 → VDOM 对象 → render 递归挂载的整条链路,那这篇文章的目的就达到了。

项目开源点击进入仓库,欢迎 clone 下来跑一跑,感受 VDOM 从 0 到 1 的过程


手写 Mini React:从 JSX 到虚拟 DOM 再到 render,搞懂 React 底层原理》 是转载文章,点击查看原文


相关推荐


MyBatis-Plus:让数据库操作飞起来的神器
小码哥_常2026/4/28

MyBatis-Plus:让数据库操作飞起来的神器 一、MyBatis-Plus 是什么? 在 Java 开发的世界里,数据库操作是不可或缺的一部分。MyBatis 作为一款优秀的持久层框架,深受开发者喜爱,而 MyBatis-Plus 则是在 MyBatis 基础上诞生的强大增强工具。它就像是给 MyBatis 这位 “武林高手” 配备了一套超级装备,让开发变得更加高效和便捷。 MyBatis-Plus 秉持着 “只做增强不做改变” 的设计理念 ,这意味着当你引入它到项目中时,就像给原有的 M


开源 Wiki 神器 Docmost:团队协作知识库的终极解决方案
修己xj2026/4/20

在团队协作中,文档管理始终是一个让人头疼的问题。传统的文档工具要么功能单一,要么价格昂贵,要么数据不在自己手里。今天,我要向大家推荐一款开源的协作式 Wiki 软件 —— Docmost。 什么是Docmost? Docmost 是一款开源的协作式 Wiki 和文档管理软件,专为团队知识管理而设计。它提供了实时协作、权限管理、空间隔离等企业级功能,同时保持了开源软件的透明性和可控性。 github 地址: github.com/docmost/doc… 文档地址: docmost.com/do


Laravel vs ThinkPHP3.x:现代框架对决
普通网友2026/4/11

好的,我们来比较一下 Laravel 和 ThinkPHP 3.x 这两个 PHP 框架的主要特点和差异。请注意,ThinkPHP 3.x 是一个相对较老的版本(ThinkPHP 已发展至 5.x/6.x/8.x),而 Laravel 则代表了更现代的 PHP 开发框架。 核心架构与设计理念 Laravel 设计哲学: 遵循 SOLID 原则和 DRY(Don't Repeat Yourself)原则。强调优雅、简洁、表达力强的代码。架构: 采用了 MVC(Model-View-Co


如何使用edge浏览器打开控制台?
宇宙计算机2026/4/3

如何使用edge浏览器打开控制台? 我们在edge浏览器中,点击F12,打开检查页面。(或者在页面上,鼠标右键 -> 工具) 但是我们点开检查页面以后,有的时候我们的屏幕过小,上面没有中文,我们不知道哪一个才是控制台。 那看下图,下图这个图标就是控制台。 然后我们就能在控制台的右下角键入JavaScript代码了


Linux 基础指令与权限管理完全指南
凤年徐2026/3/26

Linux 基础指令与权限管理完全指南 前言 在开始Linux系统编程之前,掌握常用的Linux命令和权限管理是必不可少的一步。本文将从零开始,带你熟悉Linux环境下的基本操作,包括文件管理、目录操作、文本查看、权限设置等核心内容。无论你是刚接触Linux的新手,还是希望系统梳理知识的老手,这篇文章都能为你提供清晰的指引。 思考:为什么生产环境中很少使用Linux的图形界面?因为图形界面会占用大量系统资源,且远程管理时效率低下,命令行才是Linux的精髓。 一、环境准备与远程登录 1. 查


mcp学习笔记(三)-Mcp传输协议代码示例
Shawn_Shawn2026/3/18

一、三种传输方式详解 1. Stdio 传输 1.1 原理说明 Stdio(标准输入输出)传输是最简单的传输方式。MCP Client 将Server 作为 子进程 启动,通过进程的 stdin 和 stdout 进行双向通信。 关键规则: 每条JSON-RPC 消息以 换行符 \n 分隔 stdout 专用于协议消息,日志/调试信息必须输出到 stderr Client 与 Server 一对一绑定,生命周期同步 1.2 原理图(draw.io) 1.3 交互流程图(draw.io 时


奔图M6200-6500系列打印机拆解教程?
喝酒没肉不香2026/3/9

1.后盖 取下 1 处两个螺丝,撬开 2 的卡爪,取下后盖。 2.左盖 拆下后盖后,撬开 1 处两个卡爪,取下左盖。 3.右盖 拆下后盖后,撬开 1、2 处 6 个卡爪,取下右盖。 4.扫描台 4.1拆下左盖后,取下 1 处的螺丝,拔除 2 处的三根连接线。 4.2取下 1 处的卡环,拔除支撑杆固定销,取下扫描台。 5.引擎板 拆除右盖后,拔除 1 处,取下 2 处螺丝,取下引擎板。 7.高压电源板 7.1取下引擎板后,取下


OpenClaw安装技能的三种方式
可夫小子2026/3/1

OpenClaw安装技能的三种方式 💡 大家好,我是可夫小子,《小白玩转ChatGPT》专栏作者,关注AI编程、AI自动化和自媒体。 OpenClaw很强大,但也需要搭配很多技能才能发挥最大的作用,它的安装方式没有像Claude Code那样复杂,但也还有一些操作过程。在OpenClaw里面,可以通过WebUI的界面,很直观的管理安装的技能。 但如果安装技能呢?今天分享常用的三种方式。 通过官方openclaw命令来配置 通过openclaw config 命令,选择Local→Ski


基于 YOLOv8 的水体污染目标检测系统 [目标检测完整源码]
我是杰尼2026/2/21

基于 YOLOv8 的水体污染目标检测系统 [目标检测完整源码] 一、背景:水体监控为什么需要“计算机视觉”? 在水资源保护与环境治理领域,“看得见问题”往往是治理的第一步。然而在真实场景中,水体监控长期面临以下现实挑战: 水域面积大、人工巡检成本高 污染物种类多、形态变化大 人工判读主观性强、难以量化 传统传感器难以识别“视觉型污染” 随着无人机、高清摄像头、遥感设备的普及,水体数据获取已不再是瓶颈,真正的难点转移到了如何从海量影像中自动识别污染风险。 基于此,本文介绍一套 以 YOLOv


【C++】多态
yuuki2332332026/2/12

前言: 在面向对象编程的学习脉络中,继承机制让代码的复用和层级设计成为可能,但仅靠继承无法完全体现对象行为的灵活性。比如我们通过继承定义了Person基类,以及Student、Soldier等派生类后,若想让不同对象执行 “买票” 这一相同名称的行为时展现出不同逻辑(普通人全价、学生打折、军人优先),单纯的继承语法无法高效实现这种 “一个接口,多种实现” 的需求。而多态作为继承的延伸与升华,恰好解决了这一问题 —— 它让继承体系下的不同对象,对同一行为能做出符合自身特性的响应,是面向对象编程中实

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 XYZ博客