news 2026/10/21 5:28:40

手写迷你React框架:虚拟DOM、Fiber和Hooks核心原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写迷你React框架:虚拟DOM、Fiber和Hooks核心原理

这个项目代号叫 rea,全称我私下起的是 React Essentials Analysis,说白了就是前段时间给自己定了个硬目标:不读源码,不抄别人的分析文章,从零手写一个能跑、能用的迷你版 React。踩了不少坑,也把很多以前“会用但不知道为什么”的 API 彻底看明白了。这篇文章就是那次手写实践的全记录,从 JSX 怎么被编译,到虚拟 DOM、Fiber 结构、Hooks 的运行机制,再到最后怎么把事件绑上去、怎么处理更新,一条线串下来。适合那些已经用 React 写过业务、但总觉得内部逻辑是黑盒的前端开发者,也适合准备深入读源码但被庞大代码库劝退的人。你先按这套思路自己实现一遍,再回去看真实源码,会顺畅得多。

1. 为什么要自己手写一个 React 框架

1.1 业务写久了,会碰到“API 用熟了但原理不知道”的墙

用 React 做业务开发一两年之后,其实会遇到很多说不清楚的问题。比如某个子组件明明数据没变却一直重渲染,函数组件里面 setState 之后拿到的 state 总是上一次的值,useEffect 传空数组却 lint 报警告,列表更新时 key 到底怎么填才算对。

这些问题如果你只是查网上的“优化技巧”,往往是头痛医头,今天改了明天换个场景又冒出来。但如果你理解了 React 内部那套“虚拟 DOM 树 → Fiber 树 → 真实 DOM”的映射逻辑,很多问题其实都是可以推导出来的,根本不需要死记硬背。

我当时给自己定的要求是:不看源码,不看二手解读,不看网上那些“模拟 React 实现”的现成代码,纯靠官方文档和已知的行为表现去反推内部结构。整个实现过程大概花了一周零碎时间,核心代码量控制在 300 行到 400 行之间。这个体量刚好合适,太少了说明你没碰到关键的坑,太多了说明你被工程细节拖住了。

1.2 为什么说读源码不如自己实现一遍

React 源码的仓库很大,拆分成 react、react-dom、react-reconciler、scheduler 好几个核心包。直接去读,很容易迷失在各个模块的互相调用里。源码里有大量为了兼容旧版本、支持极边缘场景而存在的分支逻辑,新手根本分不清哪些是主路径,哪些是防御性代码。

自己实现就完全不一样。你被迫从“用户视角”切换到“实现者视角”,每一个 API 都得回答出:它存在是为了解决什么问题?它背后维护了什么样的数据结构?它在渲染流程的哪个节点被触发?

比如 useState 这个 Hook,你用的时候只需要写一行,但实现它至少涉及三个核心问题:state 存在哪里、组件重新渲染时怎么恢复上一次的 state、setState 调用后怎么触发新的渲染。这三个问题每一个都直接对应一套数据结构设计,你实现过一遍就不会忘了。

1.3 这个项目的边界和验收标准

我不想做一个“玩具版”,所以明确了自己要实现的功能范围:

  • 支持用 JSX 写组件,包括函数组件和简单的宿主元素(div、span、input 等)
  • 支持 useState,多 Hooks 按顺序调用
  • 支持 useEffect,能比较依赖数组并执行副作用或清理函数
  • 支持组件更新和删除,基础 diff 能满足同位置类型变化和 key 匹配
  • 支持事件绑定,onClick 这类 props 能真实触发

明确不做的功能也写下来了:不做 Suspense,不做并发特性,不做时间切片调度,不做 forwardRef,不做 memo。这些确实很复杂,但它们是在基础渲染机制之上的扩展,把地基先打扎实,再去理解它们会容易得多。

2. 整体架构设计:把 React 拆成四层

2.1 四层结构:编译层、虚拟 DOM 层、协调层、渲染层

写代码之前我花了一天时间做架构设计。React 之所以让人觉得复杂,是因为它把“用户写的代码”和“浏览器最终执行的代码”之间做了好几层转换。为了不被绕晕,我把它拆成了四个清晰的层次:

第一层是编译层。JSX 本身不是合法 JavaScript,必须通过 Babel 编译成函数调用。这一层决定的是你的组件代码长什么样。

第二层是虚拟 DOM 层。经过编译后,组件返回的是普通的 JavaScript 对象,用来描述 UI 长什么样。这个对象包含 type(元素类型)、props(属性)、children(子元素)。

