前言
感谢 Build your own React 的作者 Rodrigo Pombo,他用不到 300 行代码加上优雅丝滑的动画效果,把 React 内核讲得清清楚楚。这篇博客沿袭了他的思路,并引用了部分原文内容。遵循真实React代码的架构,但没有所有优化和非必需功能,一步步打造出 MiniReact。
说明:本项目是对 React 极简化版的实现。真实 React 拥有合成事件、优先级调度、并发特性、服务端渲染等工业级能力,MiniReact 仅聚焦最核心的设计思想。
通过手写 MiniReact,理解虚拟 DOM 到真实 DOM 的转换、Fiber 链表如何实现可中断渲染、调和(reconciliation)如何复用节点、函数组件的本质,以及 useState 背后的闭包原理------这些正是真实 React 运行机制的基石。
一、初始化项目
我们使用 Vite 创建一个原生 JS 项目,然后用我们自己写的 mini-react 来驱动页面。
bash
pnpm create vite@latest my-react -- --template vanilla
- 在根目录下创建
main.js和MiniReact文件夹 - 在MiniReact文件夹下创建入口文件
index.js
js
// 将来这里将会导入所有的函数,比方render、createElement
// MiniReact这个对象用于存放上面导入的函数,将来想要使用直接:MiniReact.render()
const MiniReact = {};
// 对外暴露MiniReact的入口文件
export default MiniReact;
- 将
index.html改成:
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mini React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
- 删除
Public、src、main.js最终项目结构如下:
tcl
my-react/
├── MiniReact/
│ └── index.js
├── node_modules/
├── .gitignore
├── index.html
├── main.js
├── package.json
└── pnpm-lock.yaml
- 运行
pnpm run dev将初始化项目跑起来
二、核心工作流程
在动手之前,我们先梳理一下 React 的核心工作流程:
- JSX 转换 :
<div>hello</div>→React.createElement('div', null, 'hello')→ 虚拟 DOM 对象 - 渲染阶段 (Render Phase) :将虚拟 DOM 转换成 Fiber 树,可中断
- 提交阶段 (Commit Phase):将 Fiber 树同步渲染到真实 DOM
- 更新阶段 :状态变化触发重新渲染,通过调和 (Reconciliation) 找出最小变更
我们的 mini-react 将分成以下八步(跟Pombo保持一致),完整实现上述流程:
- createElement
- render函数
- Concurrent Mode 并发模式
- Fiber架构
- Render and Commit 渲染和提交
- Reconciliation调和
- FC函数组件
- Hooks实现
三、createElement
我们写的 JSX 语法,浏览器无法直接识别。Babel 会在编译阶段,将其转换为 React.createElement 函数调用。
jsx
// 我们写的 JSX
const element = <h1 title="hello">Hello MiniReact</h1>
// Babel 编译后的真实代码
const element = React.createElement("h1", { title: "hello" }, "Hello")
React.createElement 的核心作用,就是将入参转换为一个标准的 Element(元素)对象 ------ 这是 React 描述 DOM 结构的基础,本质是一个普通 JS 对象,核心包含两个属性:
type:一个字符串,用来指定 DOM 节点类型,比如h1、div等。props:一个对象,用来存储JSX的所有属性(比如id、style),同时包含特殊属性 childrenchildren用来描述当前元素的子节点:本质上是字符串或元素数组。因此 Element 天然是一个树形结构,完美对应真实 DOM 的层级关系。
理解了 Element 的本质,我们就可以动手实现自己的 createElement ,生成符合 React 规范的 虚拟DOM对象。
js
{
type: "h1",
props: {
title: "hello",
children: "Hello"
}
}
在MiniReact文件夹下创建createElement.js
js
/**
* Element元素分为纯文本和对象,因此需要两个函数来处理
* - 1:createTextElement
* - 2:createElement
*/
// (1)创建纯文本元素
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT', // 👈== 写死
props: { nodeValue: text, children: [] },
}
}
// (2)创建普通元素
function createElement(type, props, ...children) {
return {
type,
props: { ...props, children }
}
}
export { createTextElement, createElement };
export default createElement;
将createElement导入到入口文件index.js中
js
import createElement, { createTextElement } from "./createElement";
const MiniReact = {
createElement,
createTextElement
}
export default MiniReact;
在根目录下的index.js中引入并使用
js
import MiniReact from "./MiniReact";
// 这里就跟React.createElement一样了,只不过名字是MiniReact
const element = MiniReact.createElement(
'h1',
{ id: 'title', style: "background:red" },
'Hello World',
MiniReact.createElement('div', { id: 'name', style: "background:blue;" }, '---MiniReact')
)
// 打印看看长啥样
console.log(element)
在浏览器控制台看看结果

