这个项目代号叫 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 调用、神奇更新背后真正发生的事,以后再碰到性能问题、状态问题时,推断的方向就完全不一样了。