第三层是协调层。每次组件状态更新,都需要把新旧虚拟 DOM 做对比,算出哪些节点需要新增、删除、更新。React 把这套过程叫 reconciliation。

第四层是渲染层。把协调出来的更新指令,真正作用到浏览器 DOM 上,用户才能看到变化。

这个分层设计帮我理清了一个关键认知:React 核心其实不关心你渲染到哪里,它只负责“算出 UI 应该长什么样”。我们今天只针对浏览器 DOM 做渲染,但如果你想把同一套逻辑渲染到 Canvas 或其他宿主环境,只需要替换第四层。

2.2 为什么采用 Fiber 结构而不是简单递归

一开始我是想用递归去遍历虚拟 DOM 树的,因为递归写起来最直观:处理完一个节点,递归处理它的 children 和 siblings。但后来发现这个方案有一个致命问题:递归一旦开始就无法中断,一旦树很深,主线程会被长时间占用,用户输入和动画都会卡顿。

React 的 Fiber 架构本质上就是把递归遍历拍平成了一个链表遍历。每个节点变成一个 Fiber 对象,节点之间通过 child、sibling、return(父节点)三个指针互相连接。这样遍历变成了一步步前进的过程,每一步都可以停下来,让浏览器处理紧急事件后再继续。

我虽然不打算在这个版本里实现时间切片,但决定提前采用 Fiber 数据结构。原因很简单:递归版本实现完,想改成 Fiber 版本几乎等于重写,而从一开始就用 Fiber 结构,后面想加并发渲染功能就是水到渠成的事。

2.3 单向数据流到底是怎么保证的

React 给人最大的体感就是“数据变了,界面自动更新”。但它的数据流方向是严格单向的:用户操作触发事件 → 事件里调用 setState → state 变化 → 重新执行组件函数 → 得到新的虚拟 DOM → 和旧的对比 → 更新真实 DOM。

这个闭环里没有任何一个环节是反向的。props 只能在父组件里修改,子组件拿到的 props 是只读快照。我在实现的时候也严格遵循这一点,组件的渲染函数就是一个纯函数:输入是 props 和 state,输出是虚拟 DOM 结构。这样带来的好处是巨大的:因为是纯函数,所以你可以反复调用它,永远得到可预测的结果;调试时只需要检查输入输出,不需要追踪一大堆中间变量。

3. JSX 与虚拟 DOM 的落地细节

3.1 JSX 编译成什么:从 Babel 配置说起

要真刀真枪地跑起来,第一关是让 JSX 能被浏览器或 Node 环境执行。我这里用的是 Babel 的经典编译方案。

关键配置是预设@babel/preset-react,它有两个模式可以选:classic 和 automatic。classic 模式会把 JSX 编译成React.createElement(type, props, ...children)调用,所以你需要在文件顶部手动引入 React;automatic 模式会从react/jsx-runtime自动导入jsx函数,不需要手动引 React。

我在项目里故意选择了 classic 模式,因为createElement这个名字更直白,好理解。比如你写:

const element = <div className="box">Hello</div>

编译出来大概长这样:

const element = React.createElement("div", { className: "box" }, "Hello")

这就很清楚了。所谓虚拟 DOM,其实就是这个函数调用返回的一个普通对象。它不是一个“真实存在的 DOM 节点”,只是对 UI 的一个描述。

3.2 createElement 函数实现:children 是最麻烦的部分

接下来是我实现的createElement。这里有一个非常容易踩坑的细节:很多人在设计 children 时没有做归一化处理,导致后面遍历子节点时逻辑到处都是分支判断。

我的实现是这样做的:

function createElement(type, props, ...children) { return { type, props: { ...props, children: children.map(child => typeof child === "object" ? child : createTextElement(child) ) } } } function createTextElement(text) { return { type: "TEXT_ELEMENT", props: { nodeValue: text, children: [] } } }

几个设计上的取舍:

字符串和数字统一转成特殊类型的文本节点,type 固定为TEXT_ELEMENT,文本内容存在props.nodeValue里。这样后面渲染层处理文本节点时只需要判断这一个类型,其他地方不用再分散判断。

children 统一是数组。如果只有一个子元素,JSX 编译后传入的是单个值,但展开语法...children会统一收成数组,这个细节帮你省掉很多 if 判断。

为什么要把文本单独做成节点而不是直接存字符串?因为协调和渲染时,你需要统一用节点去操作。文本节点也需要有对应的 DOM 节点(document.createTextNode),也参与 diff 和更新。

3.3 虚拟 DOM 节点和 Fiber 节点别混为一谈