💡 思考 :这就是 JSX 被 Babel 编译后的结果。所以 React 可以不使用 JSX,直接手写
createElement。有一个关键细节需要注意:createElement的children属性我们是直接赋值的,但是我们知道children存放的是另外的element元素,而element元素又分为纯文本和对象,而纯文本元素又有单独的创建方法:
createTextElement,所以需要统一处理一下:
js
// 更新createElement方法
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
// 某一个children元素是对象就直接用,否则创建纯文字节点
children: children.map(child => {
if (typeof child === "object") return child;
else return createTextElement(child);
})
}
}
}
四、render
render作用:将虚拟 DOM渲染成浏览器可识别的真实 DOM,并挂载到页面容器上 。最简单最直观的实现方式是递归渲染
在MiniReact文件夹下创建render.js
js
function render(element, container) {
// (1)节点
const dom = element.type === 'TEXT_ELEMENT'
? document.createTextNode(element.props.nodeValue)
: document.createElement(element.type)
//(2)属性
const keys = Object.keys(element.props).filter(key => key !== 'children')
keys.forEach(key => dom[key] = element.props[key])
// (3)子节点
element.props.children.forEach(child => render(child, dom)) // 递归
// (4)追加到container中
container.appendChild(dom)
}
export default render;
将render导入到入口文件index.js中,然后在main.js中使用
js
import { createElement, createTextElement } from "./createElement";
import render from "./render";
const MiniReact = {
createTextElement,
createElement,
render,
}
export { createElement, render, createTextElement };
export default MiniReact;
js
import MiniReact from "./MiniReact";
const element = MiniReact.createElement(
'h1',
{ id: 'title', style: "background:red" },
MiniReact.createTextElement('hello world'), // 👈变更:这个改成文字节点
MiniReact.createElement('div', { id: 'name', style: "background:blue;" }, '---MiniReact')
)
const container = document.querySelector('#root');
MiniReact.render(element, container); // 将虚拟dom转成真实dom并挂载到 root 容器中
运行npm run dev,打开浏览器看看效果:
五、Concurrent Mode
递归 render 有一个致命问题:一旦开始,整棵树必须一次性渲染完,中途无法停止。
如果组件树很大,渲染期间,主线程会被长时间占用,用户操作、动画等高优先级任务得不到及时响应造成页面卡顿。这在真实项目中无法接受。
React 16 重构方案:将渲染工作拆分成多个小单元,每次只处理一个单元,处理完后就把主线程交还给浏览器 。如果有紧急任务(点击/动画)就暂停渲染。等浏览器空闲后,再继续处理下一个工作单元。这就是 Concurrent Mode(并发模式) 的核心思想。
实现思路:
- 把渲染拆成一个个小工作单元(Unit of Work)
- 用浏览器的 requestIdleCallback 执行任务
- 每次干完一个单元,就把主线程还给浏览器
- 时间不够就暂停,下次空闲继续
注意:React 现在用 Scheduler 包代替了 requestIdleCallback,但思想完全一样
Scheduler在我往期文章有实现:从零实现React Scheduler调度器
js
// 下一个要执行工作单元
let nextUnitOfWork = null
// 执行每一个工作单元
function performUnitOfWork() {}
// 工作循环 -- 这里的deadline是requestIdleCallBack传给回调函数的
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
// 执行完一个工作单元后要返回下一个工作单元
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
// 检查是否需要交出主线程
shouldYield = deadline.timeRemaining() < 1
}
// 告诉浏览器,下一次你空闲了请继续执行我的工作循环
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop) // 第一次请求
六、⭐Fiber
为了让 Concurrent Mode 真正落地,我们需要一个能承载「工作单元」的数据结构 ------ 它就是 Fiber。
Fiber 是 React 最核心的设计:把每一个Element节点,变成一个可拆分、可暂停、可恢复的工作单元 ,从而实现可中断渲染
Fiber数据结构:
js
{
type: 'div',
props: { children:[] },
dom: null, // 对应的真实 DOM
parent: null, // 父 Fiber
child: null, // 第一个子 Fiber
sibling: null // 兄弟 Fiber
}

