处理JSX
JSX优点
JSX 本质是 JavaScript 的语法糖,让写 UI 更自然。
我们知道,JSX并不是一种全新的语言,而是允许你在 JavaScript 代码中直接书写类似 HTML 的标签。
而这种设计消除了传统开发中"在 JS 里拼接 HTML 字符串"或"频繁调用 DOM API"的繁琐,让你能用最熟悉的标签语法来构建界面,大大降低了程序员的负担。
提供了直观且声明式的开发体验。
传统的命令式编程需要你一步步告诉浏览器"创建这个元素"、"添加那个属性"、"把它插到那里"。
而 JSX 是声明式的,你只需要描述"界面最终应该长什么样"。
代码的结构直接对应了 DOM 的树形结构,让你一眼就能看出最终的渲染结果,极大地提升了代码的可读性和可维护性。
动态数据的渲染变得极其简洁。
在 JSX 中嵌入动态内容只需一对花括号 {}。无论是渲染列表(user.map)、显示变量(${user.name})还是执行条件判断,都像是在写普通的 JavaScript 代码一样自然。这种无缝融合避免了传统模板引擎中特殊的语法指令,让开发者能完全专注于业务逻辑本身。
源码的第一阶段:The CreateElement Function
意识到 JSX 只是表象,真正的起点是 Babel 的转译。
首先先来看看这个命令:
bash
pnpm i @babel/core @babel/preset-react @babel/cli
浏览器原生只认识标准的 JavaScript(ES5/ES6+),
完全看不懂 JSX 语法(即 <div> Hello </div> 这种写在 JS 里的 HTML 标签)。如果直接运行,浏览器会报错 SyntaxError。
Babel 的作用:它在代码运行前(编译阶段),扫描你的代码,把所有 JSX 标签"翻译"成普通的 JavaScript 函数调用
bash
@babel/preset-react
Babel 核心本身不知道如何翻译 JSX,它需要插件。这个包提供了一套标准的 React 翻译规则。 当我写下 <div>Hello</div> 这样的 JSX 语法时,浏览器其实是无法直接理解的。
在代码运行前,Babel 已经默默工作,将其"翻译"成了标准的 JavaScript 函数调用:React.createElement('div', null, 'Hello')。这一步让我明白,JSX 本质上就是 createElement 函数的语法糖,而我的 Mini React 核心任务之一,就是实现这个函数。
接着,我们定义了 createElement 函数的职责:接收参数并构建虚拟节点。
这个函数非常简单却至关重要,它主要接收三个参数:
type:代表元素的类型,可以是字符串(如'div'),也可以是一个组件函数。props:包含所有的属性配置(如className,id等)。children:元素内部嵌套的子节点。
任务是将这些参数打包,返回一个描述 UI 结构的普通 JavaScript 对象,也就是我们常说的 VDOM (Virtual DOM) Element。
递归逻辑,确保所有节点(包括文本)格式统一。
在构建 VDOM 树时,我发现子节点可能是复杂的组件,也可能是简单的文本字符串。为了后续渲染(Render)和对比(Diff)算法的统一处理,我必须对它们进行标准化:
- 如果子节点是对象,说明它已经是 VDOM 节点,直接使用。
- 如果子节点是字符串或数字(叶子节点),我需要手动将其包装成一个特殊的 VDOM 对象,通常标记为
TEXT_ELEMENT。
通过这种递归处理,无论我的组件树有多深、多复杂,最终得到的都是一棵结构完全一致的纯 JSON 树。
最终,我们得到了一个标准化的 VDOM 对象结构。
经过上述处理,createElement 返回的对象长这样:经过上述处理,createElement 返回的对象长这样:
javascript
{
type: 'TEXT_ELEMENT' | 'div' | ComponentFunction, // 节点类型
props: {
className: '...', // 原始属性
children: [ // 统一处理后的子节点数组
{
type: 'TEXT_ELEMENT',
props: { nodeValue: 'Hello', children: []
}
\},
// ...其他子节点
]
}
}
开发者只需关心数据和业务逻辑,而繁琐的 DOM 创建、属性挂载、以及未来可能发生的重绘重排优化,都将被 React(或我的 Mini React)基于这棵 VDOM 树自动接管。这就是声明式编程的魅力所在。
源码的第二阶段:The Render Function
意识到 VDOM 只是蓝图,真正的落地是真实 DOM 的构建与挂载。
首先,让我们明确 render 函数的使命:它接收上一阶段生成的 VDOM 对象 和一个真实的 DOM 容器(container) 。
浏览器虽然能执行 JS,但它无法直接"显示"一个 JavaScript 对象。如果只停留在 VDOM 层面,用户看到的将是一片空白。
render 的作用:它在代码运行期(执行阶段),遍历 VDOM 树,将抽象的 JSON 节点"翻译"成浏览器能理解的真实 DOM 节点,并最终插入到页面中。
javascript
function render(element, container) {
// 开始构建真实世界
}
接着,我们定义了节点创建的逻辑:区分元素节点与文本节点。
这个判断至关重要,因为浏览器 API 对两者的创建方式完全不同:
- 元素节点(Element Node) :如果
element.type不是'TEXT_ELEMENT'(例如'div','h1'),我们需要调用document.createElement(element.type)。 - 文本节点(Text Node) :如果
element.type是'TEXT_ELEMENT',说明这是一个叶子节点,我们需要调用document.createTextNode('')。
通过这一步,我们将 VDOM 中的 type 属性映射成了真实的 DOM 实例。
属性过滤逻辑,确保 children 不被误当作 DOM 属性处理。
在将 VDOM 的 props 应用到真实 DOM 时,我发现 props 对象里混杂了普通属性(如 className, style)和子节点(children)。
- 核心规则 :除了
children以外,props里的其他键值对都应该被当作 DOM 的属性处理。 - 为什么? 因为 DOM API 中没有
setAttribute('children', ...)这种方法。children需要通过递归渲染,作为子节点挂载到父节点内部,而不是作为属性挂在标签上。
javascript
function render(element,container){
//console.log(element,container);
const dom =
element.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(element.type);
// 处理 props
const isProperty = key => key !== 'children';
Object.keys(element.props)
.filter(isProperty)
.forEach(name =>{
console.log(name,'///');
dom[name] = element.props[name];
})
element.props.children.forEach(child => render(child,dom));
container.appendChild(dom);
}
属性添加与事件绑定,赋予节点生命。
遍历过滤后的属性,将它们一一"画"到真实 DOM 上:
- 普通属性 :直接赋值,如
dom.className = value或dom.id = value。 - 特殊处理 :对于
style对象,需要遍历其内部键值对设置dom.style[key];对于以on开头的事件(如onClick),则需要使用addEventListener进行绑定,而不是直接赋值。
这一步让原本苍白的 DOM 节点拥有了样式、ID 和交互能力。
递归挂载,完成从虚拟到现实的最后一公里。
这是 render 函数最精妙的地方。处理完当前节点的属性和类型后,我们需要处理它的 children:
- 取出
element.props.children。 - 遍历每一个子节点(无论它是文本还是复杂的组件 VDOM)。
- 递归调用
render(child, dom)。注意,这里的第二个参数不再是根容器container,而是当前刚刚创建好的父节点dom。
这种递归结构确保了无论组件树有多深,所有子节点都会准确地挂载到其对应的父节点下。最后,将处理完备的 dom 节点 appendChild 到传入的 container 中。
最终,我们得到了一棵与 VDOM 结构完全一致的真实 DOM 树。
经过上述处理,页面上呈现出的结构与开发者编写的 JSX 完全一致:
html
<!-- 开发者写的 JSX -->
<div className="card">
<h1>Hello</h1>
</div>
<!-- Render 函数生成的真实 DOM -->
<div class="card">
<h1>Hello</h1>
</div>
开发者只需描述 UI 应该是什么样子 ,而繁琐的 document.createElement、setAttribute、递归遍历以及节点挂载,都被 render 函数自动接管。
这就是声明式编程的闭环:createElement 负责在内存中构建蓝图(VDOM),render 负责在浏览器中按图施工(Real DOM)。 至此,我们的 Mini React 已经具备了最基础的"渲染"能力。
实现二前瞻及总结
✅ 我们做到了什么?
- JSX 祛魅 :理解了 JSX 只是
createElement的语法糖,本质是构建 JS 对象树。 - 标准化 VDOM :实现了将字符串、数字等"叶子节点"统一包装为
TEXT_ELEMENT对象,确保了树结构的一致性。 - 递归渲染:通过简单的递归逻辑,将虚拟 DOM 树完整映射为真实 DOM 树,并挂载到页面。
- 属性处理 :学会了区分
children和普通属性,正确地将props应用到 DOM 节点上。
现在的 Mini React 已经可以完美渲染静态页面了!🎉
⚠️ 核心痛点:当前 render 函数的致命缺陷
虽然我们的代码能跑,但它存在一个架构级的致命问题,这也是 React 在 v15 及之前版本面临的真实困境:
🛑 缺陷一:同步阻塞(Synchronous & Blocking)
当前的 render 函数是同步递归执行的。
javascript
编辑
javascript
1function render(element, container) {
2 // ...创建 DOM...
3 element.props.children.forEach(child => {
4 render(child, dom); // 👈 递归调用,必须等子节点全部渲染完才能返回
5 });
6 // ...挂载...
7}
后果:
- 如果组件树很深(例如 1000 层嵌套)或者节点很多(例如渲染一个包含 10,000 条数据的列表),
render函数会一直执行,直到整棵树全部构建完毕。 - 主线程被独占 :在此期间,浏览器的主线程(Main Thread) 被完全占用。
- 用户感知 :页面会出现明显的卡顿(Jank) ,输入框无法响应点击,动画停止,甚至浏览器标签页提示"页面无响应"。
📊 数据事实 :浏览器通常需要在 16ms 内完成一帧的绘制才能达到 60FPS 的流畅度。如果我们的
render执行了 200ms,用户就要干等 200ms 才能看到画面或进行交互。
🛑 缺陷二:无法中断与恢复(Non-Interruptible)
一旦 render 开始,它就停不下来。
- 即使此时有一个更高优先级的任务(比如用户突然点击了一个紧急按钮,或者输入框需要更新),JavaScript 引擎也必须等当前的
render递归彻底结束后,才能去处理那个高优先级任务。 - 缺乏调度能力:我们无法说"先渲染一部分,让出主线程处理用户输入,剩下的等浏览器空闲了再渲染"。
🛑 缺陷三:全量重绘(No Diffing Yet)
目前的 render 每次调用都是推倒重来。
- 如果状态改变(例如只修改了一个字),我们会销毁整个 DOM 树,重新创建所有节点。
- 这不仅性能低下,还会导致焦点丢失 (input 框失去焦点)和状态重置(视频播放暂停、滚动条回到顶部)。
🔮 下一篇前瞻:引入 Fiber 架构,实现"可中断"渲染
为了解决上述"同步阻塞"的灾难,React 团队在 v16 引入了革命性的 Fiber 架构。这也是我们 Mini React 进化的下一个终极目标。
🚀 第三阶段目标:Mini React Fiber
我们将重构 render 函数,不再使用递归,而是采用链表遍历 + 时间切片(Time Slicing) 。
1. 核心变革:从"递归栈"到"工作单元(Work Unit)"
- 旧模式:函数调用栈(Call Stack),深不可测,无法中断。
- 新模式 :将渲染任务拆分成一个个小的 Fiber 节点。每个 Fiber 代表一个工作单元。
- 数据结构升级 :VDOM 对象将增加
return(父),child(子),sibling(兄弟) 指针,形成一棵可遍历的链表树。
2. 实现"时间切片"(Time Slicing)
我们将利用 requestIdleCallback (或 setTimeout 模拟) 来实现:
javascript
编辑
scss
1function workLoop(deadline) {
2 // 只要还有剩余时间,就继续工作
3 while (deadline.timeRemaining() > 0 && nextUnitOfWork) {
4 // 执行一个小任务
5 nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
6 }
7 // 时间用完了?主动让出主线程,等下一帧空闲再继续
8 if (nextUnitOfWork) {
9 requestIdleCallback(workLoop);
10 } else {
11 // 所有任务完成,提交阶段
12 commitRoot();
13 }
14}
效果 :渲染过程变得可中断、可恢复、可优先級调度。高优先级任务(如用户输入)可以插队,低优先级任务(如渲染长列表)可以利用碎片时间慢慢完成。
3. 双缓冲机制(Double Buffering)
- 引入 Current Tree (屏幕上显示的) 和 WorkInProgress Tree (内存中构建的)。
- 所有的修改都在 WIP 树上进行,互不干扰。
- 只有当整棵树构建完成后,才一次性替换指针,瞬间更新 UI,避免用户看到中间状态。
📅 预告内容
在下一篇文章中,我们将:
- 重构数据结构 :定义
Fiber节点结构。 - 实现调度器 :编写
workLoop,让渲染"动起来"且"可暂停"。 - 分离阶段 :明确区分 Render 阶段 (可中断,构建 Fiber 树)和 Commit 阶段(同步,一次性更新 DOM)。
- 解决痛点:演示如何在渲染 10,000 个节点时,依然保持输入框流畅响应。
从"同步阻塞"到"并发可调度的 Fiber",这是现代前端框架性能优化的分水岭。准备好了吗?让我们进入 React 最核心的深水区! 🌊