这里必须花篇幅澄清一个很容易搞混的概念:虚拟 DOM 节点和 Fiber 节点不是同一个东西。

虚拟 DOM 节点就是createElement返回的、描述 UI 的普通对象,它是不可变的,每次渲染重新生成。Fiber 节点是 React 内部维护的“工作单元”,它不但包含类型和 props,还包含 DOM 引用、父节点、子节点、兄弟节点的指针,以及更新状态相关的字段。你可以把虚拟 DOM 理解成“设计图”,Fiber 才是“施工现场的工位”。

我的 Fiber 节点长这样:

// fiber = { // type: "div" 或函数组件本体, // props: { ... }, // dom: 真实DOM引用,初次创建后存在, // parent: 父fiber, // child: 第一个子fiber, // sibling: 下一个兄弟fiber, // alternate: 上次渲染对应的fiber, // effectTag: "PLACEMENT" | "UPDATE" | "DELETION", // hooks: 函数组件存储hook链表的数组 // }

这个结构里最关键的是alternate字段,它指向的是上一次渲染时这颗树上对应的 Fiber。没有它,更新时就不知道哪些节点已经存在、哪些 DOM 可以复用、哪些 props 需要比对。

4. 渲染器实现:从 Fiber 树到真实 DOM

4.1 createDom:把 Fiber 变成真实 DOM

当协调器算出一个新的 Fiber 节点后,渲染器的第一个任务就是根据它的类型创建对应的真实 DOM。实现很简单:

function createDom(fiber) { const dom = fiber.type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(fiber.type) updateDom(dom, {}, fiber.props) return dom }

这里updateDom是核心,它负责两件事:把 props 里的普通属性设置到 DOM 上,同时把以on开头的事件 props 绑定为真实的事件监听器。

function updateDom(dom, prevProps, nextProps) { const isEvent = key => key.startsWith("on") const isProperty = key => key !== "children" && !isEvent(key) // 移除旧的属性 Object.keys(prevProps).filter(isProperty).forEach(name => { dom[name] = "" }) // 移除旧的事件监听 Object.keys(prevProps).filter(isEvent).forEach(name => { const eventType = name.toLowerCase().substring(2) dom.removeEventListener(eventType, prevProps[name]) }) // 设置新的属性 Object.keys(nextProps).filter(isProperty).forEach(name => { dom[name] = nextProps[name] }) // 绑定新的事件监听 Object.keys(nextProps).filter(isEvent).forEach(name => { const eventType = name.toLowerCase().substring(2) dom.addEventListener(eventType, nextProps[name]) }) }

这里有个很实用的细节:更新 props 之前,一定要先把旧的属性清掉、旧的事件解绑。不然的话,比如一个按钮的onClick从函数 A 换成了函数 B,如果不解绑,触发时仍然执行的是旧的 A,就出现“点击没反应/点击执行旧逻辑”的灵异现象。

真实 React 在这块做了事件委托和合成事件系统,把所有事件挂到根容器,再通过冒泡和 target 分发,性能和内存表现更好。但我们这个版本直接在每个 DOM 上单独绑定,简单直观,对于教学完全够用。

4.2 执行协调:render 函数入口

渲染的入口函数是render,它接收两个参数:一个是我们通过 JSX 生成的根元素对象,一个是页面上的真实容器 DOM。

let currentRoot = null let wipRoot = null let deletions = null function render(element, container) { wipRoot = { dom: container, props: { children: [element] }, alternate: currentRoot } deletions = [] wipRoot = performWork(wipRoot) commitRoot(wipRoot) }

看到这里你可能已经感觉到了,这里其实分成了两个大的阶段:

第一个阶段是“协调/渲染计算阶段”,我从根开始,把整棵虚拟 DOM 树转换成新的 Fiber 树,过程中对比旧的 Fiber 树,给节点打上 PLACEMENT、UPDATE、DELETION 这样的标记。

第二个阶段是“提交阶段”,把 Fiber 树上所有标记出来的变更统一作用到真实 DOM。这里为什么要把两个阶段分开?因为 React 的原则是:先算出整棵新树,再一次性提交改动,避免算一半就改了 DOM、中途报错留下半更新状态。真实 React 中这两个阶段名字叫 render phase 和 commit phase,这个设计非常重要。

4.3 reconcileChildren:对比新旧子节点

协调的核心方法是reconcileChildren。这一步要同时遍历新虚拟 DOM 的子节点数组和旧 Fiber 的子节点链表,逐个对比。

function reconcileChildren(wipFiber, elements) { let index = 0 let oldFiber = wipFiber.alternate && wipFiber.alternate.child let prevSibling = null while (index < elements.length || oldFiber != null) { const element = elements[index] let newFiber = null const sameType = oldFiber && element && element.type === oldFiber.type if (sameType) { newFiber = { type: oldFiber.type, props: element.props, dom: oldFiber.dom, parent: wipFiber, alternate: oldFiber, effectTag: "UPDATE" } } if (element && !sameType) { newFiber = { type: element.type, props: element.props, dom: null, parent: wipFiber, alternate: null, effectTag: "PLACEMENT" } } if (oldFiber && !sameType) { oldFiber.effectTag = "DELETION" deletions.push(oldFiber) } if (oldFiber) { oldFiber = oldFiber.sibling } if (index === 0) { wipFiber.child = newFiber } else if (element) { prevSibling.sibling = newFiber } prevSibling = newFiber index++ } }

这套逻辑虽然简单,但能准确覆盖三种基础场景:

type 相同:表示这个位置上的元素类型没变,可以复用旧的 DOM 节点,只需要把 props 标记为 UPDATE,提交阶段对比属性差异。

type 不同:旧的要么是 div 变成了 span,要么是函数组件变成了普通元素,说明整个子树都要重建。新节点标 PLACEMENT,旧节点要收集到 deletions 队列里统一删除。

oldFiber 存在但 element 没有了:说明老的子节点在新虚拟 DOM 中被移除了,同样要标记 DELETION。

这里我故意没有实现基于 key 的“节点移动”逻辑。真实 React 在遇到 key 时,会在同一层级内进行更细的匹配,实现节点位移复用。我的版本只做到同位置对比和 key 匹配的基础版本,下面会单独讲。

4.4 提交阶段:commitRoot 把 Fiber 树落地

协调完成后,wipRoot 指向一棵完整的、带 effectTag 标记的新 Fiber 树。提交阶段是这个过程里最不能被干扰的部分。

function commitRoot(root) { deletions.forEach(commitWork) commitWork(root.child) currentRoot = root wipRoot = null } function commitWork(fiber) { if (!fiber) return const domParent = fiber.parent.dom if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) { domParent.appendChild(fiber.dom) } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) { updateDom(fiber.dom, fiber.alternate.props, fiber.props) } else if (fiber.effectTag === "DELETION") { domParent.removeChild(fiber.dom) } commitWork(fiber.child) commitWork(fiber.sibling) }