对比虚拟 DOM(Elemen) 和 Fiber 的区别:Element 只描述 DOM 结构,Fiber 不仅描述结构,还记录遍历关系、工作状态、DOM 实例,让渲染可以随时中断、继续
我们需要对代码进行迁移和重构:
在MiniReact文件夹下创建 createDOM.js - 基于fiber节点创建真实DOM
js
// 将创建真实DOM的逻辑从render函数中抽离
function createDOM(fiber) {
// (1)创造DOM节点
const dom =
fiber.type === 'TEXT_ELEMENT'
? document.createTextNode(fiber.props.nodeValue)
: document.createElement(fiber.type)
//(2)赋值属性
const keys = Object.keys(fiber.props).filter(key => key !== 'children')
keys.forEach(key => dom[key] = fiber.props[key])
// (3)返回DOM
return dom;
}
export default createDOM;
- 将调度机制放到
render.js中
In the
renderwe'll create the root fiber and set it as thenextUnitOfWorkpomb提到,在render函数中,需要创建root fiber并且将他设置成下一个工作单元
js
let nextUnitOfWork = null
function render(element, container) {
// 这个就是root的fiber
nextUnitOfWork = {
dom: container,
props: { children: [element] },
sibling: null,
parent: null
}
}
// 执行一个工作单元
function performUnitOfWork(fiber) {}
// 工作循环
function workLoop(deadLine) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadLine.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop) // 第一次请求
export default render;
- 实现
performUnitOfWork
The rest of the work will happen on the
performUnitOfWorkfunction, there we will do three things for each fiber:
- add the element to the DOM
- create the fibers for the element's children
- select the next unit of work
接下来的工作就是主要围绕 performUnitOfWork 函数的实现展开
js
import createDOM from '../createDOM'
function performUnitOfWork(fiber) {
// ======== 1.创建DOM并添加到parentFiber的Dom上 =========
if (!fiber.dom) {
fiber.dom = createDOM(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// ========= 2.为每一个child生成fiber并构建关系 ==========
let prevSibling = null // 记录
// 把每一个child元素,弄成一个fiber
for (let childrenElement of fiber.props.children) {
const newFiber = {
type: childrenElement.type,
props: childrenElement.props,
parent: fiber, // child的父亲就是当前fiber
child: null,
dom: null,
sibling: null
}
// 构建fiber关系 -- 下文解释
if (!fiber.child) fiber.child = newFiber
else prevSibling.sibling = newFiber
// 更新引用
prevSibling = newFiber
}
// ============ 3. 返回下一个工作单元(fiber) ===========
if (fiber.child) return fiber.child
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling
nextFiber = nextFiber.parent
}
// 找不到说明下一个工作单元为空,那么workLoop就会停止
return undefined;
}
对于步骤 2、3做以下解释:

感兴趣的话可以阅读一下这篇文章:[React Fiber 入门------React 背后的算法](https://www.velotio.com/engineering-blog/react-fiber-algorithm#:\~:text=React Fiber is a completely,node of the D0M tree.)
运行npm run dev打开浏览器dom成功渲染出来了,并且可以插入调试代码使得在控制台打印出来整颗Fiber树结构:
七、Render and Commit Phases
在 Fiber 实现中,我们存在一个关键问题:每处理一个 Fiber 工作单元,就立刻把对应的 DOM 节点添加到页面中:
js
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDOM(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom) // ⚡立刻追加到页面上去
}
// ...其他代码不变
}
浏览器的 requestIdleCallback 随时可能中断我们的渲染工作,这就会导致用户看到不完整的 UI ------ 比如只渲染了一半的列表、残缺的按钮...
React 解决这个问题的核心方案是:把构建 Fiber 树 和 更新真实 DOM 这两个过程 分离:
- Render 阶段(可中断):只构建 Fiber 树、计算 DOM 变更,不操作真实 DOM;即使被中断,也不会影响页面展示。
- Commit 阶段(不可中断):当整个 Fiber 树构建完成后,一次性把所有 DOM 变更同步到页面,用户只会看到完整的 UI 变化。
具体做法如下:
- 用
wipRoot记录整棵 Fiber 树的根 - 在
workLoop里,当没有下一个工作单元时,调用commitRoot commitRoot会递归地把所有 Fiber 的 DOM 一次性挂载到页面上
js
// 更新一下 render.js
let nextUnitOfWork = null // 下一个工作单元
let workInProgressRoot = null // 👈新增:工作中的根节点
function render(element, container) {
workInProgressRoot = {
dom: container,
props: { children: [element] },
sibling: null,
parent: null
}
nextUnitOfWork = workInProgressRoot // 下一个工作单元就是 root Fiber
}
function performUnitOfWork(fiber) {
// ...其他代码不变
// -------------✂删掉这部分代码✂-------------
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom)
// }
// ---------✂不在构建Fiber树的时候提交✂-------
// ...其他代码不变
}
function workLoop(deadLine) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadLine.timeRemaining() < 1
}
requestIdleCallback(workLoop)
// 当没有下一个工作单元并且workInProgress树已经构建完毕,就开始提交阶段
if (!nextUnitOfWork && workInProgressRoot) {
commitRoot()
}
}
接下来,工作重点就是 commitRoot函数
js
// -----------------------(同步)提交阶段----------------------
// 提交根Fiber
function commitRoot() {
commitWork(workInProgressRoot.child)
workInProgressRoot = null // 清空
}
// 从 fiber 开始提交
function commitWork(fiber) {
if (!fiber) return;
const parentDOM = fiber.parent.dom
parentDOM.appendChild(fiber.dom)
// 递归 child 和 sibling
commitWork(fiber.child)
commitWork(fiber.sibling)
}
// --------------------------------------------------------------
为了测试一下commit 过程,我们临时把代码改一下:
js
// 提交根Fiber
function commitRoot() {
const now = performance.now()
commitWork(workInProgressRoot.child)
workInProgressRoot = null
console.log(`commit阶段耗时:${performance.now() - now}ms`);
}
运行 npm run dev ,打开浏览器查看:

八、⭐Reconciliation
到目前为止,我们的 MiniReact 只能新增 DOM 节点 ,但真实场景中,组件状态变化会触发重新渲染 ,需要对 DOM 进行更新或删除。
React 通过调和(Reconciliation) 机制,对比新旧 Fiber 树,找出差异并给Fiber打上标签,最后在Commit阶段把最小变更批量的同步到真实 DOM上 ------ 这也是 React 性能优势的核心。
-
核心思路:
- 保存上一次的 Fiber 树 ------ 用
currentRoot记录上次提交的根 - 每个 Fiber 增加
alternate属性,指向上一棵树中对应的旧 Fiber - 在
render时 ,把currentRoot作为旧树,和新传入的 element 进行比较 - 比较规则 (基于
type):- 相同 type → 复用旧 DOM,更新 props(
UPDATE) - 不同 type 且有新元素 → 创建新 DOM(
PLACEMENT) - 不同 type 且有旧元素 → 删除旧 DOM(
DELETION)
- 相同 type → 复用旧 DOM,更新 props(
- 打上
effectTag,在commit阶段根据 tag 执行对应操作 - 删除节点 需要单独记录到
deletion数组,因为旧树不在workInProgress中
- 保存上一次的 Fiber 树 ------ 用
-
保存 currentRoot 和 alternate
js
// 改造render.js
// 注意:这里虽然叫做currentRoot,但是它保存的是前一个完成的fiber root节点
let currentRoot = null // 👈新增:上次提交的根
let deletion = [] // 👈新增:待删除的 Fiber 列表
function commitRoot() {
commitWork(workInProgressRoot.child)
currentRoot = workInProgressRoot // 👈新增:保存旧 fiber root节点
workInProgressRoot = null
}
function render(element, container) {
workInProgressRoot = {
dom: container,
props: { children: [element] },
sibling: null,
parent: null,
child: null,
alternate: currentRoot // 👈新增:指向旧 Fiber root节点
}
nextUnitOfWork = workInProgressRoot
}
抽离 reconcileChildren
把原来 performUnitOfWork 中创建子 Fiber 的逻辑,抽取到 reconcileChildren 函数中,并加入新旧比较:
js
// ---------(4)reconcile函数--------------- 👈新增
function reconcileChildren(wipFiber, elements) {
// wipFiber == workInProgressFiber
let index = 0
let prevSibling = null
// 这里需要判断一下alternate是否存在,因为初始阶段wipFiber.alternate(旧Fiber)为null
let oldFiber = wipFiber.alternate ? wipFiber.alternate.child : null
while (index < elements.length || oldFiber != null) {
const element = elements[index]
// 两颗DOM一样:新元素存在 并且 老元素也存在 并且 新元素的type跟老元素的type一样
const sameType = element && oldFiber && element.type === oldFiber.type
let newFiber = null
// ---------------针对 更新、新建、删除三种情况分别处理---------------
// (1)更新:类型一样
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props, //👈== 类型一样,直接用新元素的props就行
dom: oldFiber.dom, //👈== 复用老 dom
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE', //👈== 标记为更新
}
}
// (2)新建:新元素存在 老元素不存在
if (!sameType && element) {
newFiber = {
type: element.type,
props: element.props,
dom: null, //👈== 新建的,所以还没有DOM
parent: wipFiber,
alternate: null, //👈== 没有老Fiber,所以为null
effectTag: 'PLACEMENT', //👈== 标记为新建
}
}
// (3)删除:老元素存在 新元素不存在
if (!sameType && oldFiber) {
oldFiber.effectTag = 'DELETION' //👈 标记为删除
deletion.push(oldFiber) //👈 收集待删除的Fiber(后续Commit阶段处理)
}
// ------------------------------end----------------------------
// 将performUnitOfWork中建立fiber之间链条的逻辑写道这里来
if(newFiber) {
if (index === 0) wipFiber.child = newFiber // 第一个为children
else prevSibling.sibling = newFiber // 后续为sibling
prevSibling = newFiber
}
// 更新状态
if (oldFiber) oldFiber = oldFiber.sibling
index++
}
}
// -------------------------------------------
- 在
performUnitOfWork中接入调和逻辑,用来创建newFiber
js
//----------(5)performUnitOfWork执行工作单元------
function performUnitOfWork(fiber) {
// ...其他代码不变
const elements = fiber.props.children
reconcileChildren(fiber, elements) // 👈将比较+创建fiber的逻辑抽离到这个里面来
// -----------------✂删掉这部分代码✂---------------
// let prevSibling = null
// 创建子fiber并构建fiber之间的关系
// for (let childrenElement of elements) {
// const newFiber = {
// type: childrenElement.type,
// props: childrenElement.props,
// parent: fiber,
// child: null,
// dom: null,
// sibling: null
// }
// if (!fiber.child) fiber.child = newFiber
// else prevSibling.sibling = newFiber
// prevSibling = newFiber
// }
//-------------------✂删掉这部分代码✂---------------
// ...其他代码不变
}
- 完善 Commit 阶段,处理变更标签
现在 Commit 阶段需要根据
effectTag执行不同 DOM 操作:
PLACEMENT:新增节点(原逻辑);DELETION:删除节点;UPDATE:更新节点 props。
5.1更新commit函数
js
// -----------------------(3)同步提交阶段-----------------------
// 提交根Fiber
function commitRoot() {
deletion.forEach(commitWork) // 👈 提交的时候,统一删除收集的fiber
commitWork(workInProgressRoot.child)
// 重置工作
deletion = [] // 清空,避免重复删除
currentRoot = workInProgressRoot // 本次渲染的树设置为旧树等下一次渲染
workInProgressRoot = null // 清空workInProgressRoot
}
// 从 fiber 开始提交
function commitWork(fiber) {
if (!fiber) return;
const parentDOM = fiber.parent.dom
// -------------------🆕根据effectTag做不同的DOM操作🆕-------------------
// (1)如果是替换,则替换老的DOM节点
if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
parentDOM.appendChild(fiber.dom)
}
// (2)如果是删除,则从DOM中删除
else if (fiber.effectTag === 'DELETION' && fiber.dom) {
parentDOM.removeChild(fiber.dom)
}
// (3)如果是更新,则单独处理
else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
updateDOM(fiber.dom, fiber.alternate.props, fiber.props) // 👈单独处理
}
// -------------------------🆕operation end🆕--------------------------
// 递归 child 和 sibling
commitWork(fiber.child)
commitWork(fiber.sibling)
}
// --------------------------------------------------------------
5.2 实现updateDOM函数,处理节点更新:
js
// ---------------- (4)updateDOM ----------------
function updateDOM(dom, prevProps, nextProps) {
/**
* 举个例子
* prevProps = {id: 'a', className: 'red', onClick, children: []}
* nextProps = {className: 'blue', style: 'bold', onChange,children: []}
*
* (1)删掉旧属性:id
* (2)更新props:className, style
*/
// (1)移除旧属性
Object.keys(prevProps)
.filter(key => key !== 'children') // ① → {id, className}
.filter(key => !(key in nextProps)) // ② → {id}
.forEach(key => delete dom[key]) // ③ → 删除id属性
// (2)设置新属性
Object.keys(nextProps)
.filter(key => key !== 'children') // ① → {className, style}
.filter(key => !(key in prevProps) || prevProps[key] !== nextProps[key])
// ② → {className,style}
.forEach(key => dom[key] = nextProps[key]) // ③ → red更新为blue,新增style
}
// ---------------------------------------------------------------
5.3 upDateDOM补充
到目前为止,updateDOM 只更新了普通属性(如 id、className)。但 React 中大量使用 onClick、onChange 这类事件监听器,当函数发生变化时,我们需要先解绑旧的监听器 ,再绑定新的,否则内存会泄漏,且交互逻辑不会更新。
js
// ---------------- (4)updateDOM ----------------
function updateDOM(dom, prevProps, nextProps) {
// 在updateDOM顶层新增
//-------------------🆕事件监听器处理🆕-------------------
const isEvent = key => key.startsWith('on') // 是否以on开头
const getEventName = key => key.slice(2).toLowerCase() // onClick -> click
// [1] 移除旧的事件函数
Object.keys(prevProps)
.filter(isEvent)
.filter(key => !(key in nextProps) || prevProps[key] !== nextProps[key])
.forEach(key=>{
const eventName = getEventName(key)
dom.removeEventListener(eventName,prevProps[key])
})
// [2] 添加新的事件函数
Object.keys(nextProps)
.filter(isEvent)
.filter(key => prevProps[key] !== nextProps[key])
.forEach(key=>{
const eventName = getEventName(key)
dom.addEventListener(eventName,nextProps[key])
})
//-------------------🆕事件监听器处理end🆕-----------------
// ... 其余代码保持不变
}
5.4 对于新创建的节点,也需要注册事件监听器
问题:我们只是在upDataDOM的时候对事件绑定函数做了处理,然而当我们首次渲染的时候呢?此时也要将事件函数绑定到dom节点上,此时只需要修改一点点代码
js
function commitWork(fiber) {
// ...其他代码
//--------------只改这一点点------------------
// (1)如果是替换,则替换老的DOM节点
if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
parentDOM.appendChild(fiber.dom)
updateDOM(fiber.dom, {}, fiber.props) //👈新增:新创建的节点,也需要注册事件监听器
}
// -------------------------------------------
// ...其他代码
}
5.5见证奇迹:
基本核心的逻辑已经写完了,可以测试一下:
- 更新dom的props,看看页面是否更新
- 新增一个dom节点,看看页面便没变化
- 给dom绑定一个事件函数看看是否生效
- 修改刚刚的dom事件函数看看是否生效
- 删除dom看看页面变化




