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.createElement 或 element.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会对key、ref做特殊处理,并且会冻结 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。具体来说:
- 如果有
child,下一个工作单元就是child - 如果没有
child,就找sibling - 如果没有
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):
- 不同类型的元素产生不同的树 — 如果
type变了,直接销毁旧树 - 通过
key标识列表中的元素 —key相同的元素复用 - 同层比较,不跨层移动 — 不会把一个节点从树的一侧移到另一侧
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。它的实现需要做三件事:
- 从旧 Fiber 中恢复上次的状态(或者用初始值)
- 排队一个状态更新
- 触发重新渲染
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 前执行,不是卸载时才执行
如果你想继续深入,以下是进阶方向:
- 实现
useMemo和useCallback— 核心是在 Fiber 上存储缓存值和依赖比较 - 实现
useReducer—useState本质上就是useReducer的语法糖 - 添加优先级系统 — 参考 React 的 Lanes 模型,为不同类型的更新分配优先级
- 实现 Suspense — 需要 Fiber 树的「挂起」和「恢复」机制
- 添加 Concurrent Mode —
startTransition的核心是标记低优先级更新
⚡ 关键结论: React 的核心设计并不复杂——Virtual DOM + Fiber + Diff + Hooks,四层抽象构成了整个框架的基础。真正复杂的是工程细节:错误边界、服务端渲染、并发调度、Suspense 边界等。但理解了核心原理,这些工程细节就不再是黑盒。
🔗 相关工具与资源
如果你对框架原理感兴趣,以下资源值得收藏:
- React 官方源码 — 建议从
packages/react-reconciler开始读 - Build your own React(英文) — Dan Abramov 推荐的经典教程
- Preact 源码 — 3KB 的 React 兼容实现,代码量小适合学习
- jsjson.com 在线工具 — JSON 格式化、代码压缩等开发常用工具