提交顺序上有个很重要的细节:DELETION 的删除操作要先于新增和更新执行。因为如果先把新节点插进去,再删旧节点,遇到旧节点和新节点有相同 ID 或其他标记时,可能误删刚刚插入的新节点。deletions 是在 render 阶段收集的,提交时第一个循环先处理删除。

提交完成后的最后一步是currentRoot = root。这一步代表“上一棵树”的指针更新完成,下一次渲染时新的 workInProgress 树才能拿到正确的 alternates 去做 diff。很多自己实现 React 的人会忘记这一步,导致第二次更新时永远拿不到旧 Fiber,diff 完全失效,UI 会越渲染越乱。

5. Hooks 的实现原理:useState 与 useEffect 内部机制

5.1 state 到底存在哪里

很多 React 开发者好奇一个问题:函数组件每次渲染都重新执行一遍,那 state 存在哪里?总不可能存在局部变量里吧,局部变量重新执行后就丢了。

答案是:state 存在组件对应的 Fiber 节点上。每个函数组件 Fiber 都有一个hooks属性,它是一个数组,数组里每个元素对应组件里调用的一次 Hook。

当一个函数组件执行时,渲染器会把它当前的 Fiber 设为 “正在工作的 Fiber”,然后从 0 开始计数,每调用一次 Hook,就从数组里取一个位置。useState 拿到的是当前这一格的数据结构。

let wipFiber = null let hookIndex = null function updateFunctionComponent(fiber) { wipFiber = fiber hookIndex = 0 wipFiber.hooks = [] const children = [fiber.type(fiber.props)] reconcileChildren(fiber, children) }

5.2 useState 的实现与 setState 触发更新的完整链路

useState 的实现其实非常朴素:

function useState(initialValue) { const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex] const hook = { state: oldHook ? oldHook.state : initialValue, queue: [] } const actions = oldHook ? oldHook.queue : [] actions.forEach(action => { hook.state = action instanceof Function ? action(hook.state) : action }) const setState = action => { hook.queue.push(action) wipRoot = { dom: currentRoot.dom, props: currentRoot.props, alternate: currentRoot } deletions = [] performWork(wipRoot) commitRoot(wipRoot) } wipFiber.hooks.push(hook) hookIndex++ return [hook.state, setState] }

