从零实现一个 Mini React:Virtual DOM、Fiber 架构与 Hooks 核心原理

用 500 行 TypeScript 从零实现一个可运行的 Mini React,深入解析 Virtual DOM、Fiber 协调器、useState 和 useEffect 的底层原理,附完整代码和逐行注释。

前端开发 2026-06-05 18 分钟

2026 年,React 的 npm 周下载量超过 2800 万,但绝大多数开发者停留在「会用」层面——Fiber 是什么?useState 为什么不能放在条件语句里?useEffect 的依赖数组到底是怎么比较的?这些问题的答案都藏在 React 的实现细节里。最好的学习方式不是读源码,而是自己动手写一个。 本文用不到 500 行 TypeScript 代码,从零实现一个包含 Virtual DOM、Fiber 协调器和 Hooks 系统的 Mini React,每一行代码都有详细注释。

💡 提示: 本文的目标不是复制 React 的全部功能,而是通过最小可运行实现理解核心原理。完成本文后,你再读 React 源码会发现一切都变得清晰了。

🧱 一、Virtual DOM 与 createElement

1.1 什么是 Virtual DOM?

Virtual DOM(虚拟 DOM)本质上就是一个普通的 JavaScript 对象,用来描述真实 DOM 树的结构。React 之所以用 Virtual DOM 而不是直接操作真实 DOM,核心原因是真实 DOM 操作的成本很高——每次 document.createElementelement.setAttribute 都可能触发浏览器的布局(Layout)、绘制(Paint)和合成(Composite)。

Virtual DOM 的工作流程是:状态变化 → 生成新的 Virtual DOM 树 → Diff 比较新旧树 → 只把差异部分应用到真实 DOM。这样就把多次 DOM 操作合并为最少的必要操作。

1.2 实现 createElement

React 中的 JSX 会被 Babel 编译为 React.createElement 调用。我们先定义 Virtual DOM 节点的类型,然后实现 createElement 函数:

// 类型定义:Virtual DOM 节点
type VNode = {
  type: string | Function;     // 标签名或组件函数
  props: Record<string, any>;  // 属性对象
  key: string | null;          // 列表 key
};

// createElement:将 JSX 转换为 VNode 对象
// JSX <div className="app">hello</div> 会被编译为:
// createElement("div", { className: "app" }, "hello")
function createElement(
  type: string | Function,
  props: Record<string, any> | null,
  ...children: any[]
): VNode {
  return {
    type,
    props: {
      ...(props || {}),
      // children 也是 props 的一部分
      // 如果子节点是文本,包装为特殊的 TEXT 类型
      children: children.flat().map((child) =>
        typeof child === "object" && child !== null
          ? child
          : createTextElement(String(child))
      ),
    },
    key: props?.key ?? null,
  };
}

// 文本节点的 VNode 表示
function createTextElement(text: string): VNode {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
    key: null,
  };
}

⚠️ 警告: 真实的 React 中 createElement 会对 keyref 做特殊处理,并且会冻结 props 对象以防止意外修改。我们的 Mini React 省略了这些细节,聚焦核心逻辑。

1.3 从 VNode 到真实 DOM

有了 VNode,下一步是把它渲染为真实 DOM。这个过程叫做 reconciliation(协调)的初始阶段:

// commit 阶段:将 VNode 渲染为真实 DOM 节点
function createDom(vnode: VNode): Text | HTMLElement {
  // 文本节点
  if (vnode.type === "TEXT_ELEMENT") {
    return document.createTextNode(vnode.props.nodeValue);
  }

  // 元素节点
  const dom =
    vnode.type === "svg"
      ? document.createElementNS("http://www.w3.org/2000/svg", vnode.type as string)
      : document.createElement(vnode.type as string);

  // 设置属性(排除 children)
  updateDom(dom, {}, vnode.props);

  return dom;
}

