资讯动态

手写迷你React框架:拆解虚拟DOM、Diff与Hooks核心实现

发布时间:2026/10/21 15:14:25 来源:尧图企业网站定制
兄弟们今天想聊一个我最近几个月一直在忙的玩具项目一个我从零手写的迷你前端框架我给它起名叫“rea”。名字取得挺随意就是把 React 掐头去尾剩个核但它解决的可不是名字这么随意的问题。React 的源码我啃过好几遍源码本身不复杂难的是它为了兼容生产环境堆了一大堆边界情况、调度策略和性能优化你把那些枝叶全部砍掉之后剩下的核心骨架到底是什么这个项目就是想把这根骨架给“拆”出来。如果你也想真正搞懂框架底层在干什么不想只停留在 写 JSX、调 setState 的舒适区那这篇文章大概率对你有点用。我会把从虚拟 DOM 设计、Diff 协调、Hooks 实现到调度器的完整思路全盘托出所有代码都可以直接抄去复现。1. 为什么手写一个“迷你 React”项目定位与设计取舍1.1 手写框架的动机我最早接触 React 的时候真的把它当成一个黑盒。组件挂在页面上状态一更新界面就跟着变。当时觉得挺神奇但心里一直有块石头没落地这个“变”到底是怎么发生的后来开始啃源码发现两个问题。一是 React 的源码包体积太大协调器、渲染器、调度器、事件系统、合成事件池这些模块互相交织单看某一个文件还能看懂逻辑一旦把它们串起来整个人就麻了。二是线上模式下的源码包含大量开发环境的警告代码和性能优化分支你删掉这些噪音之后核心的部分反而被淹没在里面。所以我就做了一个决定与其继续在庞大的源码里面大海捞针不如直接自己动手写一个能跑起来的“微型版本”。这个过程其实是个主动消化的过程别人把知识喂到嘴边你只是“看过”理解程度大概只有两成但当你坐下来把每一行代码自己敲出来的时候每个数据结构为什么要这样设计、每次更新为什么要走这个流程理解就变成了九成。rea 这个项目就是这么来的。它不做生产级的事情目标很纯粹用最少的代码量把 React 最核心的工作机制表现清楚。1.2 范围界定只做核心不做外围既然目标是最小可用的 Core那“不做什么”跟“做什么”一样重要。我一开始就把功能边界画得很清楚避免自己陷入到框架外围的无底洞里去。rea 主要包含四块功能函数组件渲染用 vnode 描述结构通过 render 函数挂载到真实 DOM。一个基于微任务的调度器state 更新后自动触发重新渲染。一个简易 Diff 协调算法支持同层对比、key 匹配和最小化 DOM 操作。三个最常用的 HooksuseState、useEffect、useRef。明确不做的部分是Class 组件、Suspense、并发特性、服务端渲染、合成事件系统、Fragment、Portal。原因很简单这些东西每一个单拎出来都是大坑牵扯到的调度模型和数据结构都不一样。比如 Portal 会破坏 DOM 树与组件树的层叠关系Fragment 也需要额外处理“无父容器的多节点渲染”加上它们之后核心逻辑会被大量特判淹没反而不利于理解主干。有兴趣的朋友可以先想清楚React 的本质是“数据变了要更新视图”其余所有特性都是在这个主干上长的枝条我的 rea 就是只想让你清清楚楚看到这根主干。1.3 技术选型为什么用 TypeScript项目整体用 TypeScript 编写但全程没有引入任何第三方依赖。选择 TypeScript 的原因非常朴素这个项目涉及到大量的节点数据结构、标签类型定义和参数传递如果不加类型约束写到最后基本靠猜。给自己留几个关键的类型定义比如 VNode、Hook、Effect类型系统能在编译阶段就帮你拦住一大部分愚蠢的错误。我不建议新手直接上 JavaScript 来写这种软件骨架因为你在排查问题的时候一个 undefined 到底缺在哪、类型错了还是逻辑错了JS 完全不给提示调式体验会非常煎熬。工程初始化我用了 Vite 做开发服务器和构建工具主要是快而且配置少。这里要强调一下Vite 只是个便捷的宿主环境核心代码和它没有耦合关系你完全可以把它替换成任何你喜欢的打包工具甚至可以不开服务器直接写一个 HTML 文件引脚本就能跑。2. 虚拟 DOM 与初始渲染的完整链路2.1 vnode 的数据结构设计干前端这行的都知道一句话操作真实 DOM 是昂贵的能少碰就少碰。那怎么保证少碰React 的思路是先在内存里用普通 JavaScript 对象做一份“图纸”这份图纸就是虚拟 DOM。每一次状态更新不直接抠真实页面上的节点而是重新画一份新图纸然后把新旧图纸拿来做差异对比最终只把差异部分落到真实 DOM 上。所以 vnode 的数据结构是整个框架的地基。在 rea 里我把它设计成最朴素的四个字段export interface VNode { type: string | Function; props: Recordstring, any | null; children: VNode[]; key?: string | number | null; }type 代表节点的标签名比如 div、span如果这个节点是一个函数组件那么 type 就指向那个组件函数本身。props 是节点的属性集合包括 className、style、事件处理函数等。children 是一个数组用来存放子节点的 vnode。key 是列表 diff 时候用到的唯一标识这个后面展开说。这个设计基本上是把真实 DOM 里会变化的维度全部映射到了一个纯对象上。为什么是纯对象而不是类实例因为纯对象可以被安全地序列化、进行结构共享并且创建成本比较低。类实例附带方法和内部状态反而会搅乱数据流的纯粹性。“数据是数据逻辑是逻辑”是我在设计这个项目时反复提醒自己的原则。2.2 createElement 与 h 函数的实现在实际 React 编码中我们写的是 JSX但 JSX 最终会被编译成 React.createElement 调用。rea 没有走编译那一步我直接手写了一个 h 函数来充当 JSX 编译之后的目标函数。它做的事情就是接收标签类型、属性和子节点最后返回一个 vnode 对象。function h(type, props {}, ...children: any[]): VNode { const flatChildren children .flat() .filter((child) child ! null child ! false child ! true); return { type, props: props || {}, children: flatChildren.map((child) typeof child object ? child : createTextVNode(child) ), key: (props props.key) || null, }; }这里有几个细节值得好好说一下。第一个是 flatChildren 的展开与过滤。因为子节点可能是一个数组嵌套例如你通过 map 渲染一个列表map 返回的本身就是数组所以需要拍平一层。过滤掉 null、undefined 和布尔值是因为这些值在真实 React 中也不会渲染成任何 DOM 节点空保留它们只会让后续的 diff 判断多一堆情况要处理直接滤掉是性价比最高的做法。第二个细节是文本节点的处理。文本节点在真实 DOM 里是一个 Text 节点不算是普通元素。我在 h 函数里做了一个归一化基本类型的子节点会被转成一个特殊的 vnode 对象用 type 字段的 TEXT 来标记。这样一来后续的 DOM 创建和 diff 对比就只需要处理 VNode 对象这一种形态大幅减少分支判断。function createTextVNode(text: string): VNode { return { type: TEXT, props: { nodeValue: String(text) }, children: [], key: null, }; }2.3 render 挂载过程从 vnode 到真实 DOM虚拟 DOM 有了之后接下来最自然的问题就是怎么把它变成真实 DOM 塞进浏览器里。这一步我实现了 render 函数它接收两个参数一个是要渲染的 vnode 树和一个挂载点容器元素。function render(vnode: VNode | null, container: HTMLElement) { if (!vnode) return; container.appendChild(createDom(vnode)); }核心的 createDom 函数负责把单个 vnode 转换成对应的真实 DOM 节点。它首先要判断当前 vnode 到底是个函数组件还是普通元素。如果是函数组件那就调用这个函数拿到它返回的子 vnode然后递归地创建 DOM。如果是普通元素就创建对应的 HTMLElement接着把属性一一设置上去再对 children 数组做递归处理。function createDom(vnode: VNode): HTMLElement | Text { if (vnode.type TEXT) { return document.createTextNode(vnode.props?.nodeValue ?? ); } if (typeof vnode.type function) { const childVNode vnode.type(vnode.props); return createDom(childVNode); } const dom document.createElement(vnode.type as string); updateProps(dom, {}, vnode.props || {}); vnode.children.forEach((child) { dom.appendChild(createDom(child)); }); return dom; }看到这里你可能会说函数组件那里有 bug组件函数执每次都会返回一个新的 vnode那它的 state 存在哪这就涉及到了下一个大主题初始渲染后的更新机制到底怎么设计。每次更新如果都从根节点重新 createDom那页面会被全部销毁重建性能就完了。必须走 diff。3. 协调算法与更新调度3.1 调度器设计setState 之后发生了什么React 的核心循环是状态驱动视图但 setState 之后到底是怎么触发 Vue 那种“响应式”的在 rea 里我并没有做复杂的数据代理和依赖收集而是直接用一个订阅列表每个组件实例准确说应该是每个 fiber 节点但在 rea 里我用一个更朴素的“组件状态实例”来代替都注册自己的更新函数。setState 一调用就把它对应的更新任务丢到一个全局队列里然后通过一个微任务去统一执行。let updateQueue: Array() void []; let isFlushing false; function scheduleUpdate(updateFn: () void) { updateQueue.push(updateFn); if (!isFlushing) { isFlushing true; Promise.resolve().then(flushUpdates); } } function flushUpdates() { const queue [...updateQueue]; updateQueue []; isFlushing false; queue.forEach((fn) fn()); }这里为什么选择 Promise 微任务而不是 setTimeout原因在于微任务在同一个事件循环结束前就会执行。假设你在一个事件处理函数里连续调用了好几次 setState它们会被压到同一个微任务里一起 flush中间只会有一次重新渲染这就实现了天然的批量更新。如果用 setTimeout 宏任务你还要自己额外做去重合并而且用户会感受到一个明显的事件循环延迟渲染会略微偏慢。选择微任务本质上是“用最少代码实现合并更新”的最佳路径。批量更新这个概念很多人只停留在面试题层面觉得自己知道了“连续 setState 只会触发一次 render”。但在自己实现时你会发现你压根不用特意写类似 batchedUpdates 的逻辑只要利用好微任务这个天然批处理机制行为就自动达成了。这是我对“框架是顺应语言特性”这句话体会最深的一次。3.2 Diff 算法核心规则Diff 是整个框架算法密度最高的部分。这一步的输入是旧 vnode、新 vnode 和它们对应的真实 DOM 容器输出是一系列真实 DOM 增删改操作。React 的 Diff 之所以能做到 O(n) 复杂度是因为它做了三条非常务实的假设。一是不同层级的节点不会跨层级复用也就是说如果节点从同一层级移到了另一层在框架眼里就是删除加新增不会试图去“移动”它。二是同一层级下不同类型的元素直接重建。三是可以通过 key 来识别同一列表中的兄弟节点。我在 rea 里实现了两个层次的对比。第一层是对比单个节点。如果新旧节点的 type 不同直接替换整个子树。如果 type 相同则进行属性对比把所有需要在真实 DOM 上更新的属性批量执行。function diffChildren(oldVNode: VNode, newVNode: VNode, parent: HTMLElement) { const oldChildren oldVNode.children || []; const newChildren newVNode.children || []; const oldKeyMap new Map(); oldChildren.forEach((child, index) { oldKeyMap.set(child.key ?? index_${index}, { child, index }); }); let lastPlacedIndex 0; newChildren.forEach((newChild, newIndex) { const key newChild.key ?? index_${newIndex}; const matched oldKeyMap.get(key); if (matched) { updateElement(matched.child, newChild); oldKeyMap.delete(key); lastPlacedIndex Math.max(lastPlacedIndex, matched.index); } else { const dom createDom(newChild); parent.appendChild(dom); } }); oldKeyMap.forEach(({ child }) { if (child.dom child.dom.parentNode parent) { parent.removeChild(child.dom); } }); }第二层是对比属性。我在项目里明确了两种特殊属性style 对象需要摊平对比事件监听器需要做移除和绑定其他普通属性直接用 setAttribute 处理。这里有个比较坑的细节style 的对比不能简单粗暴地整对象覆盖否则之前设置但新状态里没有的属性不会失效。function updateProps(dom: HTMLElement, oldProps: any, newProps: any) { const oldKeys Object.keys(oldProps || {}).filter((k) k ! key k ! children); const newKeys Object.keys(newProps || {}).filter((k) k ! key k ! children); oldKeys.forEach((key) { if (!(key in (newProps || {}))) { if (key.startsWith(on)) { const eventName key.slice(2).toLowerCase(); dom.removeEventListener(eventName, oldProps[key]); } else if (key style) { Object.keys(oldProps[key]).forEach((styleKey) { dom.style[styleKey] ; }); } else { dom.removeAttribute(key); } } }); newKeys.forEach((key) { const value newProps[key]; if (key.startsWith(on)) { const eventName key.slice(2).toLowerCase(); dom.addEventListener(eventName, value); } else if (key style) { Object.entries(value).forEach(([styleKey, styleValue]) { dom.style[styleKey] styleValue; }); } else { dom.setAttribute(key, value); } }); }3.3 为什么 key 这么重要我在很多社区讨论里都看到过“为什么要用 key”的讨论说实话不亲手写一遍 diff 算法你很难真正理解 key 的价值。没有 key 的时候列表对比只能靠索引。索引对比有一个致命问题如果列表头部插入了新元素那后面所有元素的索引都变了框架会认为“这个位置的节点还在只是内容变了”于是把所有后续节点都执行一遍更新操作这不仅是性能浪费还可能因为一些节点内部状态被粗暴更新而出现显示错乱。用 key 之后新旧节点的匹配关系从位置变成了“身份”。就像调用函数的时候你按照座位号找人还是按照脸找人前者一旦座位调整就全乱后者不管怎么换座位都不会认错。所以在 rea 的 diff 里我用 Map 构建了一个基于 key 的旧节点查找表这比逐个遍历查找的效率高很多也是 diff 性能的关键点之一。4. Hooks 机制的核心实现4.1 useState状态存储与更新触发第一次把 hooks 和渲染流程打通的时候我激动了一会儿因为那个设计确实精巧。在 rea 中每个函数组件在渲染时会绑定一个钩子列表组件每次重新渲染时都维护一个指向这个列表的游标指针。let currentComponent null; let hooksCursor 0; function useStateT(initialState: T): [T, (value: T | ((prev: T) T)) void] { const hookIndex hooksCursor; const component currentComponent; const hook component.hooks[hookIndex] ?? { state: initialState, queue: [], }; hook.queue.forEach((action: any) { hook.state typeof action function ? action(hook.state) : action; }); hook.queue []; const setState (value: T | ((prev: T) T)) { hook.queue.push(value); scheduleUpdate(() { rerenderComponent(component); }); }; component.hooks[hookIndex] hook; hooksCursor; return [hook.state, setState]; }这段代码看着小但是触及了一个非常重要的设计规则为什么 hook 调用不能放在条件语句里因为所有的 hook 状态都是按照调用顺序存在数组里的。第一次渲染时调用 useState 依次把状态推到数组第 0、1、2 位。第二次渲染时框架假定你还按同样的顺序调用它才能把第 0 位状态准确对回到 useState 上去。一旦你某次渲染在 useContext 后面少调用了一次或者提前 return 了那后面的 hook 顺序就会整体错位状态自然就乱了。React 官方文档里那句话“hooks 必须在顶层调用”不是道德要求而是数据结构决定的硬约束。4.2 批量更新与状态合并你可能注意到 setState 内部直接存了一个“函数或值”的队列。通常我们强制值也可以但在同一个事件循环里连续 setState 时如果新状态依赖旧状态比如 setState(count count 1) 写三次那最终结果要正确加 3就必须把这三个函数按顺序执行一遍。这也是为什么函数式更新比直接传值更安全。rea 在渲染前会把所有待处理的 action 一次性 flush 掉保证最终计算出来的 state 是正确的。这里又引出一个组件实例存储在什么地方的问题。我选了一个比较简单粗暴的方式在 render 的时候遇到了函数组件就为它生成一个 component 实例对象塞进一个 map 里以它对应的真实 DOM 节点作为 id。rerenderComponent 的时候取出之前保存的 props 和 vnode 快照重新执行组件函数拿到新 vnode然后进入 diff 流程。听起来有种奇异感但就这么朴素的方案已经足够支撑日常使用了。4.3 useEffect在渲染完成之后做什么useEffect 的实现比 useState 更讲究时机。它的触发时机一定是在真实 DOM 更新完成之后不能阻塞渲染。我在 rea 里定义了一个待执行副作用队列组件渲染结束之后统一执行。function useEffect(effectFn: () void | (() void), deps?: any[]) { const hook currentComponent.hooks[hooksCursor] ?? { deps: undefined, cleanup: undefined, }; hooksCursor; const hasNoDeps deps undefined; const hasDepsChanged !hasNoDeps !areDepsEqual(hook.deps, deps); if (hasNoDeps || hasDepsChanged) { if (hook.cleanup) hook.cleanup(); currentComponent.effects.push({ run: () { hook.cleanup effectFn(); }, }); } hook.deps deps; }这里有几个决定行为正确性的细节。第一个是依赖比较必须用 Object.is 语义不能直接 或者用深比较。第二个是如果依赖数组是空数组那么副作用只会在挂载后执行一次这个效果需要专门判断首次渲染的时候 deps 是 undefined传入的 deps 是空数组因为 undefined ! []所以必然判定为 changed执行一次后续渲染都是 [] 和 [] 的 Object.is 比较值相同不执行。第三个是清理函数需要在上一次副作用重新执行前调用否则监听器会越叠越多出现内存泄漏。我第一次写这段的时候踩过一个很隐蔽的坑我在是否执行 effect 的判断里用了依赖数组的 JSON.stringify 深比较结果拿到的依赖对象每次都是新引用但 JSON 内容一样导致 effect 反复执行最后页面卡成了幻灯片。换成 Object.is 之后行为恢复正常。这种坑文档里面根本不会告诉你只有自己实现的时候才会留下深刻肌肉记忆。5. 完整实操从零搭建“rea”并跑通一个 Demo5.1 初始化工程与目录结构接下来是实操环节你会看到整个项目从空白文件夹到跑通 Demo 的全过程。第一步是初始化工程我用 Vite 创建一个 TypeScript 环境mkdir rea cd rea npm create vitelatest . -- --template vanilla-ts npm install装完以后把默认生成的 src 里面清干净自己按下面的目录结构重新整理。整个项目只依赖宿主环境自身的东西核心文件不 import 任何第三方包。这一点非常关键一旦在核心代码里引入了工具库你会不自觉地用几个 API 绕开真正的难点失去学习意义。rea/ ├── index.html └── src/ ├── core/ │ ├── vnode.ts │ ├── h.ts │ ├── render.ts │ ├── diff.ts │ ├── scheduler.ts │ └── hooks.ts ├── components/ │ └── App.ts └── main.ts模块之间是单向依赖的scheduler 不依赖 renderrender 依赖 diff 和 hhooks 依赖 scheduler 和当前组件状态。你要是画一下依赖图那一定是一个干净的有向无环图这也是我们后面排查问题时的导航图。5.2 核心实现步骤与关键代码按模块顺序一个一个看。首先是 vnode.ts 定义类型export type VNodeType string | Function | typeof TEXT_NODE; export const TEXT_NODE TEXT; export interface VNode { type: VNodeType; props: Recordstring, any | null; children: VNode[]; key: string | number | null; }接着是 h.ts构造 vnode 的工厂函数前面已经展示过。然后是 scheduler.ts微任务批量调度队列也展示过。真正关键的部分是 render.ts 与 diff.ts 如何协同。我把 updateElement 设计成递归核心函数function updateElement(oldVNode: VNode, newVNode: VNode) { if (!oldVNode || oldVNode.type ! newVNode.type) { const newDom createDom(newVNode); const parent oldVNode?.dom?.parentNode; oldVNode?.dom?.replaceWith(newDom); newVNode.dom newDom; return; } const dom (newVNode.dom oldVNode.dom); if (newVNode.type ! TEXT_NODE) { updateProps(dom as HTMLElement, oldVNode.props || {}, newVNode.props || {}); diffChildren(oldVNode, newVNode, dom as HTMLElement); } else { const newText newVNode.props?.nodeValue ?? ; if (dom?.textContent ! newText) { dom!.textContent newText; } } }这套递归逻辑虽然简短但有个隐藏的坑文本节点的对比不需要走 props 那套 setAttribute 流程直接对 textContent 赋值就行。第一次我把文本节点也走了一遍 updateProps导致每次更新都会重新设置 nodeValue 属性逻辑上没问题但是平白多了很多无意义的函数调用而且很难从代码里一眼看出为什么文本节点要特殊对待。单独处理之后文本更新路径只有一行判断效率极高可读性也高了一大截。5.3 hooks 模块的组装hooks.ts 需要和组件渲染流程打通。重新渲染组件时需要把 currentComponent 指向目标组件把 hooksCursor 重置为 0然后执行组件函数获取新的 vnode。function rerenderComponent(component: ComponentInstance) { const oldVNode component.vnode; const newVNode renderComponent(component); currentComponent component; hooksCursor 0; component.effects.forEach((ef) ef.run()); component.effects []; currentComponent null; updateElement(oldVNode, newVNode); }这里面的顺序很有讲究先拿到新 vnode然后执行所有副作用最后再做 diff 更新。为什么要先执行副作用因为 useEffect 里经常会操作真实 DOM 或者订阅事件这些操作应该发生在页面实际更新之前还是之后按照 React 的语义effect 发生在 commit 之后也就是说新 DOM 已经体现在页面上之后才是 effect 的舞台。所以在 rea 里我先更新了 DOM再执行 effects保证用户在 effect 里读取到的 DOM 是最新的。如果你把顺序搞反了effect 里拿到的永远是旧 DOM调试时会发现怎么都滞后一步。5.4 一个 Demo 把整个框架串起来最后写一个简单但覆盖全的 Demo一个计数器组件内部用到了 useState同时在 useEffect 里更新了文档标题另外还有一个列表组件演示 key 在 Diff 里的作用。function Counter(props: { title: string }) { const [count, setCount] useState(0); const [items, setItems] useState([A, B, C]); useEffect(() { document.title Count: ${count}; }, [count]); const addItem () { setItems((prev) [X${prev.length}, ...prev]); }; return h( div, { className: counter }, h(h1, null, props.title), h(p, null, 当前数值: ${count}), h( button, { onClick: () setCount((c) c 1), }, 加一 ), h( button, { onClick: addItem, }, 头部插入一项 ), h( ul, null, items.map((text, index) h(li, { key: ${text}_${index} }, text) ) ) ); } render(h(Counter, { title: hea 框架测试 }), document.getElementById(app));跑起来之后点击“加一”和“头部插入一项”页面正常渲染列表头部插入一项时其他项不会被整体重建这就是 key 和 diff 生效了。你可以打开浏览器开发者工具的 Performance录一段操作查看真实 DOM 节点是否被频繁创建销毁。我实测下来插入一项只创建一个新的 li 节点其余节点全部复用Diff 算法的正确性得到了实打实的验证。6. 常见问题与排查实录写这个框架期间我至少修过十几个隐蔽的 bug现在把最容易误导人的几类问题整理成一张速查表。这些问题在真实 React 里你不会碰到因为官方框架已经帮你规避了但你迟早会在自己动手实现的时候把它们一个个踩完。现象根本原因解决方案与避坑点击按钮页面完全不更新调度器没用微任务或者 setState 没有触发 scheduleUpdate检查 setState 是不是准确把更新任务推进了全局队列是不是被条件判断挡掉了更新后整个页面闪一下/白屏diff 判断类型不同导致整棵子树替换检查函数组件更新时 oldVNode 是不是 null初次挂载和后续更新是否走对了分支列表渲染顺序错乱或者状态错位key 缺失或者 key 用了不稳定的值保证 key 唯一且稳定不要用 index 作为唯一标识useEffect 在依赖没变时反复执行依赖数组比较用成了深比较或长度比较改用 Object.is 逐项比较且只比较引用组件内部状态丢失组件函数重新执行时对应的 hooks 容器没有正确绑定检查 currentComponent 在进入渲染函数之前是否赋值渲染完是否重置事件绑定重复叠加useEffect 清理函数没正确执行重点检查 cleanup 函数是否在依赖变化前被调用未定义时是否用了 undefined 去调用6.1 调试手段与工具链建议调试这种项目我的经验是第一优先使用“回归式检查”每个功能模块写出来之后不要急着往下走先用一个极小的测试页面把它验证掉。比如写完 h 函数直接 console.log 一个 vnode 看看结构对不对写完 render挂载一个最简单的 hello world写完 diff先手动构造两个 vnode 对比观察 DOM 变化最后才把 hooks 接上。第二是善用浏览器开发者工具的 Performance 面板。录一段交互观察是不是有重复的节点创建记录如果有说明你的 diff 复用逻辑没生效。还有一个非常拙笨但极其有效的办法给 createDom 函数内部加一行 console.count统计真实 DOM 节点创建次数。如果每次更新它都在增长那 diff 肯定存在“路径丢失”问题。另外一个认知建议是不要试图在主循环代码里写太多防御逻辑。我去检查某个 bug 的时候容易陷入一种思维 — 不断往代码里加 if 来规避可能出现的问题。这会让核心逻辑越来越臃肿到最后你根本分不清哪些分支是必要的哪些只是因为害怕出错。正确做法是先保证主干逻辑足够清晰再回头针对特定边界补判断。如果你的主干代码里出现了一堆条件分支大概率是在掩盖设计缺陷而不是在解决问题。6.2 与真实 React 的差距写完之后我曾经把这套代码拿给一个懂 React 源码的同事看他第一时间指出的问题是“你的 diff 不会复用移动的节点。”确实rea 的 diff 对于同一层级的兄弟节点匹配规则是“按 key 找对象找不到就新建”它不会做节点位置的移动优化。列表头部插入一项时真实 React 会尽量复用原本所有节点然后只调整它们的位置而 rea 的行为是把所有没有匹配上的旧节点删掉、新节点重新创建虽然比全量重建好了不少但并不是最优解。要真正意义上实现节点移动需要引入最长递增子序列算法来计算最优移动序列那已经是另一个复杂度的博弈了。7. rea 的边界与后续扩展指南7.1 如果你想基于它继续深入如果你是看了这篇文章之后自己也想动手写一个我的建议是先把主干跑通不要一开始就搞复杂特性。主干稳定后再尝试按下面的顺序扩展支持 useMemo 和 useCallback。核心是加一个依赖数组和缓存值的存储同时要注意这两个 hook 内部如果使用 useState需要重新调度。支持 Fragment。需要改一个地方函数组件如果返回一个数组的 vnode那它的挂载过程就不能直接 createDom得改为创建一组兄弟节点。支持 ref 转发和 DOM 引用。用 useRef 存一个普通对象挂载后把真实 DOM 塞进去卸载时置空。实现调度优先级。把微任务队列替换成支持优先级排序的调度堆让紧急更新插队这就开始向 React 18 的并发特性靠拢了。每个扩展都不是独立的它们会不断迫使你把核心数据结构加固。比如接了 Fragment 之后你的 vnode 树就不再是一棵树了而可能是一个虚拟根下有多个子树的森林这要求 render 函数支持多根挂载。这些连锁反应恰恰是学习框架最好的部分你永远在“偷懒”和“妥协”之间做权衡最后你会理解所有框架的特性背后都是数据结构撑起来的而不是“别人有意加了一堆玄学 API”。7.2 进一步学习的话还有什么方向如果你不想继续写框架了想回去读 React 源码你会发现之前看不懂的东西开始变得亲切。你会知道 Fiber 树的每一个节点大概对应着什么priority 调度器为什么存在hook 的链表结构到底是怎么穿起来的。有条件的朋友还可以去读一读各个社区框架的实现比如类 React 框架 preact它的核心代码经过高度压缩注释也少但有了 rea 的手写经验之后你会觉得它的每一条分支都能对上你踩过的坑。这是一个很正向的循环。我个人在实际操作中的最大体会是写完 rea 之后我对“框架”这个词的理解完全变了。它不再是某个团队写出来的神秘黑盒而是由几个朴素的数据结构和算法拼起来的有机体。每个 API 背后都有一个活生生的场景在驱动。你不可能期待通过读一次源码就达到这个理解层级最好的路径永远是亲手把它做出来哪怕做出来的东西只值 200 行代码。最后再分享一个小技巧你在实现任何一个系统的时候给自己设置一个“必须能对别人讲清楚”的目标你会发现自己会主动去简化代码、清理边界因为讲不清楚的地方就是你还没想明白的地方。希望这篇项目记录能给你一点动力去做一个属于你自己的 rea。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