很多人在这一步会有一个很大的疑问:setState 调用后,我是怎么知道要重新渲染哪个组件的?其实实现起来非常粗暴:从当前 Fiber 一层层往上找到根 Fiber,把整棵树重新协调一遍,渲染的起点永远是根节点。也就是说,任何一个组件 setState,最终都是整棵树重跑 diff。这也解释了为什么 React 性能优化的核心方向之一是控制“渲染范围”——只要没有 memo 之类的缓存机制,默认情况下一个子组件 setState,它的所有子孙组件都会被执行一遍。

真实 React 的 setState 不是立即同步执行的,它会走 scheduler 调度,批量合并多个更新,再按优先级决定何时开始渲染。我的简化版本是同步重渲染,每次 setState 都从头跑一次完整协调,这对于教学更直观,但性能上确实不行。理解了这套简化逻辑后再去看 “自动批处理” 和 “Concurrent Mode”,理解成本会降低很多。

5.3 为什么 Hooks 不能写在条件语句里

这个价值百万的经典问题,用我们现在的实现来解释实在太直观了。useState 的取值是按下标hookIndex从hooks数组里取的。第一次渲染时,组件调用顺序是 useState A、useState B、useState C,数组里三个位置分别存了 A、B、C 的 state。

第二次渲染时,如果因为某些条件,useState B 被跳过了,那么 useState C 就会去取数组下标 1 的值,拿到的是 B 上次的 state。整个数据全部错位,组件状态全乱。

这就像三个人排好队领饭,结果第二个人临时跑开,第三个人往前一步领了第二个人的饭。每个人领到的都不是自己的。React 官方强制 “Rules of Hooks”,要求 Hooks 必须在顶层调用,背后的实现原因就是这个链表按序取值的机制。规则不是凭空规定的,你可以不清楚具体实现,但要理解它为什么这么定。

5.4 useEffect:让副作用在渲染提交后执行

useEffect 给我带来的最大“原来如此”时刻,是理解它的执行时机。它不是组件函数执行时同步执行的,而是在所有 DOM 变更提交完成之后才执行。这么做有一个非常实际的原因:副作用通常要读取 DOM 位置、订阅外部事件、发起网络请求,这些操作依赖“页面已经更新完成”这个前提。

我的简化实现是把 useEffect 的 callback 存在 hooks 数组里,在 commit 完成后统一执行:

function useEffect(callback, deps) { const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex] const hasChanged = !oldHook || oldHook.deps.length !== deps.length || deps.some((dep, i) => !Object.is(dep, oldHook.deps[i])) if (hasChanged) { if (oldHook && oldHook.cleanup) { oldHook.cleanup() } hook.effect = callback() } const hook = { deps, cleanup: null } // 简化版:提交阶段真正执行 wipFiber.hooks.push(hook) hookIndex++ }

真实 React 中 useEffect 的清理函数执行时机是:下次副作用执行前,以及组件卸载时。我实现的时候分了两步,先把回调结果作为 cleanup 存起来,下一次依赖变化时先执行旧的 cleanup 再执行新的回调。这个顺序极其重要,不然你订阅了外部事件但没清理,组件一卸载就疯狂报内存泄漏告警。

依赖对比用的Object.is是另一件容易忽略的事。每次渲染都生成一个新的对象字面量,Object.is 比较两个不同对象引用永远是 false,所以 useEffect 依赖项里写对象、数组、函数,几乎每次都会执行。这不是 React 的 bug,而是引用比较的固有行为。

6. 更新机制与 Diff 策略的取舍

6.1 基础 Diff 算法:同位置同类型复用

Diff 算法是整个项目里最有意思的部分。真实 React 的 diff 算法细节非常多,但它最底层的逻辑可以用一句话概括:如果在同一层级、同一位置,前后两次渲染的元素 type 相同,就认为这个节点可以复用,只更新 props;如果 type 不同,就销毁旧节点、创建新节点。

这个策略虽然看起来有点“粗暴”,但它有一个很扎实的合理性基础:在实际业务中,绝大多数 UI 变化的场景都是同一类组件更新自己的属性,比如一个列表项从“未完成”变成“已完成”,type 仍然是那个列表项组件。真正把 div 换成 span 的场景非常少见。React 用牺牲极少数的“不精确复用”换来了 O(n) 的对比复杂度,而不是树形结构对比的 O(n^3)。