5.6 总结一下:
到这里,我们已经完整实现了 React 最核心的更新机制 ------调和(Reconciliation) ,也就是大家常说的 Diff 算法 (真实 React 还额外用 key 做了列表优化)
回顾完整渲染流程:
-
render 初始化 :调用
render()生成新的虚拟 DOM,创建wipRoot,启动渲染任务。 -
Concurrent Mode :
workLoop借助requestIdleCallback,在浏览器空闲时逐个执行 Fiber 工作单元,实现可中断渲染。 -
Fiber 构建与 Diff 调和 :
performUnitOfWork调用reconcileChildren,同层对比新旧 Fiber 树:-
类型相同 → 复用 DOM,打
UPDATE标签 -
新节点存在、类型不同 → 新建 Fiber,打
PLACEMENT标签 -
旧节点存在、类型不同 → 标记
DELETION,收集待删除节点
-
-
Render 阶段结束 :整棵新 Fiber 树构建完成,
nextUnitOfWork为空,进入 Commit 阶段。 -
Commit 阶段批量更新 DOM :
commitRoot统一执行所有 DOM 操作:-
先处理
DELETION:从 DOM 中移除废弃节点 -
再处理子节点:
PLACEMENT执行appendChild,UPDATE调用updateDom同步属性 / 事件
-
-
收尾重置 :将
currentRoot更新为本次渲染的wipRoot,清空wipRoot,等待下一次渲染。
九、Function Components
函数组件是 React 核心特性之一,它和普通 DOM 元素的核心差异在于:
- 函数组件的 Fiber 节点没有对应的 DOM 节点(因为函数组件本身只是逻辑容器,不是真实 DOM 节点);
- 函数组件的 children 不是直接从 props 读取,而是执行函数的返回值。

