一、项目目标
本项目从零开始实现一个极简版的 React,只保留最核心的机制:
-
虚拟 DOM(vnode)
-
createElement (手写
h函数) -
函数组件 (无状态组件,接收
props返回 vnode) -
useState(简易版状态管理)
-
render(递归渲染,状态变化后全量重渲染)
通过这份代码,可以深入理解 React 底层是如何工作的,而不被复杂的调度、diff 等细节干扰。
二、核心概念速览
| 概念 | 作用 | 在本项目中的实现 |
|---|---|---|
| 虚拟节点 (vnode) | 描述 UI 的普通 JS 对象,包含 type 和 props(含 children)。 |
createElement 返回此类对象。 |
| createElement | 将 JSX 编译结果转换为 vnode。 | 手写 h(type, props, ...children),处理文本节点。 |
| 函数组件 | 一个接收 props 并返回 vnode 的函数。 |
buildDomTree 中判断 typeof vnode.type === "function" 时执行。 |
| useState | 让函数组件拥有内部状态,状态改变时触发重渲染。 | 通过全局 hooks 数组 + 调用顺序索引保存状态。 |
| render | 将 vnode 挂载到真实 DOM 容器中。 | buildDomTree 递归创建 DOM,并监听状态变化触发全局重渲染。 |
三、代码结构
项目主要包含三个文件:
text
mini-react/
index.js # Mini React 核心实现
App.js # 示例应用组件
main.js # 入口:渲染 App 到 #root
1. index.js -- Mini React 核心
| 函数 / 变量 | 作用 |
|---|---|
createTextElement |
为文本内容创建特殊的 vnode(type: "TEXT_ELEMENT")。 |
createElement |
生产 vnode,将 children 扁平化、过滤无效值,文本节点转为 TEXT_ELEMENT。 |
createDomNode |
根据 vnode 创建真实 DOM 节点(文本节点或元素)。 |
updateDomProperties |
将 vnode.props 同步到真实 DOM,处理事件(onXxx)和普通属性。 |
isEvent / isProperty |
辅助函数,区分 props 中的事件和普通属性。 |
hooks / hookIndex |
存储所有组件状态的全局数组,以及当前渲染的 hooks 索引。 |
render |
重置索引,清空容器,调用 buildDomTree 创建 DOM 并挂载。 |
buildDomTree |
递归构建真实 DOM:若 vnode.type 是函数,则执行组件函数,得到新 vnode,继续构建;否则创建 DOM 并处理 children。 |
useState |
返回当前状态和更新函数,更新时重新执行 render。 |
2. App.js -- 示例应用
演示了:
-
函数组件
Card、Counter -
多个
useState的使用 -
事件绑定(
onClick、onInput) -
条件渲染
3. main.js -- 入口
javascript
import MiniReact from './mini-react/index.js';
import { App } from './App.js';
const container = document.getElementById('root');
MiniReact.render(MiniReact.createElement(App), container);
四、关键函数解析
1. createElement 与 createTextElement
javascript
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children
.flat()
.filter(child => child != null && child !== false)
.map(child => (typeof child === 'object' ? child : createTextElement(child))),
},
};
}
children 扁平化 :...children 可能包含数组(如嵌套的 JSX 元素),用 flat() 展平。
-
过滤 :删除
null、undefined、false(常见条件渲染技巧)。 -
文本节点 :如果不是对象,则视为文本,调用
createTextElement生成{type: "TEXT_ELEMENT", props: {nodeValue: String(child), children: []}}。
2. buildDomTree -- 组件与 DOM 的桥梁
javascript
function buildDomTree(vnode) {
// 函数组件
if (typeof vnode.type === "function") {
const componentVNode = vnode.type({
...(vnode.props || {}),
children: vnode.props?.children || [],
});
return buildDomTree(componentVNode);
}
// 普通节点 / 文本节点
const dom = createDomNode(vnode);
const children = vnode.props?.children || [];
children.forEach(child => dom.appendChild(buildDomTree(child)));
return dom;
}
-
函数组件:直接调用组件函数,传入
props和children,得到新的 vnode,然后递归构建。 -
普通节点:创建 DOM 元素,递归处理 children。
3. useState 实现
javascript
let hooks = [];
let hookIndex = 0;
function useState(initialValue) {
const currentIndex = hookIndex;
const currentValue = hooks[currentIndex] !== undefined ? hooks[currentIndex] : initialValue;
function setState(nextValue) {
const valueToStore = typeof nextValue === 'function' ? nextValue(hooks[currentIndex]) : nextValue;
hooks[currentIndex] = valueToStore;
render(rootVNode, rootContainer); // 触发重渲染
}
hooks[currentIndex] = currentValue;
hookIndex += 1;
return [currentValue, setState];
}
-
原理 :利用闭包 和全局数组 存储每个组件的状态。每次调用
useState时,hookIndex递增,保证每个状态有唯一索引。 -
更新 :调用
setState时,更新hooks对应索引的值,然后重新执行render,从头构建整棵树。在重渲染过程中,hookIndex从 0 开始,但hooks数组保留了上一次的值,所以状态得以恢复。 -
注意 :
render会清空容器并重新调用buildDomTree,这会导致整个应用重新渲染。真实 React 不会这样粗暴,而是通过 diff 只更新变化的部分。
4. 事件处理
在 updateDomProperties 中:
-
移除旧事件:遍历
prevProps中onXxx属性,调用removeEventListener。 -
添加新事件:遍历
nextProps中onXxx属性,调用addEventListener。
事件名转换:onClick → click(toLowerCase().slice(2))。
五、运行与调试
1. 环境要求
-
现代浏览器(支持 ES6+)
-
一个简单的 HTML 文件,包含
<div id="root"></div>和<script type="module">引入main.js
2. 运行方式
-
可以使用任何静态服务器(如
live-server)打开项目。 -
控制台可以看到
console.log输出,帮助追踪虚拟节点和状态变化。
3. 调试技巧
-
在
createElement中打印vnode,观察 children 结构。 -
在
useState中打印hookIndex和hooks数组,理解状态如何存储。 -
在
buildDomTree中打印,查看组件调用顺序。
六、扩展思考
-
性能优化
当前实现是状态变化后全量重绘,真实 React 通过 Fiber 和 diff 算法只更新变化的部分。你可以尝试加入简单的 key 机制和 diff 算法。
-
多个组件实例的状态隔离
目前
hooks是全局的,但通过buildDomTree的执行顺序,不同组件实例的状态会按调用顺序存储。如果组件内部有多个useState,依赖顺序正确即可正常工作。 -
副作用(useEffect)
可以尝试增加
useEffect,在状态更新后执行副作用。 -
Context
可以模拟 Context API,通过
Provider和Consumer传递数据。 -
错误边界
可以添加
try-catch包裹组件执行,捕获错误并显示回退 UI。
七、总结
这个 Mini React 实现虽然只有一百多行代码,却涵盖了 React 最核心的设计思想:
-
声明式 UI:通过 vnode 描述 UI。
-
组件化:函数组件接收 props 返回 UI。
-
状态管理:useState 让函数组件拥有内部状态。
-
单向数据流:状态变化导致重新渲染,产生新的 vnode。
javascript
//main.js
import MiniReact from './mini-react/index.js';
import { App } from './App.js';
const container = document.getElementById('root');
MiniReact.render(MiniReact.createElement(App), container);
//mini-react
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: String(text),
children: [],
},
};
}
function createElement(type, props, ...children) {
console.log("vnode:", { type, props, children });
return {
type,
props: {
...(props || {}),
children: children
.flat()
.filter(
(child) => child !== null && child !== undefined && child !== false,
)
.map((child) =>
typeof child === "object" ? child : createTextElement(child),
),
},
};
}
function createDomNode(vnode) {
const dom =
vnode.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(vnode.type);
updateDomProperties(dom, {}, vnode.props);
return dom;
}
/* 把 vnode 里的 props 同步到真实 DOM 上 */
function updateDomProperties(dom, prevProps, nextProps) {
/* 移除旧事件 */
Object.keys(prevProps)
.filter(isEvent)
.forEach((name) => {
// { onClick: handleClick }==>dom.removeEventListener('click', handleClick)
const eventType = name.toLowerCase().slice(2);
dom.removeEventListener(eventType, prevProps[name]);
});
Object.keys(prevProps)
.filter(isProperty)
.forEach((name) => {
if (!(name in nextProps)) {
dom[name] = "";
}
});
/* 添加 */
Object.keys(nextProps)
.filter(isProperty)
.forEach((name) => {
dom[name] = nextProps[name];
});
Object.keys(nextProps)
.filter(isEvent)
.forEach((name) => {
const eventType = name.toLowerCase().slice(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
function isEvent(key) {
return key.startsWith("on");
}
// 意思是普通属性要排除两类:
// children
// 事件属性
// 因为:
// children 是递归渲染用的,不该直接挂到 DOM 上
// 事件要用 addEventListener 处理,不是简单赋值
function isProperty(key) {
return key !== "children" && !isEvent(key);
}
let rootVNode = null;
let rootContainer = null;
let hooks = [];
let hookIndex = 0;
function render(vnode, container) {
rootVNode = vnode;
rootContainer = container;
hookIndex = 0;
container.innerHTML = "";
container.appendChild(buildDomTree(vnode));
}
function buildDomTree(vnode) {
if (typeof vnode.type === "function") {
const componentVNode = vnode.type({
...(vnode.props || {}),
children: vnode.props?.children || [],
});
return buildDomTree(componentVNode);
}
const dom = createDomNode(vnode);
const children = vnode.props?.children || [];
children.forEach((child) => {
dom.appendChild(buildDomTree(child));
});
return dom;
}
function useState(initialValue) {
console.log("useState call", {
hookIndex,
existingValue: hooks[hookIndex],
initialValue,
});
const currentIndex = hookIndex;
const currentValue =
hooks[currentIndex] !== undefined ? hooks[currentIndex] : initialValue;
function setState(nextValue) {
const valueToStore =
typeof nextValue === "function"
? nextValue(hooks[currentIndex])
: nextValue;
hooks[currentIndex] = valueToStore;
render(rootVNode, rootContainer);
}
hooks[currentIndex] = currentValue;
hookIndex += 1;
return [currentValue, setState];
}
const MiniReact = {
createElement,
render,
useState,
};
export default MiniReact;
//App.js
import MiniReact from './mini-react/index.js';
const h = MiniReact.createElement;
function Card({ title, children }) {
return h(
'section',
{ className: 'card' },
h('h2', null, title),
h('div', { className: 'card-content' }, ...children)
);
}
function Counter({ label, step = 1 }) {
const [count, setCount] = MiniReact.useState(0);
return h(
'div',
{ className: 'counter' },
h('p', { className: 'counter-label' }, label),
h('p', { className: 'counter-value' }, `当前值: ${count}`),
h(
'div',
{ className: 'counter-actions' },
h(
'button',
{ onClick: () => setCount((value) => value - step) },
`-${step}`
),
h(
'button',
{ onClick: () => setCount((value) => value + step) },
`+${step}`
)
)
);
}
export function App() {
const [name, setName] = MiniReact.useState('Mini React');
const [showTips, setShowTips] = MiniReact.useState(true);
return h(
'main',
{ className: 'page' },
h(
'header',
{ className: 'hero' },
h('p', { className: 'eyebrow' }, '从 0 到 1 手写一个简易版 React'),
h('h1', null, 'Mini React Learning Lab'),
h(
'p',
{ className: 'hero-copy' },
'这个示例只保留最核心的思想:虚拟节点、函数组件、状态和重新渲染。你可以一边改代码,一边观察页面变化。'
)
),
h(
Card,
{ title: '1. createElement 做了什么?' },
h(
'p',
null,
'JSX 编译后的本质就是 createElement 调用。这里我们手写 h(type, props, ...children) 来生成虚拟节点。'
),
h('p', null, '你现在看到的整棵界面,本质上就是一棵由普通 JavaScript 对象组成的 vnode 树。')
),
h(
Card,
{ title: '2. render 做了什么?' },
h(
'p',
null,
'render 会递归遍历 vnode,把它们转换成真实 DOM,并挂载到 #root 上。'
),
h('p', null, '教学版里我们采用最容易理解的策略:状态变化后整棵树重新渲染。真实 React 会更精细。')
),
h(
Card,
{ title: '3. useState 为什么能记住状态?' },
h(
'p',
null,
'我们用一个 hooks 数组保存状态,再用 hookIndex 记录当前执行到第几个 hook。每次组件重新执行时,靠调用顺序把旧状态取回来。'
),
h(Counter, { label: '基础计数器', step: 1 }),
h(Counter, { label: '步长为 2 的计数器', step: 2 })
),
h(
Card,
{ title: '4. 受控输入示例' },
h('label', { className: 'field-label', htmlFor: 'name-input' }, '输入一个名字:'),
h('input', {
id: 'name-input',
className: 'text-input',
value: name,
onInput: (event) => setName(event.target.value),
placeholder: '输入后会触发 setState'
}),
h('p', { className: 'preview' }, `你好,${name}。这说明状态变化后,函数组件会重新执行。`)
),
h(
Card,
{ title: '5. 条件渲染示例' },
h(
'button',
{ className: 'ghost-button', onClick: () => setShowTips((value) => !value) },
showTips ? '隐藏提示' : '显示提示'
),
showTips
? h(
'ul',
{ className: 'tips' },
h('li', null, '函数组件本质上就是:输入 props,输出 UI 描述。'),
h('li', null, '状态更新后,组件会再次执行,生成新的 UI 描述。'),
h('li', null, '真实 React 还会做 diff、Fiber 调度、批量更新等优化。')
)
: h('p', { className: 'preview' }, '提示已隐藏,但状态仍然保存在 hooks 数组中。')
)
);
}