// 更新 DOM 属性:对比旧 props 和新 props
function updateDom(
  dom: HTMLElement | Text,
  prevProps: Record<string, any>,
  nextProps: Record<string, any>
) {
  const isEvent = (key: string) => key.startsWith("on");
  const isProperty = (key: string) => key !== "children" && !isEvent(key);
  const isNew = (key: string) => prevProps[key] !== nextProps[key];
  const isGone = (key: string) => !(key in nextProps);

  // 移除旧事件监听
  Object.keys(prevProps)
    .filter(isEvent)
    .filter((key) => isGone(key) || isNew(key))
    .forEach((key) => {
      const eventType = key.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[key]);
    });

  // 移除旧属性
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone)
    .forEach((key) => {
      (dom as any)[key] = "";
    });

  // 设置新属性
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew)
    .forEach((key) => {
      (dom as any)[key] = nextProps[key];
    });

  // 添加新事件监听
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew)
    .forEach((key) => {
      const eventType = key.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[key]);
    });
}

到这里,我们已经有了一个能渲染静态页面的「迷你框架」。但它有一个致命缺陷:一旦状态变化,就需要重新渲染整棵 DOM 树。当节点数量达到数千个时,性能会急剧下降。这就是 Fiber 架构要解决的问题。

⚡ 二、Fiber 架构与可中断渲染

2.1 为什么需要 Fiber?

React 15 的协调器是递归的——遍历整棵 VNode 树,一旦开始就不能中断。当组件树很大时,这个过程可能耗时几十毫秒甚至上百毫秒,阻塞主线程,导致用户输入无响应、动画卡顿。

React 16 引入了 Fiber 架构,核心思想是把递归改成循环,把大任务拆成小任务,每个小任务执行完后检查是否需要让出主线程给高优先级任务(比如用户输入、动画)。

2.2 Fiber 数据结构

每个 Fiber 节点是一个链表节点,包含三个指针:

// Fiber 节点:协调器的工作单元
type Fiber = {
  type: string | Function | "TEXT_ELEMENT";
  props: Record<string, any>;
  dom: HTMLElement | Text | null;      // 对应的真实 DOM 节点
  parent: Fiber | null;                // 父 Fiber
  child: Fiber | null;                 // 第一个子 Fiber
  sibling: Fiber | null;               // 下一个兄弟 Fiber
  alternate: Fiber | null;             // 上一次渲染的 Fiber(用于 diff)
  effectTag: "PLACEMENT" | "UPDATE" | "DELETION" | null; // 需要执行的 DOM 操作
  hooks?: any[];                       // 函数组件的 hooks 状态
};

// 全局状态
let nextUnitOfWork: Fiber | null = null;  // 下一个工作单元
let wipRoot: Fiber | null = null;         // 正在构建的 Fiber 树根节点
let currentRoot: Fiber | null = null;     // 上一次提交的 Fiber 树根节点
let deletions: Fiber[] = [];              // 需要删除的 Fiber 节点

Fiber 树的遍历顺序是:child → sibling → parent。具体来说:

  1. 如果有 child,下一个工作单元就是 child
  2. 如果没有 child,就找 sibling
  3. 如果没有 sibling,就沿着 parent 往上找,直到找到有 sibling 的祖先或到达根节点

2.3 实现可中断的协调器

核心是一个 workLoop 函数,每次只处理一个 Fiber 节点,然后通过 requestIdleCallback 让出主线程:

// 用 requestIdleCallback 实现时间切片
// 真实 React 使用 MessageChannel,因为 requestIdleCallback 在浏览器中
// 的调度粒度太粗(最多每秒执行 ~20 次),但用于教学足够了
function workLoop(deadline: IdleDeadline) {
  let shouldYield = false;

  // 有工作单元且浏览器空闲时,继续处理
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    // 检查剩余空闲时间,不足 1ms 时让出主线程
    shouldYield = deadline.timeRemaining() < 1;
  }

  // 所有工作单元处理完毕,一次性提交到 DOM
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }

  requestIdleCallback(workLoop);
}

// 启动渲染
requestIdleCallback(workLoop);

// performUnitOfWork:处理单个 Fiber 节点,返回下一个工作单元
function performUnitOfWork(fiber: Fiber): Fiber | null {
  const isFunctionComponent = typeof fiber.type === "function";

  if (isFunctionComponent) {
    // 函数组件:执行函数获取子 VNode
    updateFunctionComponent(fiber);
  } else {
    // 原生元素:创建 DOM 并处理子节点
    updateHostComponent(fiber);
  }

  // 返回下一个工作单元(child → sibling → parent)
  if (fiber.child) return fiber.child;

  let next: Fiber | undefined = fiber;
  while (next) {
    if (next.sibling) return next.sibling;
    next = next.parent ?? undefined;
  }

  return null;
}