我们将基于 Pombo 的思路,改造 MiniReact 以支持函数组件:
We check if the fiber type is a function, and depending on that we go to a different update function.
In
updateHostComponentwe do the same as before.
- 更改
main.js
js
import { createElement, render } from "./MiniReact";
function APP(props) {
return createElement(
"h1",
null,
"Hi ",
props.name // 这个实际上是 <h1> 的 child
)
}
const container = document.querySelector('#root');
/**
* 这里跟着 pomb 做,他将这个函数直接传递给了 createElement(type,props,...children)
* 这就意味着,后续的这个fiber节点的type
* 1. fiber.type就直接为 APP 函数
* 2. type都为函数了,那么我们就不能根据type来创造dom了,因此这个额fiber也没有dom节点
*/
const element = createElement(APP, { name: 'foo' })
render(element, container);
- render阶段构造Fiber时区分函数组件和原生组件:
函数组件有两个关键不同:
- 没有对应的 DOM 节点 ------ 它的 Fiber 上
dom为null。 children来自执行函数 ,而不是直接读取props.children。
在 performUnitOfWork 中,我们根据 fiber.type 是否为函数来分流:
js
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function // 是否是函数组件
// ①:函数组件
if (isFunctionComponent) updateFunctionComponent(fiber)
// ②:普通的元素节点
else updateHostComponent(fiber)
// ... 其他代码不变
// 3. 返回下一个工作单元(fiber) ...
}
实现updateFunctionComponent和updateHostComponent
js
// (1) updateHostComponent
function updateHostComponent(fiber) {
/**
* 原生组件保持原样
* 1.创建DOM并记录到当前fiber节点中
* 2.创建子级Fiber以及建立关系
*/
if (!fiber.dom) fiber.dom = createDOM(fiber)
const elements = fiber.props.children
reconcileChildren(fiber, elements)
}
// (2) updateFunctionComponent
function updateFunctionComponent(fiber) {
// 运行函数获取 children,然后调用 reconcileChildren
const functionComponent = fiber.type
const children = [functionComponent(fiber.props)]
reconcileChildren(fiber, children)
}
- commit阶段处理无 DOM 的 Fiber:
commitWork 之前假设每个 Fiber 都有 dom 和 parent.dom。现在函数组件的 Fiber 没有 DOM,需要调整:
- 找父 DOM 节点 :向上遍历,直到找到带有
dom的 Fiber。 - 删除节点 :向下遍历,找到真正有
dom的子 Fiber 来移除。
js
// 从 fiber 开始提交
function commitWork(fiber) {
if (!fiber) return;
// 👈函数式组件的fiber没有dom节点,需要一直往上找直到找到带有dom的fiber
let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const parentDOM = domParentFiber.dom;
// -------------------根据effectTag做不同的DOM操作-------------------
// (1)如果是替换,则替换老的DOM节点
if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
parentDOM.appendChild(fiber.dom)
// 对于新创建的节点,也需要注册事件监听器
updateDOM(fiber.dom, {}, fiber.props)
}
// (2)如果是删除,则从DOM中删除
else if (fiber.effectTag === 'DELETION' && fiber.dom) {
commitDeletion(fiber, parentDOM); // 👈删除的是dom,针对函数组件没有dom,需要单独处理
}
// (3)如果是更新,则单独处理
else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
updateDOM(fiber.dom, fiber.alternate.props, fiber.props)
}
// ----------------------------operation end--------------------------
// 递归 child 和 sibling
commitWork(fiber.child)
commitWork(fiber.sibling)
}
删除逻辑单独抽出来,递归找到实际 DOM 节点:
js
function commitDeletion(fiber, parentDOM) {
// 如果是函数组件fiber没有dom,需要一直往上找直到找到带有dom的fiber
if (fiber.dom) {
parentDOM.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, parentDOM)
}
}
看看结果:

尝试更多的可能:
js
import { createElement, render } from "./MiniReact";
// Clock时钟组件 - 接收时间作为参数
function Clock(props) {
const title = "当前时间: ";
return createElement(
"div",
{ style: "border: 1px solid blue; padding: 10px; margin: 10px;" },
title,
props.time
);
}
// APP组件
function APP(props) {
return createElement(
"div",
{ style: "font-family: Arial; padding: 20px;" },
createElement("h1", null, "MiniReact Demo"),
createElement("p", null, "Hello, ", props.name),
createElement(Clock, { time: props.currentTime })
);
}
// 渲染应用并实现实时更新
function renderApp() {
const currentTime = new Date().toLocaleTimeString();
const element = createElement(APP, {
name: 'World',
currentTime: currentTime
});
const container = document.querySelector('#root');
render(element, container);
}
renderApp(); // 首次渲染
setInterval(renderApp, 1000); // 每秒更新一次时间

小结
- 函数组件在 Fiber 树中存在,在 DOM 树中不存在。这是必须单独处理的根本原因。
- Render 阶段:函数组件不创建 DOM,children 来自执行函数。
- Commit 阶段:操作 DOM 时要跳过函数组件,向上找到真正的宿主节点;删除时要向下找到真正的 DOM 节点。
- 这些处理逻辑是 React Fiber 架构能支持函数组件的关键设计。
理解了这一点,你就能明白为什么 React 函数组件不能直接挂载 ref(需要 forwardRef),以及为什么组件卸载时的清理函数能正确执行------因为清理函数也是沿着这个逻辑找到真实 DOM 后触发的。
十、 Hooks
在支持函数组件后,我们终于来到最后一步:实现 React 最核心的 Hooks ------ useState。这也是理解 React 状态管理底层逻辑的关键。
- 为什么函数组件需要 Hooks?
函数组件本身只是一个普通函数,每次渲染都会重新执行。普通变量在函数执行完就消失了,无法保存状态。那 React 是怎么记住 count 的呢?
答案 :状态存储在 Fiber 节点 上。每个函数组件的 Fiber 节点会挂一个 hooks 数组,数组里每个元素对应一个 useState 调用。每次组件重新执行时,useState 从对应的老 Fiber 上读取上次的状态,然后返回给组件。这样,虽然函数重新执行了,但状态被"记忆"在了 Fiber 上。
- hooks在Fiber上长啥样?