我实现的 reconcileChildren 正是这个策略的直接映射。type 相等时,新建的 Fiber 直接复用 oldFiber 的 dom,并且带上 alternate 指针,这样提交阶段才能拿到上一次的 props 做属性对比。

6.2 key 的作用与一个容易理解的实现

React 中 key 的核心作用是辅助 diff,让 React 在列表变化时能识别出“同一个元素跑到了新位置”,而不是误判成“删了一个元素又新增了一个元素”。真实 React 对 key 的处理是一个复杂的多指针匹配过程,我这里实现的是一个简化版思路:

function mapElementsToOldFiber(elements, oldFiber) { const map = {} let fiber = oldFiber while (fiber) { const key = fiber.props && fiber.props.key if (key != null) { map[key] = fiber } fiber = fiber.sibling } return map }

遍历新子元素时,优先用 key 去旧 Fiber 的 Map 里查。查到了说明同一个元素只是挪了位置,可以复用 DOM;查不到说明是新增节点。真实 React 还会维护一个可移动的可复用的节点池,配合 “lastPlacedIndex” 来决定哪些节点需要移动。我的实现没有做到这个程度,但对理解 key 为什么不能随便用 index 已经足够了。

用 index 当 key 的本质问题是:当列表头部插入一条数据时,后面所有元素的 index 都变了,React 会认为这些元素是“不同的节点”,全部销毁重建,性能断崖式下跌。更糟的是如果 key 直接用了 index,前后两个不同的元素被匹配成同一个,DOM 节点被复用,但内容却对不上,就会出现列表内容错乱的 bug。

6.3 为什么更新要找到根节点重新渲染

executeState 触发更新时,我们在 render 之后调用了一个关键的performWork函数。这个函数会重新构建一整棵 Fiber 树。其实 React 还有一个重要的优化叫 bailout,如果某个组件的 props 和 state 都没变,它可以跳过该子树的协调。React 是通过比较 props 的引用和 memo 标记来做的。

我自己的版本没有做任何 bailout,每个 setState 都会从根开始完整遍历。这是故意的选择,因为我现在的主目标是让逻辑完整、可用,而不是性能优化。但如果继续往深处扩展,下一步最容易加的就是“如果组件 props 引用没变,就跳过子节点 diff”,这就是 React.memo 的底层逻辑。

7. 用实践验证成果:写一个可运行的 Demo

7.1 项目配置:从 JSX 文件到浏览器运行

光说不练是假把式。我搭了一个最小的运行环境来验证手写框架真的能用。目录结构大概是这样:

  • 手写的核心库放在 mini-react 目录
  • 一个 index.js 作为应用入口,里面写 JSX 组件
  • Babel standalone 脚本在浏览器端直接编译 JSX

用 Babel standalone 虽然不适合生产,但做演示和学习非常舒服,html 里引一个 script 标签就能在浏览器里编译 JSX,不用搭 Node 构建链。

<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> <script type="text/babel" src="./index.js"></script>

然后我在 index.js 里写了一个计数器组件、一个列表组件,还有一个带 useEffect 的日志组件。

7.2 Demo 代码与实际输出

计数器加列表的完整代码如下:

function Counter() { const [count, setCount] = useState(0) useEffect(() => { document.title = `当前计数: ${count}` }, [count]) return ( <div> <h1>计数器</h1> <p>当前值: {count}</p> <button onClick={() => setCount(c => c + 1)}>加一</button> <button onClick={() => setCount(c => c - 1)}>减一</button> </div> ) } function List() { const [items, setItems] = useState([ { id: "a", text: "项目A" }, { id: "b", text: "项目B" }, { id: "c", text: "项目C" } ]) const addItem = () => { const newId = Date.now().toString() setItems([...items, { id: newId, text: `项目${newId}` }]) } return ( <div> <ul> {items.map(item => ( <li key={item.id}>{item.text}</li> ))} </ul> <button onClick={addItem}>新增项目</button> </div> ) } function App() { return ( <div> <Counter /> <List /> </div> ) } render(<App />, document.getElementById("root"))

运行结果符合预期:点击按钮页面数字会变化,document.title 跟着更新,新增列表项渲染到页面上,DOM 数量和内容都正确。这个 Demo 至少证明了我手写的 createElement → reconcile → commit 整条链路是通的,useState 和 useEffect 在多个组件、多组 Hook 调用下也能正常工作。

8. 常见问题与排查技巧实录