// 处理函数组件
function updateFunctionComponent(fiber: Fiber) {
  // 设置当前正在处理的 Fiber,供 Hooks 使用
  wipFiber = fiber;
  hookIndex = 0;
  fiber.hooks = fiber.hooks || [];

  // 执行函数组件,获取子 VNode
  const children = [(fiber.type as Function)(fiber.props)];
  reconcileChildren(fiber, children);
}

// 处理原生元素组件
function updateHostComponent(fiber: Fiber) {
  // 创建真实 DOM(如果还没有)
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  // 协调子节点
  reconcileChildren(fiber, fiber.props.children);
}

⚠️ 警告: 真实的 React 使用 requestIdleCallback 的替代方案——通过 MessageChannel 实现更精确的调度。React 18 还引入了优先级车道(Lanes)模型,不同类型的状态更新(用户输入、过渡动画、离屏更新)有不同的优先级。

2.4 Reconciliation:Diff 算法

Reconciliation(协调)是 React 的核心算法,负责对比新旧 Virtual DOM 树,找出需要更新的部分。React 的 Diff 算法基于三个假设,将 O(n³) 复杂度降为 O(n):

  1. 不同类型的元素产生不同的树 — 如果 type 变了,直接销毁旧树
  2. 通过 key 标识列表中的元素key 相同的元素复用
  3. 同层比较,不跨层移动 — 不会把一个节点从树的一侧移到另一侧
function reconcileChildren(wipFiber: Fiber, elements: VNode[]) {
  let index = 0;
  let oldFiber = wipFiber.alternate?.child ?? null;
  let prevSibling: Fiber | null = null;

  // 同时遍历新元素和旧 Fiber
  while (index < elements.length || oldFiber !== null) {
    const element = elements[index];
    let newFiber: Fiber | null = null;

    // 判断是否可以复用(类型相同)
    const sameType = oldFiber && element && element.type === oldFiber.type;

    if (sameType) {
      // ✅ 类型相同:更新 props
      newFiber = {
        type: oldFiber!.type,
        props: element.props,
        dom: oldFiber!.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
        child: null,
        sibling: null,
      };
    }

    if (element && !sameType) {
      // ✅ 新元素:创建新 Fiber
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
        child: null,
        sibling: null,
      };
    }

    if (oldFiber && !sameType) {
      // ❌ 旧元素被删除:标记删除
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    // 移动旧 Fiber 指针
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    // 将新 Fiber 挂载到 Fiber 树上
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      prevSibling!.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}

2.5 Commit 阶段:一次性更新 DOM

当所有 Fiber 节点都处理完毕后,我们把所有 DOM 变更一次性提交到页面上。这保证了用户不会看到「半更新」的状态:

function commitRoot() {
  // 先处理删除
  deletions.forEach(commitWork);
  // 再处理新增和更新
  commitWork(wipRoot!.child);
  // 保存当前 Fiber 树
  currentRoot = wipRoot;
  wipRoot = null;
}

function commitWork(fiber: Fiber | null) {
  if (!fiber) return;

  // 找到最近的有 DOM 节点的父 Fiber
  let domParentFiber = fiber.parent;
  while (!domParentFiber?.dom) {
    domParentFiber = domParentFiber?.parent ?? null;
  }
  const domParent = domParentFiber!.dom as HTMLElement;

  if (fiber.effectTag === "PLACEMENT" && fiber.dom) {
    // 新增:插入 DOM 节点
    domParent.appendChild(fiber.dom);
  } else if (fiber.effectTag === "UPDATE" && fiber.dom && fiber.alternate) {
    // 更新:只修改变化的属性
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  } else if (fiber.effectTag === "DELETION") {
    // 删除:移除 DOM 节点
    commitDeletion(fiber, domParent);
  }

  // 递归处理子节点和兄弟节点
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

function commitDeletion(fiber: Fiber, domParent: HTMLElement) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    // 函数组件没有 DOM,需要继续向下找
    commitDeletion(fiber.child!, domParent);
  }
}

