100行实现Mini React

一、项目目标

本项目从零开始实现一个极简版的 React,只保留最核心的机制:

  • 虚拟 DOM(vnode)

  • createElement (手写 h 函数)

  • 函数组件 (无状态组件,接收 props 返回 vnode)

  • useState(简易版状态管理)

  • render(递归渲染,状态变化后全量重渲染)

通过这份代码,可以深入理解 React 底层是如何工作的,而不被复杂的调度、diff 等细节干扰。

二、核心概念速览

概念 作用 在本项目中的实现
虚拟节点 (vnode) 描述 UI 的普通 JS 对象,包含 typeprops(含 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 -- 示例应用

演示了:

  • 函数组件 CardCounter

  • 多个 useState 的使用

  • 事件绑定(onClickonInput

  • 条件渲染

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. createElementcreateTextElement

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() 展平。

  • 过滤 :删除 nullundefinedfalse(常见条件渲染技巧)。

  • 文本节点 :如果不是对象,则视为文本,调用 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;
}
  • 函数组件:直接调用组件函数,传入 propschildren,得到新的 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 中:

  • 移除旧事件:遍历 prevPropsonXxx 属性,调用 removeEventListener

  • 添加新事件:遍历 nextPropsonXxx 属性,调用 addEventListener

事件名转换:onClickclicktoLowerCase().slice(2))。


五、运行与调试

1. 环境要求

  • 现代浏览器(支持 ES6+)

  • 一个简单的 HTML 文件,包含 <div id="root"></div><script type="module"> 引入 main.js

2. 运行方式

  • 可以使用任何静态服务器(如 live-server)打开项目。

  • 控制台可以看到 console.log 输出,帮助追踪虚拟节点和状态变化。

3. 调试技巧

  • createElement 中打印 vnode,观察 children 结构。

  • useState 中打印 hookIndexhooks 数组,理解状态如何存储。

  • buildDomTree 中打印,查看组件调用顺序。


六、扩展思考

  1. 性能优化

    当前实现是状态变化后全量重绘,真实 React 通过 Fiber 和 diff 算法只更新变化的部分。你可以尝试加入简单的 key 机制和 diff 算法。

  2. 多个组件实例的状态隔离

    目前 hooks 是全局的,但通过 buildDomTree 的执行顺序,不同组件实例的状态会按调用顺序存储。如果组件内部有多个 useState,依赖顺序正确即可正常工作。

  3. 副作用(useEffect)

    可以尝试增加 useEffect,在状态更新后执行副作用。

  4. Context

    可以模拟 Context API,通过 ProviderConsumer 传递数据。

  5. 错误边界

    可以添加 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 数组中。')
    )
  );
}
相关推荐
恋猫de小郭2 小时前
2026 AI 时代下,Flutter 和 Dart 的机遇和未来发展,AI 一体化
android·前端·flutter
多行不易2 小时前
JavaScript与Sonic前端交互:构建可视化数字人生成界面
javascript·数字人·viewui·sonic
1314lay_10072 小时前
Element Plus左侧侧边栏按照屏幕宽度来确定显示和隐藏,如果太小的话,侧边栏消失,菜单会变成一个小按钮,点击按钮以模态框弹出
javascript·vue.js·elementui
看客随心3 小时前
vue + elementPlus大屏项目使用autofit做适配及注意点
前端·javascript·vue.js
网络点点滴3 小时前
Vue3 全局API转移到应用对象
前端·javascript·vue.js
波哥学开发3 小时前
基于 OPFS 的前端缓存实践:图片与点云数据的本地持久化
前端
whuhewei3 小时前
useCountDown (React Hooks)倒计时
前端·javascript·react.js
DanCheOo3 小时前
流式输出:让 AI 回复像 ChatGPT 一样打字机效果
前端·全栈