8.1 点击事件不生效

我遇到的第一个问题就是:第一次渲染页面出来正常,但点击按钮没有任何反应。排查了一遍发现是 updateDom 里的事件绑定顺序出了问题。我先把事件绑定的逻辑放在了属性设置的后面,但dom[name] = nextProps[name]这行代码会反复执行,旧事件还没来得及被移除,新事件又被加上了。实际运行中,事件监听器叠加会导致回调执行多次,或者因为 removeEventListener 传入的引用不对,旧的监听器根本移除不掉。

真正的坑点是:removeEventListener 必须传入和 addEventListener 完全相同的引用函数,字节相同都不行,必须是同一个对象。所以 updateDom 里必须先用 prevProps 里保存的旧函数做移除,不能用 nextProps 里新函数去“覆盖”。

8.2 状态更新了,但 UI 没变

这是所有实现 React/Redux 类框架的人最先遇到的诡异问题之一。表面现象是:调用了 setState 以后 console.log 打印 state 已经是新值了,但页面上没有变化。

我排查后发现,问题出在currentRoot没有被正确更新。第一次渲染提交后 currentRoot 指向了 wipRoot,但第二次 setState 触发的 wipRoot 用的 alternate 仍然是旧的那棵完整的树。因为 currentRoot 和 wipRoot 是引用关系,触发的时机一乱,alternate 就会丢失。alternate 一旦丢失,reconcileChildren 里 oldFiber 就永远是 null,diff 失效,所有节点都会被当成 PLACEMENT 重新插入一遍。如果 DOM 已经被 build 过一次,appendChild 会把已有节点重新移动或重复插入,UI 表现就是状态变了、内容没变甚至越变越乱。

排查办法是在 commitRoot 的最后一行打一个条件日志,输出 currentRoot 的引用是否每次渲染都切换成功。这个问题提醒我一个重要习惯:凡是有双缓存结构的系统,提交阶段的“切换指针”步骤一定不能省,也不能写错位置。

8.3 useEffect 导致无限循环

这是我踩得最疼的坑。在 useEffect 里 setState,而 setState 又触发全新渲染,新渲染执行 useEffect 时依赖里的 state 引用又不同,于是又设置一次……循环往复。

模拟复现的方式是:

useEffect(() => { setCount(c => c + 1) }, [count])

页面上的计数器会飞快往上跳,直到浏览器掉帧卡死。原因很清晰:useEffect 回调执行时 count 已经变了,下一次渲染用的依赖是新的 count,与上次依赖不同,于是继续执行回调。这个循环在真实 React 里同样存在,官方文档里也明确警告过这种用法。排查技巧是,在 useEffect 回调入口打一行 log 打印依赖变化前后的取值,很快就能看清是哪一个依赖在“永不相等”。

8.4 key 重复或格式不对导致的列表渲染错乱

把列表 key 写成 index 的坑大家都听过,但我实际做完手写版之后才真正理解它错乱到个什么程度。因为我的 diff 在同位置对比之外,还用了一个 Map 做 key 匹配。同一层级的 key 重复时,Map 里后面的节点会直接覆盖前面的节点,然后旧节点集合里就少了一项,协调结果完全错乱。

这个问题的排查标志是:新插入列表项时,某一行内容变成另一行内容,或者一个节点被“吃掉”了。解决办法非常直接:key 必须在该兄弟节点中是唯一的、稳定的字符串,不能用数组 index,更不能每次渲染用 Math.random 重新生成。

8.5 开发时常用的一套调试日志方法

整个项目排查过程中,我最常用的一套方法是给三个关键环节加日志:reconcileChildren 循环里打印每一对 oldFiber 和 newFiber 的 type、key、effectTag;commitRoot 打印当前 commit 的 effectTag 和 DOM 文本内容;hooks 数组打印每次渲染时 useState 和 useEffect 取到的下标和值。

这三个日志打出来,整个渲染过程就像被现场直播一样透明。真实 React 也有官方调试工具 React DevTools,它能看到 Fiber 树和 hooks 链,原理和我这套日志是一样的,只是做得精致得多。理解了这一步,再去看 DevTools 里不同颜色节点和警告,会非常亲切。

9. 从 mini 版再往前走:距离真实 React 还差多少

手写完成这个 mini 版后,我对 React 源码的态度彻底转变了,从“敬畏但害怕”变成了“想去读一读”。因为我已经有了一个简化但正确的心智模型,接下来往里填充复杂细节就有坐标系了。