-
Hooks 如何配合 Fiber 实现更新?
-
首次渲染 :执行
Counter(),遇到useState(0),发现当前 Fiber 上没有hooks数组,就新建一个hook对象,state设为初始值0,queue为空。然后返回[0, setCount]。 -
用户点击按钮 :调用
setCount(c => c+1),这个setCount会把(c) => c+1这个动作推入当前hook.queue中,然后触发重新渲染 (创建一个新的wipRoot,启动workLoop)。 -
重新渲染 :再次执行
Counter(),此时useState会拿到旧的hook(通过alternate.hooks[hookIndex]),然后依次执行queue中的所有动作,计算出新的state,并清空queue。最后返回新状态和同一个setCount。
这就是 Hooks 的秘密 :状态并不存储在闭包变量里,而是存储在 Fiber 节点上。每次渲染都是重新执行函数,但通过 alternate 保留了上一次的状态,并通过 queue 实现了批量更新。
Pombo 在原教程中,通过以下核心思路实现 useState:
- 为函数组件的 Fiber 节点新增
hooks数组,存储多次调用useState的状态; - 用全局变量追踪「当前工作中的 Fiber」和「当前 hook 索引」;
- 状态更新时,将更新动作存入队列,触发新的渲染流程;
- 重新渲染时,批量执行更新队列中的动作,更新状态并同步到视图。
1. 修改示例
js
// 将 main.js 改为经典的计数器:
import { createElement, render } from "./MiniReact";
import { useState } from "./MiniReact/render"; // 后续将useState写到render中
function Counter({ name }) {
const [count, setCount] = useState(0);
return (
createElement(
"div",
{ style: "display:flex;gap:5px;background:pink; padding:5px" },
createElement("div", null, 'counter组件:'),
createElement("button", { onClick: () => setCount(pre => pre + 1) }, "增加"),
createElement("p", null, `${name}:${count}`),
createElement("button", { onClick: () => setCount(pre => pre - 1) }, "减少")
)
)
}
const container = document.querySelector('#root');
const element = createElement(Counter, { name: 'Counter组件' })
render(element, container);
2 在函数组件中支持 Hooks
Hooks 的实现依赖 当前正在渲染的函数组件 Fiber 。我们需要在 updateFunctionComponent 中记录这个 Fiber,并为它初始化一个 hooks 数组,以及当前 hook 的索引。
js
// 在render.js的全局变量中:
// -------------------(1)全局引用-------------------------
let nextUnitOfWork = null // 下一个要执行的工作单元
let workInProgressRoot = null // 工作中的根节点
let currentRoot = null // 前一个完成的root
let deletion = [] // 记录要删除的fiber节点
let wipFiber = null; // 👈新增:当前正在工作的函数组件 Fiber
let hookIndex = null; // 👈新增:当前 hook 在 hooks 数组中的索引
// ---------------------------------------------------------
js
// 改写 updateFunctionComponent:
function updateFunctionComponent(fiber) {
wipFiber = fiber; // 当前正在处理的函数组件fiber
hookIndex = 0; // 从hooks链表的 0 索引开始
wipFiber.hooks = []; // 存储该组件所有的 hook 对象
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
实现 useState 的第一步:读取/创建当前 hook
现在我们来写 useState 函数。它要做什么?
-
从 旧的 Fiber(fiber.alternate) 上找到对应的旧 hook(如果有)。
-
创建一个新的 hook 对象,包含
state和queue。 -
将这个新 hook 放入当前 Fiber 的
hooks数组,并递增索引。 -
返回
[state, setState]。
js
// 将useState写到render.js中,并导出,因为useState用到了render.js中的全局彼岸两
export function useState(initialState) {
// 1. 尝试从旧的 Fiber(alternate)中获取同位置的 hook
const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
// 2. 创建新的 hook 对象
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [] // 暂留,后续用来存放更新动作
};
// 3. 存入当前 Fiber 的 hooks 数组
wipFiber.hooks.push(hook);
hookIndex++;
// 4. 返回状态(暂时没有 setState)
return [hook.state];
}
export { useState };
此时,如果我们在组件里写
const [count, setCount] = useState(0),count就能拿到旧状态(首次为 0)。但点击按钮调用setCount还不会做任何事情。
实现 setState:推入更新队列 + 触发重新渲染
setState 不应该直接修改 hook.state(因为可能有多次更新需要合并),而是把"更新动作"存入 hook.queue。然后,我们需要启动一次新的渲染,让整个 MiniReact 重新执行,并在重新执行时应用这些更新。
如何启动新的渲染?回忆一下,我们的 render 函数创建了一个 workInProgressRoot 并设置了 nextUnitOfWork,然后 workLoop 就会开始工作。在 setState 中,我们也要做类似的事情:基于当前的 currentRoot 创建一个新的 workInProgressRoot,并让它成为下一个工作单元。
js
// useState中,创建setState函数
const setState = (action) => {
// 将action存入到 hook的queue中
hook.queue.push(action);
// 跟render类似,触发重新渲染
wipFiber = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipFiber;
deletions = []
}
现在,当 setState 被调用后,会创建一个新的工作单元,workLoop 会重新开始整个 Fiber 树的构建。当再次执行到同一个函数组件时,useState 会重新执行,此时我们需要把旧 hook 的 queue 里的所有 action 依次应用到新 hook 的 state 上。
修改 useState 中创建 hook 的部分,在拿到 oldHook 之后,如果有旧的 queue,就遍历执行它们:
js
// 重点:如果有待执行的更新,就依次应用到 state 上
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
// 如果 action 是函数,就调用它并传入旧状态;否则直接使用 action 作为新值
if (typeof action === 'function') {
hook.state = action(hook.state);
} else {
hook.state = action;
}
});
- 为什么需要
queue而不直接修改 state?
考虑一个场景:在同一个事件循环中,连续调用三次 setCount(c => c + 1)。如果不使用队列,可能只有最后一次生效,或者会触发三次渲染。使用队列后:
- 第一次
setCount将c => c+1推入队列,调度渲染。 - 第二次、第三次也把动作推入同一个队列。
- 当重新渲染执行组件时,
useState会依次执行队列中的三个函数,0 -> 1 -> 2 -> 3,最终 state 变成 3,然后清空队列。 - 只触发了一次渲染,性能更好,且结果正确。
这就是 React 中 批量更新 的简化实现。
- useState完整代码:
js
// render.js中
export function useState(initialState) {
// 1. 尝试从旧的 Fiber(alternate)中获取同位置的 hook
const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
// 2. 创建新的 hook 对象
const hook = {
state: oldHook ? oldHook.state : initialState,
queue: []
};
// 3. 如果有待执行的更新,就依次应用到 state 上
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
// 如果 action 是函数,就调用它并传入旧状态;否则直接使用 action 作为新值
if (typeof action === 'function') {
hook.state = action(hook.state);
} else {
hook.state = action;
}
});
// 4. 创建setState函数
const setState = (action) => {
// 将action存入到 hook的queue中
hook.queue.push(action);
// 跟render类似,触发重新渲染
workInProgressRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = workInProgressRoot;
deletion = []
}
// 5. 存入当前 Fiber 的 hooks 数组
wipFiber.hooks.push(hook);
hookIndex++;
// 6. 返回状态(暂时没有 setState)
return [hook.state, setState];
}
- 检验效果:

这就是 Hooks 最核心的机制。虽然我们的实现忽略了优先级调度、批量合并的优化,但它完全体现了 React Hooks 的设计思想:状态存储在 Fiber 上,通过队列实现更新,用调度器触发重渲染。
十一、支持JSX语法
在前面的实现过程中,我们一直手动调用 MiniReact.createElement() 来创建虚拟 DOM 对象,比如这样:
js
const element = MiniReact.createElement(
'h1',
{ id: 'title', style: "background:red" },
'Hello World',
MiniReact.createElement('div', { id: 'name' }, '---MiniReact')
)
之所以不直接用更简洁的 JSX 语法,核心目的是让大家彻底理解 JSX 的本质 :JSX并不是什么新的设计,本身也并不能被浏览器识别,它只是 React.createElement() 的「语法糖」------ 所有写的 JSX 代码,最终都会被 Babel/ESBuild 等编译工具转译为 createElement 函数调用。
js
const element = (
<h1 id="title" style="background:red">
Hello World
<div id="name">---MiniReact</div>
</h1>
);
经过编译后,会被转换成和我们手动调用 MiniReact.createElement() 完全一致的代码。前面手动写 createElement,是为了跳过「语法糖」的包装,直接看到虚拟 DOM 构建的底层逻辑;当我们理解了核心原理后,就可以配置工具,让项目支持 JSX 语法,回归更简洁的开发体验。
- 项目根目录下新建
vite.config.js文件:
js
import { defineConfig } from 'vite'
export default defineConfig({
esbuild: {
// 开启 JSX 转换模式
jsx: 'transform',
// 指定 JSX 编译后的工厂函数(对应我们的 createElement)
jsxFactory: 'createElement',
// 自动注入 createElement 导入,避免每个文件手动 import
jsxInject: `import { createElement } from './MiniReact/createElement.js'`
},
})
- 修改文件后缀并使用 JSX(Vite 会识别
.jsx后缀文件并触发 JSX 编译)
js
// main.jsx
import { render } from "./MiniReact";
import { useState } from "./MiniReact/render";
const Counter = ({ theme }) => {
const [count, setCount] = useState(0);
return (
<div
style={`
display:flex;
gap:5px;
background:${theme};
padding:10px;
border-radius: 8px;
margin: 10px;
transition: background 1s ease;`
}
>
<div>counter组件:</div>
<button onClick={() => setCount(pre => pre + 1)}>增加</button>
<p>count:{count}</p>
<button onClick={() => setCount(pre => pre - 1)}>减少</button>
</div>
);
}
const APP = () => {
const [color, setColor] = useState('pink');
const changeColor = () => setColor(pre => pre === 'pink' ? 'lightblue' : 'pink')
return (
<div
style={`
background:${color === 'pink' ? 'skyblue' : 'pink'};
padding: 10px;
border-radius: 8px;
margin: 10px;
transition: background 0.8s ease`
}
>
APP组件
<Counter theme={color} />
<button onClick={changeColor}>切换主题</button>
</div>
)
}
const container = document.querySelector("#root");
render(<APP />, container);
注:我们现在的MiniReact,没有对style属性进行处理,正常的
JSX它的style是一个对象格式,我们暂时保留字符串格式
- 官方:style={{ background-color: ${color}; padding: 10px }}
- 我们:style={`background-color: ${color}; padding: 10px`}
十二、后记
写到这里,我们从零实现的 MiniReact 已经跑完了 React 最核心的全程:从 JSX 到虚拟 DOM、从 Fiber 架构到两阶段渲染,再到调和机制、函数组件与 useState,整个骨架已经清晰可见。
整个过程,我们始终遵循 Rodrigo Pombo 在 Build your own React 中展示的思路------用不到 300 行核心代码,理解React 最核心的设计思想。
当然,我们的 MiniReact 只是「骨架版」,真实 React 还做了大量工业级的优化和拓展,比如:
- 渲染阶段,React 会通过启发式规则跳过「确定无变更」的子树,而不是遍历整棵树;
- 提交阶段,React 维护了「仅包含有副作用的 Fiber」的链表,而非递归遍历所有节点;
- React 会复用旧 Fiber 树的节点,而非每次都创建新对象;
- React 给更新打上「过期时间戳」实现优先级调度,而非简单丢弃未完成的工作;
- 还有合成事件、并发渲染、Suspense、服务端渲染...... 这些都是 React 庞大生态的一部分。
但这恰恰是「极简实现」的价值:我们剥离了所有非核心的优化和兼容逻辑,只留下 React 最本质的骨架 ------ 虚拟 DOM 转 Fiber、可中断的工作循环、基于 Diff 的调和机制、函数组件与 Hooks 的闭包本质。
最后,再次由衷感谢 Rodrigo Pombo,这篇博客完全站在他的肩膀上,只是做了更贴合中文语境的拆解、补充和验证。