到这里,我们的 Mini React 已经支持可中断的协调精确的 DOM 更新了。但还缺少最关键的能力——状态管理。

🪝 三、Hooks 系统的实现

3.1 为什么 Hooks 有「规则」?

React 的 Hooks 有两个著名规则:只能在函数组件顶层调用不能放在条件语句里。理解了实现原理,你就知道为什么了。

我们的 Hooks 状态存储在 Fiber 节点的 hooks 数组中,通过全局的 hookIndex 索引来定位。每次渲染时 hookIndex 从 0 开始递增,如果某个 Hook 被条件语句跳过,后续所有 Hook 的索引都会错位——这就是规则的原因。

// 全局状态:当前正在处理的 Fiber 和 Hook 索引
let wipFiber: Fiber | null = null;
let hookIndex = 0;

3.2 实现 useState

useState 是最基础的 Hook。它的实现需要做三件事:

  1. 从旧 Fiber 中恢复上次的状态(或者用初始值)
  2. 排队一个状态更新
  3. 触发重新渲染
type Hook = {
  state: any;
  queue: any[];  // 待处理的更新队列
};

function useState<T>(initial: T): [T, (action: T | ((prev: T) => T)) => void] {
  const oldHook: Hook | undefined =
    wipFiber!.alternate?.hooks?.[hookIndex];

  // 初始化 Hook:第一次渲染用初始值,后续从旧 Hook 恢复
  const hook: Hook = {
    state: oldHook ? oldHook.state : initial,
    queue: oldHook ? oldHook.queue : [],
  };

  // 处理队列中的所有更新(批量更新)
  // 注意:React 18 的自动批处理(Automatic Batching)也基于类似机制
  hook.queue.forEach((action) => {
    hook.state =
      typeof action === "function" ? action(hook.state) : action;
  });
  // 清空队列
  hook.queue = [];

  // 保存 Hook 到当前 Fiber
  wipFiber!.hooks!.push(hook);
  hookIndex++;

  const setState = (action: T | ((prev: T) => T)) => {
    hook.queue.push(action);

    // 触发重新渲染:创建新的 Fiber 树根节点
    wipRoot = {
      type: currentRoot!.type,
      props: currentRoot!.props,
      dom: currentRoot!.dom,
      alternate: currentRoot,
      parent: null,
      child: null,
      sibling: null,
      effectTag: null,
      key: null,
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  };

  return [hook.state, setState];
}

💡 提示: 你可能注意到 setState 的函数式更新(传函数而不是值)会被直接压入队列。这意味着多个 setState(prev => prev + 1) 调用会依次执行,而不是全部基于同一个旧状态。这就是为什么在连续更新时推荐使用函数式写法。

3.3 实现 useEffect

useEffect 的实现比 useState 复杂一些,需要处理依赖数组比较和异步执行:

type EffectHook = {
  deps: any[];
  cleanup: (() => void) | void;
  callback: () => void | (() => void);
};

function useEffect(callback: () => void | (() => void), deps: any[]) {
  const oldHook: EffectHook | undefined =
    wipFiber!.alternate?.hooks?.[hookIndex];

  const hook: EffectHook = {
    deps,
    cleanup: undefined,
    callback,
  };

  // 比较依赖是否变化
  const depsChanged =
    !oldHook ||
    deps.some((dep, i) => !Object.is(dep, oldHook.deps[i]));

  if (depsChanged) {
    // 依赖变化了,需要在 commit 后执行
    // 真实 React 使用调度器(Scheduler)在 commit 后异步执行
    // 我们简化为在 commitRoot 之后同步执行
    pendingEffects.push(hook);
    if (oldHook?.cleanup) {
      // 先执行上一次的 cleanup
      oldHook.cleanup();
    }
  }

  wipFiber!.hooks!.push(hook);
  hookIndex++;
}

// 在 commitRoot 中收集需要执行的 effects
const pendingEffects: EffectHook[] = [];

// 修改 commitRoot,在 DOM 更新后执行 effects
function commitRootWithEffects() {
  deletions.forEach(commitWork);
  commitWork(wipRoot!.child);
  currentRoot = wipRoot;
  wipRoot = null;

  // DOM 更新完成后执行 effects
  pendingEffects.forEach((hook) => {
    hook.cleanup = hook.callback() as (() => void) | void;
  });
  pendingEffects.length = 0;
}

📌 记住: useEffect 的 cleanup 函数在下一次 effect 执行前调用,而不是在组件卸载时才调用。这个细节很多开发者都会忽略,导致内存泄漏和事件监听器堆积。

3.4 实现 useRef

useRef 的实现非常简单——它只是一个在 Fiber 生命周期内保持不变的对象引用:

type RefHook = { current: any };

function useRef<T>(initialValue: T): { current: T } {
  const oldHook: RefHook | undefined =
    wipFiber!.alternate?.hooks?.[hookIndex];

  const hook: RefHook = oldHook ?? { current: initialValue };

  wipFiber!.hooks!.push(hook);
  hookIndex++;

  return hook;
}

📊 四、与 React 的关键差异对比

特性 Mini React React 19
调度机制 requestIdleCallback 自研 Scheduler + MessageChannel
优先级模型 无(FIFO) Lanes 优先级车道(5 种优先级)
Diff 算法 单层比较 单层比较 + key 复用优化
批量更新 简单队列 Automatic Batching + Transitions
Hooks 实现 同步数组 链表 + 闭包 + 位运算
Suspense ❌ 不支持 ✅ 支持(含 Streaming SSR)
Server Components ❌ 不支持 ✅ 支持(RSC 协议)
并发渲染 基础时间切片 完整 Concurrent Mode
代码行数 ~500 行 ~30,000+ 行

🎯 五、完整使用示例

把上面所有代码组合起来,我们就可以用 Mini React 构建一个有交互的计数器应用:

// 使用 Mini React 构建一个计数器
function Counter(props: { initial: number }) {
  const [count, setCount] = useState(props.initial);
  const [step, setStep] = useState(1);

  useEffect(() => {
    document.title = `Count: ${count}`;
    return () => {
      console.log("cleanup: count changed");
    };
  }, [count]);

  return createElement(
    "div",
    { className: "counter" },
    createElement("h1", null, `Count: ${count}`),
    createElement(
      "button",
      { onClick: () => setCount((c: number) => c + step) },
      `+${step}`
    ),
    createElement(
      "button",
      { onClick: () => setStep((s: number) => s + 1) },
      "增加步长"
    )
  );
}

// 挂载到 DOM
const container = document.getElementById("root")!;
const vdom = createElement(Counter, { initial: 0 });

// 初始渲染
wipRoot = {
  type: container.constructor.name,
  props: { children: [vdom] },
  dom: container,
  alternate: currentRoot,
  parent: null,
  child: null,
  sibling: null,
  effectTag: null,
  key: null,
};
nextUnitOfWork = wipRoot;
deletions = [];

💡 六、核心收获与进阶方向

通过实现这个 Mini React,你应该理解了以下核心概念:

  • Virtual DOM 是一种编程模式,不是性能优化——它的价值是声明式编程
  • Fiber 是一种数据结构 + 调度策略,核心目标是实现可中断渲染
  • Reconciliation 的时间复杂度是 O(n),基于三个假设简化了 Diff
  • Hooks 依赖调用顺序,因为它们本质上是数组索引访问
  • useEffect 的 cleanup 在下次 effect 前执行,不是卸载时才执行

如果你想继续深入,以下是进阶方向:

  1. 实现 useMemouseCallback — 核心是在 Fiber 上存储缓存值和依赖比较
  2. 实现 useReduceruseState 本质上就是 useReducer 的语法糖
  3. 添加优先级系统 — 参考 React 的 Lanes 模型,为不同类型的更新分配优先级
  4. 实现 Suspense — 需要 Fiber 树的「挂起」和「恢复」机制
  5. 添加 Concurrent ModestartTransition 的核心是标记低优先级更新

关键结论: React 的核心设计并不复杂——Virtual DOM + Fiber + Diff + Hooks,四层抽象构成了整个框架的基础。真正复杂的是工程细节:错误边界、服务端渲染、并发调度、Suspense 边界等。但理解了核心原理,这些工程细节就不再是黑盒。

🔗 相关工具与资源

如果你对框架原理感兴趣,以下资源值得收藏:

📚 相关文章