真实 React 里还有很多东西我没有实现,但每一个都清楚知道它的位置和作用:并发渲染时间切片需要把一个完整的渲染工作切成多个时间片,用 requestIdleCallback 或者自实现调度器去决定何时让出主线程;更新优先级调度需要一个小顶堆维护多个更新任务,按紧急程度决定先处理哪个;Suspense 需要管理 Promise 的挂起与重试;useTransition、useDeferredValue 都是在调度层做文章。这些特性不会改变我 mini 版里的核心流程,它们只是在“何时开始渲染”和“渲染到一半能否暂停”这两个维度上加了大量精细控制。

如果你看完这篇文章打算自己动手实现一遍,我的建议是按这个顺序来:先实现 createElement 和虚拟 DOM 数据结构,再实现渲染器和 createDom,接着实现最朴素的渲染函数,然后加 props 更新和事件绑定,再逐步加入函数组件、useState、useEffect。每一步都跑一个对应的小 Demo,确保这一步稳定了再进下一步。不要想着一步到位写出一个全功能框架,那只会让你迷失在 bug 的海洋里。

我在实际写的过程里最深的一个体会是:React 的所有设计都围绕一个平衡点展开——既要让开发者写出声明式、可预测的代码,又要保证浏览器端性能可接受。你看到的每个看起来“魔法般”的 API,背后都是一个非常朴素的数据结构加一套严格定义的流程。亲手重走一遍这条路,你会看清那些 JSX 标签、Hook 调用、神奇更新背后真正发生的事,以后再碰到性能问题、状态问题时,推断的方向就完全不一样了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 5:27:57

agent-skills 智能体能力体系:从技能定义到编排落地的工程实践

1. 从“agent-skills”这个命名说起&#xff1a;它到底指什么第一次看到agent-skills这个词&#xff0c;很多人会下意识地把它当成某个具体框架或者某个开源库的名字。但如果你在工程一线待过一段时间就会发现&#xff0c;它更像是一个概念容器——用来描述“智能体&#xff08…

作者头像 李华
网站建设 2026/10/21 5:27:48

G711封装RTP实战:从PCM到UDP的语音传输与避坑指南

简介&#xff1a;这份资源围绕G.711音频编码与RTP实时传输的封装实现展开&#xff0c;面向从事VoIP开发、实时音频流传输或网络协议学习的开发者与相关专业学生。包内共4个文件&#xff0c;以C语言源码、SDP会话描述文件、G.711音频样本及说明文档为主&#xff0c;压缩包约2.16…

作者头像 李华
网站建设 2026/10/21 5:27:18

纯C++手写FlappyBird:SDL2游戏开发实战与架构解析

1. 为什么选择纯C手写一款FlappyBird很多人第一次接触游戏开发&#xff0c;都是从"抄一个FlappyBird"开始的。但绝大多数教程走的是Unity、Godot或者PythonPygame的路线&#xff0c;真正用纯C从零手写、不依赖任何游戏引擎的完整案例其实并不多。原因很简单&#xff…

作者头像 李华
网站建设 2026/10/21 5:27:15

纯C++手写FlappyBird:从游戏循环到碰撞检测的完整实现

1. 项目缘起与整体设计思路1.1 为什么选择纯C手写一款FlappyBird很多人第一次接触游戏开发&#xff0c;都是从“复刻经典小游戏”开始的。FlappyBird这个项目尤其典型&#xff1a;规则简单到一句话就能说清——点击屏幕让小鸟穿过管道缝隙&#xff0c;撞到就重来。但真正动手写…

作者头像 李华
网站建设 2026/10/21 5:26:36

从激光雷达到占用栅格地图:机器人导航地图模块设计与实践

做机器人的人应该都有同感&#xff1a;底盘能动起来只是开始&#xff0c;真正决定机器人是不是“靠谱”的&#xff0c;是它能不能回答三个问题——我在哪、我要去哪、路上有没有墙。这三个问题绕不开一张地图。我最近在梳理一个叫lingbot-map的地图模块&#xff0c;它来自一个代…

作者头像 李华
网站建设 2026/10/21 5:26:25

SolidWorks到Unity模型导入管线:从CAD数据到实时交互场景的完整指南

1. 从一张图纸到可交互场景&#xff1a;这条管线到底在解决什么问题做过机械设计的朋友大概率都经历过这样的场景&#xff1a;在 SolidWorks 里把一套机构画得清清楚楚&#xff0c;装配体干涉检查全绿&#xff0c;工程图也出完了&#xff0c;结果领导或者客户来一句——“能不能…

作者头像 李华