1. "手写一个 React 框架"是什么意思?
"手写"不是让你从零发明一个 React,而是用纯 JavaScript 自己实现 React 最核心的两个功能:
createElement--- 把 JSX 转成虚拟 DOM(VDOM)对象render--- 把虚拟 DOM 渲染成真实 DOM
在这个项目中,用 ~60 行代码实现了一个迷你框架 Didact (挂载在 window.Didact 下),模拟 React 的核心流程。
scss
整个流程只有两步:
JSX → Babel转译 → Didact.createElement() → VDOM对象 → Didact.render() → 真实DOM
- 第一步 createElement :接收标签名、属性、子元素,返回一个普通 JS 对象(VDOM),对文本子节点统一包装成
TEXT_ELEMENT类型。 - 第二步 render :根据 VDOM 的
type创建真实 DOM 节点,挂上属性,递归处理子节点,最后appendChild到容器。
React 的真实源码当然更复杂(Fiber 调度、Diff 算法、并发模式等),但对于理解核心原理,这两个函数就够了。React 的本质就是 JavaScript 函数。
2. 框架还能手写吗?
当然可以。React 本质上就是 JavaScript 函数,核心机制并不神秘:
js
// 这就是 "手写" 的 createElement,只是一个接收参数、处理数据、返回对象的纯函数
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === 'object' ? child : createTextElement(child)
)
}
}
}
整个 Didact 框架只有 60 行代码,就实现了 JSX → VDOM → 真实 DOM 的完整链路。React 框架的起点,就是一个简单的纯函数。
3. 什么是"统一的命名空间"?
指框架的所有 API 挂在一个对象下,例如:
js
window.Didact = {
createElement, // 创建虚拟DOM
render // 渲染虚拟DOM
}
这和 React 的做法一样 --- React 也是一个统一的命名空间,所有 API 都是 React.createElement、React.render、React.useState 这样统一使用的。
4. JS 对象是什么?VDOM 如何用 JS 对象描述界面?
JS 对象 就是 JavaScript 里用 {} 包起来的一组键值对 ,用 . 来读写属性值:
js
// 这就是一个 JS 对象 ------------ 描述一个"人"
const person = {
name: "张三",
age: 28,
city: "上海"
}
console.log(person.name); // "张三"
person.age = 29; // 改数据
VDOM 就是一个 JS 对象 ,用 type 和 props 来描述 DOM 结构:
js
// 这段 JSX:
<div style="background:salmon">
<h1>Hello</h1>
</div>
// 经过 Babel 转译 + createElement 之后,变成一个 JS 对象:
const vdom = {
type: "div",
props: {
style: "background:salmon",
children: [
{
type: "h1",
props: {
children: [
{ type: "TEXT_ELEMENT", props: { nodeValue: "Hello", children: [] } }
]
}
}
]
}
}
多个 element 对象通过 children 数组嵌套,就构成了 VDOM 树。
5. VDOM → 真实 DOM 是什么意思?render 函数具体做了什么?
- 虚拟 DOM:内存里的普通 JS 对象,描述"页面上应该有什么"。用户看不见。
- 真实 DOM:浏览器里真正显示的 HTML 元素。用户能看见。
- render:把 JS 对象描述的页面结构,变成浏览器真正能显示的 HTML 元素。
less
虚拟DOM对象 (内存里) 真实DOM (页面上)
───────────────────── ─────────────────
{ <div>
type: "div", render <h1>Hello</h1>
props: { ──────> <h2>World</h2>
children: [...] </div>
}
}
render 函数拆解为三步(对应项目源码):
第一步 :根据 type 创建真实 DOM 节点
js
const dom = element.type === 'TEXT_ELEMENT'
? document.createTextNode('') // 文本节点
: document.createElement(element.type); // 普通标签
第二步 :把属性挂上去(过滤掉 children 属性)
js
const isProperty = key => key !== "children";
Object.keys(element.props).filter(isProperty).forEach(name => {
dom[name] = element.props[name]; // 如 style="background:salmon"
});
第三步:递归处理子节点,塞进父节点
js
element.props.children.forEach(child => render(child, dom)); // 递归
container.appendChild(dom); // 最终插入页面
render 做完 appendChild 之后就结束了,此时页面上有了真实的 DOM 节点。底层调用的就是浏览器原生 API:document.createElement、document.createTextNode、appendChild,和你在 HTML 原生 JS 里用的完全一模一样。
VDOM 不是"另一种 DOM" ,可以将 VDOM 理解为内存里的"设计图纸",render 就是施工队按图纸造真实的房子。React/Didact 底层调用的就是浏览器原生 DOM API,最终产物都是标准的 DOM 节点(HTMLDivElement 等),和你在 HTML 里手写的没有区别。
6. "开发者只需要操作 JS 对象(改数据)"是什么意思?
这句话容易产生误解,不是说开发者去手动操作 VDOM 那个 JS 对象,而是说 React 内部用 JS 对象(VDOM)来代替直接操作真实 DOM,开发者只需要改变普通的状态数据即可。
对比两种写法:
没有 React 时,操作的是真实 DOM(性能差、代码繁琐):
js
document.getElementById('title').innerText = 'Hello';
document.getElementById('title').style.color = 'red';
// 每次改东西都要这样写一大串
有 React 时,开发者只需改 state:
jsx
function App() {
const [text, setText] = useState("Hello"); // 改这个数据就行
return <h1>{text}</h1> // React 自动同步到页面
}
整个流程是 :你改数据(setText) → React 内部用 createElement 生成新的 VDOM(JS 对象)→ React 拿新旧 VDOM 对比,算出差异 → React 只更新变化的那部分真实 DOM。作为开发者你根本不需要碰 DOM,只需要改 text 这种普通数据就够了。"操作 JS 对象"发生在 React 内部,React 用 JS 对象做高效对比后再更新页面,你不需要手动写。
7. React 的 DOM 节点和 HTML 里的 DOM 节点一样吗?
完全一样。 React / Didact 底层调用的就是浏览器原生 API:
js
// Didact render 函数的源码,用的就是原生 API
const dom = element.type == 'TEXT_ELEMENT'
? document.createTextNode('') // ← 原生 Web API
: document.createElement(element.type); // ← 还是原生 Web API
React/Didact 最终调用 document.createElement、document.createTextNode、appendChild,和你在 HTML 原生 JS 里用的完全一模一样 。最终产物都是浏览器标准的 DOM 节点(HTMLDivElement 等),挂到页面上之后你用 document.getElementById 也能查到。
可以这样理解:两张图纸(新旧 VDOM)在内存里对比,算出哪里需要改,施工队(render)只修有变化的地方。但最终造出来的"房子"(DOM 节点),和你在 HTML 里手写的没有任何区别。
8. Vite/脚手架 和 React 的分工:它们各干各的
Vite 和 React 做的是完全不相干的两件事:
| Vite / 脚手架(构建时) | React(运行时) | |
|---|---|---|
| 干什么 | JSX 编译成 JS、模块化打包、热更新(HMR)、CSS处理、TypeScript编译、生产打包压缩 | VDOM 生成、Diff 对比、DOM 更新 |
| 什么时候干活 | 你写代码 / 保存文件时 | 用户打开网页、点击按钮、交互时 |
| 类比 | 翻译:把 JSX 语法翻译成浏览器能懂的 JS | 厨师:把食材(数据)做成菜(页面) |
整个时间线:
css
┌───── 写代码/保存(构建时)─────┐ ┌───── 浏览器运行(运行时)─────────────────┐
│ │ │ │
│ Vite 干活: │ │ React 干活: │
│ <h1>Hello</h1> │ │ Didact.createElement(...) │
│ ↓ Babel 编译 │ │ ↓ │
│ Didact.createElement('h1', ...) │ → │ { type: "h1", props: {...} } (VDOM) │
│ │ │ ↓ │
│ 然后 Vite 退出,不参与了 │ │ Didact.render(element, container) │
│ │ │ ↓ │
└────────────────────────────────┘ │ <h1>Hello</h1> (真实DOM) │
└──────────────────────────────────────────┘
关键点 :在 setText → 新 VDOM → render 这个过程中,Vite 完全不在场。Vite 只参与"写代码 → 能运行"这一步(把 JSX 编译成 JS),不参与"运行后 → 渲染"这一步。渲染完全是 React 的事。
另外,Vite 不仅仅做 Babel 编译,它还包括:import/export 模块化处理、热更新(HMR)、CSS 预处理器编译、TypeScript 编译、生产环境打包压缩等功能。
9. document.createElement 和 React.createElement 是两个完全不同的函数
名字像但完全不同,这是最容易混淆的地方:
document.createElement |
React.createElement(Didact.createElement) |
|
|---|---|---|
| 谁提供的 | 浏览器原生 API,内置在浏览器里 | React 源码自己写的,是自定义 JS 函数 |
| 返回值 | 真实 DOM 节点(HTMLDivElement 等) |
普通 JS 对象(VDOM){ type, props } |
| 用途 | 创建页面上的 HTML 元素 | 描述页面结构 |
| 函数内部 | 浏览器内核实现,你改不了 | React 团队自己设计参数格式和返回结构 |
js
// 浏览器原生 API ------------ 创建真实 DOM 元素
document.createElement('div') // → <div></div>(真正的 HTML 元素对象)
// React 自己写的函数 ------------ 创建 VDOM 对象(普通 JS 对象)
React.createElement('div', null) // → { type: "div", props: { children: [] } }
React 的 createElement 是 React 团队自己设计并实现的函数,接收 (type, props, children) 三个参数,返回固定结构的 JS 对象 { type, props: { ...children } }。浏览器完全不知道这个函数的存在。
React 做的事 :自己写 createElement 函数 + 自己写 render 函数 + 运行时调用它们。不是调浏览器现成的 API,而是 React 自己造了一套规则和函数,浏览器只负责最后那一步(document.createElement、appendChild),而且也是 React 在 render 函数里替你去调用的。
10. createElement 内部实现详解
当 Babel 把 <h1>Hello</h1> 编译成 Didact.createElement('h1', null, 'Hello') 后,执行过程如下:
js
function createElement(type, props, ...children) {
return {
type, // 'h1' 直接存进 type
props: {
...props, // null 展开就是空(无属性时)
children: children.map(child =>
typeof child === 'object'
? child // 已经是 VDOM 对象,直接使用
: createTextElement(child) // 普通字符串 → 包装成文本节点对象
)
}
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text, // 文本内容
children: [] // 文本节点没有子节点
}
}
}
执行过程拆解 (以 <h1>Hello</h1> 为例):
rust
调用:createElement('h1', null, 'Hello')
参数:
type = 'h1'
props = null
children[0] = 'Hello' (普通字符串)
执行:
1. 'Hello' 是字符串,typeof child !== 'object'
2. 调用 createTextElement('Hello')
→ 返回 { type: "TEXT_ELEMENT", props: { nodeValue: "Hello", children: [] } }
3. 最终返回:
{
type: "h1",
props: {
children: [
{
type: "TEXT_ELEMENT",
props: { nodeValue: "Hello", children: [] }
}
]
}
}
为什么要统一处理文本节点? 普通字符串 'Hello' 和 VDOM 对象结构不同,如果 render 时要分别判断,代码会很乱。通过 createTextElement 把文本也包装成 { type: "TEXT_ELEMENT", props: { nodeValue: "...", children: [] } } 统一格式,所有节点结构一致,render 时无需区分类型,直接递归处理。
11. React 的 render ≠ 浏览器的渲染
两个"渲染"不是一回事,经常被混淆:
| React 的 render | 浏览器的渲染 | |
|---|---|---|
| 做什么 | 把 VDOM(JS对象)变成真实 DOM 节点并 appendChild 插入页面 |
浏览器内核把 DOM + CSS 画成屏幕上的像素 |
| 产物 | HTML 元素节点(<div>、<h1> 等) |
你眼睛看到的画面(像素) |
| 谁在做 | React / Didact 代码 | 浏览器渲染引擎(内核) |
完整渲染流水线:
scss
React render 结束 浏览器渲染开始
────────────── ──────────────
DOM 节点已在页面上 → 构建渲染树 → 布局(计算位置大小) → 绘制(画像素) → 你看到了画面
做完 appendChild 之后,React 的 render 就结束了 。此时页面上有了真实的 DOM 节点,浏览器检测到 DOM 变了,才自动触发自己的渲染流程。React 只负责产出 DOM 节点,至于怎么把 DOM 画到屏幕上,那是浏览器内核(渲染引擎)的事,React 完全不管。
12. VDOM 有什么好处?没有 VDOM 会怎么样?
没有 VDOM 时(原生 JS),每次改数据都直接操作真实 DOM:
js
// 用户点击按钮,想把 "Hello" 改成 "你好"
document.getElementById('title').innerText = '你好'; // 改文字 → 触发重绘
document.getElementById('title').style.color = 'red'; // 改颜色 → 又触发重绘
// 每个改动都直接打在真实 DOM 上,每个改动都可能触发浏览器的重绘重排
VDOM 的三大好处:
-
批量对比,一次性更新:VDOM 是普通 JS 对象,创建和对比都非常快。React 先在 JS 对象层面算完所有差异,再一次性操作真实 DOM,避免频繁触发重绘重排。
没有 VDOM:改文字 → 操作DOM → 改颜色 → 操作DOM → 改大小 → 操作DOM (3 次 DOM 操作)
有 VDOM: 先在 JS 对象里算完所有差异 → 一次性更新 DOM (1 次 DOM 操作) -
跨平台:VDOM 就是一个纯 JS 对象,不依赖浏览器。同一个 VDOM 结构可以渲染到不同平台:
css
VDOM对象 → render → 浏览器 DOM
→ render → React Native(iOS/Android 原生组件)
→ render → Canvas
→ render → PDF
→ render → ...
- 解耦数据与 DOM:开发者只关心数据(state),React 负责把数据高效同步到页面。你可以把 VDOM 理解为"数据"和"页面"之间的中间层。
13. 一个 JSX 片段 = 一个 element 对象
JSX 只是写法上的语法糖 ,它等于 createElement 的调用,然后返回一个 element 对象(VDOM 树上的一个节点):
js
// 这两行是等价的:
const el1 = <div>Hi</div> // JSX 写法
const el2 = Didact.createElement('div', null, 'Hi') // 编译后写法
// el2 的值 = { type: "div", props: { children: [TEXT_ELEMENT] } } ← element 对象
复杂嵌套示例:
jsx
const element = (
<div style="background:salmon">
<h1>Hello</h1>
<h2>World</h2>
</div>
)
// 编译后变成一个嵌套的 element 对象:
{
type: "div",
props: {
style: "background:salmon",
children: [
{ type: "h1", props: { children: [{ type: "TEXT_ELEMENT", props: { nodeValue: "Hello" } }] } },
{ type: "h2", props: { children: [{ type: "TEXT_ELEMENT", props: { nodeValue: "World" } }] } }
]
}
}
每个 element = VDOM 树上的一个节点 ,节点之间通过 children 数组串联,最终形成一棵树。JSX 就是一层糖衣,剥开后全是普通的 JS 函数调用和对象。
14. 挂载点是什么?和 render 有什么关系?
挂载点 是 HTML 里预先放好的一个空容器(通常是 <div id="root"></div>),亲手写的或脚手架模板生成的。
html
<!-- index.html -->
<body>
<div id="root"></div> <!-- ← 挂载点,一开始是空的,等着 React 往里填内容 -->
</body>
JS 中通过 getElementById 拿到它,传给 render:
js
const container = document.getElementById('root'); // 获取挂载点
Didact.render(element, container); // 把 VDOM 渲染的内容塞进去
render 前后对比:
xml
render 之前: render 之后:
<div id="root"></div> <div id="root">
<div style="background:salmon">
<h1>Hello, world!</h1>
<h2>from Didact</h2>
</div>
</div>
↑ ↑
空壳子 VDOM 渲染的内容被塞进去了
在 render 函数最后,通过 container.appendChild(dom) 把生成的 DOM 塞进挂载点。
不是每个标签都是挂载点 。挂载点是 React 接管页面的唯一入口,整个应用通常只有一个。挂载点之外的内容(如果有)不受 React 控制。React 从这个入口进去之后,里面的所有内容都是 React 自己管的。