首页 / 资讯中心 / 文章详情

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

手写迷你React框架:虚拟DOM、Fiber和Hooks核心原理 ★ FEATURED ARTICLE
这个项目代号叫 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)调用所以你需要在文件顶部手动引入 Reactautomatic 模式会从react/jsx-runtime自动导入jsx函数不需要手动引 React。我在项目里故意选择了 classic 模式因为createElement这个名字更直白好理解。比如你写const element div classNameboxHello/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 树到真实 DOM4.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 的人会忘记这一步导致第二次更新时永远拿不到旧 Fiberdiff 完全失效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. 用实践验证成果写一个可运行的 Demo7.1 项目配置从 JSX 文件到浏览器运行光说不练是假把式。我搭了一个最小的运行环境来验证手写框架真的能用。目录结构大概是这样手写的核心库放在 mini-react 目录一个 index.js 作为应用入口里面写 JSX 组件Babel standalone 脚本在浏览器端直接编译 JSX用 Babel standalone 虽然不适合生产但做演示和学习非常舒服html 里引一个 script 标签就能在浏览器里编译 JSX不用搭 Node 构建链。script srchttps://unpkg.com/babel/standalone/babel.min.js/script script typetext/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 就永远是 nulldiff 失效所有节点都会被当成 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、effectTagcommitRoot 打印当前 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 调用、神奇更新背后真正发生的事以后再碰到性能问题、状态问题时推断的方向就完全不一样了。
阅读完成 · 觉得有帮助?
咨询建站