☰
从零手写迷你渲染库:拆解虚拟DOM与diff更新原理
2026/10/18 9:04:46 网站建设 项目流程

我在整理内部一个小型视图运行时的时候,项目代号一直叫 rea。这名字看起来只有三个字母,但它几乎逼着我把“前端框架到底在做什么”重新想了一遍。说白了,rea 是我为了搞懂声明式 UI 更新原理,从零手写的一个迷你渲染库,核心代码只有两百多行。它没有完整的调度器,也没有复杂的性能优化,但它有一套从数据变化到页面更新的完整链路:状态变更、生成新 vnode、对比旧 vnode、把差异写回 DOM。这篇文章适合两类人:一类是写了大量业务代码、但一到框架底层就发怵的人,另一类是准备啃框架源码、却被庞大代码库劝退的朋友。如果你想自己实现一个“最小可用”的渲染器,rea 这套思路可以当起点。

1. 从一次组件状态事故说起:rea 诞生的动机

1.1 现场复盘:筛选联动里的 DOM 同步事故

事情源于我做过的一个后台管理页面:左边是筛选条件表单,右边是对应的列表数据。刚开始只有三个筛选条件,我用原生 JavaScript 直接操作 DOM:选择框变化时读一下值,重新请求列表,再手动更新表格节点。早期逻辑很直观,代码也能跑。

后来需求开始膨胀:增加了重置按钮、分页、排序字段,筛选条件之间还有联动。比如选了某个部门后,搜索框里会出现对应的候选标签。到这一步,DOM 更新点开始分散在各个事件回调里。真正翻车是在一次联调测试中——用户先选了条件 A,列表刷新到第三页,然后点击重置。结果页面上的 select 选项确实被清空了,但表格数据仍然停留在按条件 A 过滤后的状态,分页也停在第三页。后来我排查发现:重置操作触发了两个后续动作,清空筛选条件和恢复默认列表顺序,两个动作都异步修改了同一个表格区域,而最后一次返回的请求结果覆盖了前面所有状态,导致页面显示成了“条件已空但数据没复位”。

这个问题本质上是把数据状态和视图状态的同步逻辑散落在 DOM 事件里,没有统一入口。每次新增一个联动,我就要重新检查所有可能改 DOM 的路径,哪怕只是加一个“保存为常用筛选”按钮,也可能引发新的覆盖冲突。

1.2 为什么我选择写一个“微型渲染库”而不是直接上框架

当时团队里也有人建议直接引入现成框架,这样所有更新都走框架的响应式机制,确实能解决上述问题。但我的顾虑在于:业务代码已经和原有 DOM 结构深度绑定,直接切换框架意味着大量重写;而且即便引入了框架,我依然解释不了内部是怎么处理“连续多次状态变化”和“异步结果覆盖视图”的。我不想把问题从一个黑盒挪进另一个黑盒。

所以我决定写一个非常小的渲染库,目标不是替代框架,而是把之前手写 DOM 同步时那些混乱点收拢到一个可控的更新流程里。rea 这个名字也是这时候定下来的:它既可以看成 reactive 的缩写,也提醒我“让更新重新归位”。我给自己划了一条很清楚的边界:rea 只做三件事,用 vnode 描述视图结构,用 diff 找出新旧差异,用 patch 把差异写回 DOM。其他能力比如请求逻辑、本地存储、事件代理,全部交给业务层自己处理。

这个边界非常重要。因为项目规模越小,出问题时需要检查的代码范围就越小。当页面再出现“数据和视图对不上”的时候,我只需要在一个更新函数里观察比较过程,而不用在所有事件回调里猜状态。

2. rea 的总体设计:vnode、渲染与更新的一次重组

2.1 一条更新流水线:状态到视图的必经之路

rea 的核心假设很简单:视图是状态的函数。给定一份状态,就能得到一份视图描述;当状态变化时,重新执行一次视图描述生成,然后和旧描述做对比,只修改真正变化的部分。

为了让这个假设落地,rea 用 vnode 作为视图描述的数据结构。每个 vnode 有三个基础字段:type 表示节点类型、props 表示属性、children 表示子节点。type 既可以是普通的标签字符串(比如 div、ul、li),也可以是一个函数,用来表示组件。当 type 是函数时,rea 会在渲染时调用这个函数,得到下一层 vnode,再继续递归展开。

在这种设计下,数据流天然是单向的。业务代码不会直接修改某个 DOM 节点的文本或 class,只能通过统一的更新入口修改状态。更新入口触发后,rea 从组件根节点重新取一遍新 vnode,再进入 diff/patch 流程。所有 DOM 操作都被限制在 patch 这一步,而 patch 的逻辑又完全由 diff 的结果驱动,不需要业务代码介入。

2.2 为什么是 vnode + diff,而不是全量重建或手动修补

有人可能会问:为什么不直接销毁旧 DOM,然后重建一遍?那样不是更简单吗?确实,对很小的页面来说,全量重建能跑通。但它有几个比较麻烦的副作用:输入框会丢失焦点、滚动位置会复位、页面上的临时 DOM 状态(比如手动展开的折叠面板)会全部重置。对富交互的后台页面来说,这些体验问题很难接受。

手动修补则是另一个极端。它的问题是每次都要精确定位变化的节点,代码演变成一长串 getElementById 加条件判断。在多人维护的项目里,这种做法最大的问题是没有约束,谁也不知道某个节点是否还被其他模块操作。

vnode + diff 的思路介于两者之间:用 JavaScript 对象描述结构,比较 JS 对象本身比反复读写真实 DOM 要便宜得多;浏览器不需要因为一次细小变化就触发大面积重排。同时,diff 的结果天然记录了“这次更新做了什么”,调试时可以打印新旧 vnode 差异,也可以把 diff 过程可视化。我经常用一个类比向同事解释:真实 DOM 是装修好的墙,vnode 是装修图纸。改方案时先改图纸,再对比图纸前后差异,决定哪几面墙要拆,而不是每天拿着锤子直接砸墙。

3. 手写 rea 最小核心:createElement 到 patch 的完整链路

3.1 数据结构:为了让 diff 知道自己在比较什么

rea 的入口是一个仿照常见声明式框架风格定义的 createElement 函数。它接收节点类型、属性和子节点参数,经过扁平化和过滤后生成 vnode:

function createTextNode(text) { return { type: 'TEXT', props: { nodeValue: String(text) }, children: [] }; } function createElement(type, props, ...children) { const flatChildren = children .flat(Infinity) .filter((child) => child !== null && child !== undefined && child !== false); return { type, props: props || {}, children: flatChildren.map((child) => typeof child === 'object' ? child : createTextNode(child) ), }; }

这里有两个细节值得注意。第一,flat(Infinity) 是为了处理嵌套数组形式的子节点,比如 map 方法返回的数组可以直接展开。第二,过滤掉 null、undefined、false,是为了方便条件渲染,避免用户在 JSX 或渲染函数里写false && <div />这种表达式时生成多余节点。把文本统一包装成 type 为 TEXT 的 vnode,是为了让 diff 逻辑只用关心节点类型是否一致,不需要为字符串单独写一套分支。

3.2 首次挂载:createDOM 如何把 vnode 变成真实节点

当拿到一个完整的 vnode 树之后,rea 的 render 函数负责把它挂载到容器里。这一版 createDOM 用来演示首次挂载就好,组件实例和更新入口会在后面的章节补上:

function render(vnode, container) { const dom = createDOM(vnode); container.appendChild(dom); return dom; } function createDOM(vnode) { if (vnode.type === 'TEXT') { const dom = document.createTextNode(vnode.props.nodeValue); vnode.dom = dom; return dom; } if (typeof vnode.type === 'function') { const componentVNode = vnode.type(vnode.props || {}); const dom = createDOM(componentVNode); vnode.dom = dom; return dom; } const dom = document.createElement(vnode.type); vnode.dom = dom; setProps(dom, vnode.props); for (const child of vnode.children) { dom.appendChild(createDOM(child)); } return dom; } function setProp(dom, name, value) { if (name.startsWith('on') && typeof value === 'function') { dom[name.toLowerCase()] = value; } else if (name === 'className') { dom.setAttribute('class', value); } else if (name === 'style' && typeof value === 'object') { Object.assign(dom.style, value); } else { dom.setAttribute(name, value); } }

每个 vnode 的 dom 字段会在创建 DOM 后回填,这为之后的 diff 提供了直接从 vnode 找到旧 DOM 的能力。事件属性用dom[name.toLowerCase()] = value的方式赋值,是因为事件监听器需要被覆盖而不是叠加,直接赋给对应的 onclick 这种原生属性,新 handler 会替换旧 handler,避免旧监听器泄漏。

3.3 更新阶段:递归 patch 时 keep/ replace 的判定规则

首次挂载完成后,页面里的视图已经由 vnode 树生成。后续状态更新时,rea 需要拿“新 vnode 树”和“旧 vnode 树”对比。为了控制复杂度,rea 的初版没有引入复杂的任务调度,而是用同步递归完成 diff。这里简化后的核心思想如下:

function update(parentDom, oldVNode, newVNode, index = 0) { const oldDom = parentDom.childNodes[index]; if (!oldVNode) { if (oldDom) parentDom.removeChild(oldDom); if (newVNode) parentDom.appendChild(createDOM(newVNode)); return; } if (!newVNode) { parentDom.removeChild(oldDom); return; } // 节点类型不同:直接替换整棵子树 if (oldVNode.type !== newVNode.type) { const newDom = createDOM(newVNode); parentDom.replaceChild(newDom, oldDom); newVNode.dom = newDom; return; } // 文本节点:只比较文本内容 if (oldVNode.type === 'TEXT') { if (oldVNode.props.nodeValue !== newVNode.props.nodeValue) { oldDom.nodeValue = newVNode.props.nodeValue; } newVNode.dom = oldDom; return; } // 相同标签:更新属性,再递归更新子节点 updateProps(oldDom, oldVNode.props, newVNode.props); newVNode.dom = oldDom; const oldChildren = oldVNode.children; const newChildren = newVNode.children; const maxLen = Math.max(oldChildren.length, newChildren.length); for (let i = 0; i < maxLen; i++) { update(oldDom, oldChildren[i], newChildren[i], i); } }

这段 update 函数的名字听起来像新手写的,但它的判定规则很简单:类型不同就替换,类型相同就更新属性并深入子节点。文本节点单独处理是因为它的“内容”存放在 props.nodeValue 里,没有子节点可以递归。

updateProps 需要特别注意“删除旧属性”这件事,否则会出现很诡异的残留状态:

function updateProps(dom, oldProps = {}, newProps = {}) { for (const name of Object.keys(oldProps)) { if (!(name in newProps)) { if (name.startsWith('on')) { dom[name.toLowerCase()] = null; } else { dom.removeAttribute(name === 'className' ? 'class' : name); } } } for (const name of Object.keys(newProps)) { if (oldProps[name] !== newProps[name]) { setProp(dom, name, newProps[name]); } } }

遍历旧 props 的时候,凡是新 props 里不存在的属性都要删除。这一点很容易漏写,因为首次创建 DOM 时 setProps 只负责添加属性,不会让你意识到更新时还需要清理。我在 rea 早期版本里就吃过大亏:一个输入框的外层容器曾因为新旧 props 不一致,残留了一个 title 属性,用户鼠标悬停时弹出完全过期的提示。

4. 列表与调度:diff 里最容易翻车的三个细节

4.1 没有 key 的列表更新为什么会出现状态串位

上面的 update 函数是按索引逐个比较子节点的。对静态列表来说没有问题,但一旦列表内容顺序变化,问题就来了。

假设旧列表是 [A, B, C],新列表是 [D, B, C],D 被插入到最前面,A 被删掉了。如果按索引比较,索引 0 的旧节点是 A,新节点是 D,两个节点结构完全相同(都是表格行),type 也一样。于是 rea 会认为“这个节点没变,只需要更新文本”,然后直接把 A 对应的那个 DOM 行改成 D 的内容。

从最终渲染结果看,页面上确实显示了 [D, B, C],文字是对的。但问题出在这里:如果 A 这一行内部有一个输入框,用户已经在里面输入了内容;或者这一行对应某个子组件的状态,那么这些状态并不会被重置,而是跟着 DOM 保留下来。此时用户看到的可能是输入框里还残留着 A 行旧数据的输入内容。列表元素的身份被索引绑架了,而不是按内容去匹配节点。

4.2 我的 key 策略:Map 重排比双端对比更省事也够用

解决这个问题需要引入 key。每个 vnode 上如果带一个稳定且唯一的 key,rea 就能在更新时先找旧 vnode 列表里有没有相同 key 的节点;有就直接复用其 DOM,没有才创建新 DOM。rea 的实现方向是先把旧 children 按 key 放进 Map,然后遍历新 children 做复用和移动:

function updateChildren(parentDom, oldChildren, newChildren) { const oldMap = new Map(); oldChildren.forEach((child, index) => { const key = child.props && child.props.key != null ? child.props.key : index; oldMap.set(key, { child, index }); }); let lastPlacedIndex = -1; newChildren.forEach((newChild, newIndex) => { const key = newChild.props && newChild.props.key != null ? newChild.props.key : newIndex; const matched = oldMap.get(key); if (matched) { const { child, index } = matched; update(parentDom, child, newChild, newIndex); if (index < lastPlacedIndex) { const nextDom = parentDom.childNodes[newIndex + 1] || null; parentDom.insertBefore(child.dom, nextDom); } lastPlacedIndex = Math.max(lastPlacedIndex, index); oldMap.delete(key); } else { const dom = createDOM(newChild); const nextDom = parentDom.childNodes[newIndex + 1] || null; parentDom.insertBefore(dom, nextDom); } }); oldMap.forEach(({ child }) => { if (child.dom && child.dom.parentNode) { child.dom.parentNode.removeChild(child.dom); } }); }

这段代码里的 lastPlacedIndex 是经典移动策略:只有当一个新节点的旧索引,小于已经处理过的最大旧索引时,才需要把 DOM 移动到新位置。因为从前往后遍历时,如果旧索引一直在递增,说明节点相对顺序没变,不需要移动;一旦出现“旧索引变小”,说明它被前移了,这时用 insertBefore 搬到正确位置。

我没有在 rea 里实现双端对比那种更激进的 DOM 复用优化。原因很现实:rea 面向的是普通管理后台,列表长度基本在几十到几百条,Map + 单次遍历的复杂度已经足够,代码可读性还更好。如果以后遇到巨型列表,或者大量插入/删除的场景,再考虑更高级的对比策略也不迟。

key 本身的选择也很重要。不要用数组下标当 key,原因上面已经看到;更不要用随机数当 key,那会让每次更新都匹配不上,等于强制重建。最好的 key 是业务上稳定的唯一标识,比如用户 id、订单号。

4.3 连续多次 setState 如何合并成一次视图刷新

如果没有调度机制,每次状态更新都立刻执行同步 diff,那在一个循环里连续更新 100 次状态,rea 就会 diff 100 次。这不仅浪费性能,还会让中间态被多次渲染到真实 DOM 上,用户可能看到闪烁。

rea 的解决办法很朴素:把更新请求放进队列,然后用微任务统一冲刷。同一轮事件循环里的多次 setState 会被合并成一次更新:

let scheduleRunning = false; let updateQueue = []; function scheduleUpdate(instance) { if (!updateQueue.includes(instance)) { updateQueue.push(instance); } if (!scheduleRunning) { scheduleRunning = true; Promise.resolve().then(() => { const queue = updateQueue.slice(); updateQueue = []; scheduleRunning = false; for (const item of queue) { item.update(); } }); } }

为什么用 Promise.resolve().then 而不是 setTimeout?因为微任务会在当前同步代码全部执行完之后立刻运行,时间上比 setTimeout 更确定,也比 requestAnimationFrame 更可控,不会受浏览器帧率影响。这样,业务层连续调用多次 setState 时,rea 会把同一个组件实例去重后只冲刷一次。

这套调度逻辑没有处理“高优先级更新打断低优先级更新”的问题,也没有并发渲染的概念。对中小型页面来说已经足够可靠。我在使用中体会到,最好不要在一开始就想着把调度器做得像生产级框架那样复杂,因为调度器会引入大量时序边界问题,排错成本很高。

5. 给 rea 装上 hooks:状态记忆与副作用时机

5.1 useState 是怎么记住旧值的:当前实例与序号存取

组件函数在每次渲染时都会重新执行,函数内的局部变量会被销毁。那 useState 返回的值为什么还能保留?关键不在于 useState 本身,而在于 rea 在渲染某个组件时,会把“当前正在渲染的组件实例”临时挂到全局变量上。组件里调用 useState 时,rea 根据调用顺序访问实例上保存的 hooks 数组:

let currentComponent = null; let hookIndex = 0; function useState(initialValue) { const instance = currentComponent; const index = hookIndex; if (instance.hooks.length === index) { instance.hooks.push( typeof initialValue === 'function' ? initialValue() : initialValue ); } const setValue = (nextValue) => { const value = typeof nextValue === 'function' ? nextValue(instance.hooks[index]) : nextValue; instance.hooks[index] = value; scheduleUpdate(instance); }; hookIndex += 1; return [instance.hooks[index], setValue]; }

注意 setValue 里的函数式更新:如果传入的参数是函数,rea 会把旧值传进去,用返回值作为新状态。这对依赖旧值的多次连续更新特别有用,比如计数器连续加三次时不会因为闭包捕获旧值而只加一次。

组件渲染完毕之后,rea 会把 currentComponent 和 hookIndex 归零,保证下一个组件的 hooks 从 0 号开始存取。这套机制的前提是:同一组件的多次渲染,hooks 的调用顺序必须保持一致。

5.2 useEffect 的触发时机与依赖对比的简单实现

useEffect 需要解决两个问题:什么时候执行回调,以及依赖数组变化时如何判断“变化”。rea 的实现是先在渲染阶段收集 effect,等 DOM 更新完成后再统一执行:

const pendingEffects = []; function useEffect(callback, deps) { const instance = currentComponent; const index = hookIndex; const prevDeps = instance.hookDeps[index]; let changed = true; if (prevDeps !== undefined && deps) { changed = deps.length !== prevDeps.length || deps.some((dep, i) => dep !== prevDeps[i]); } if (changed) { pendingEffects.push({ instance, index, callback, deps: deps || [], }); } hookIndex += 1; }

为什么要在 DOM 更新后再执行 effect?因为大多数 useEffect 的回调都会访问真实 DOM,比如测量元素宽度、绑定事件、读取滚动位置。如果你在渲染阶段就执行,拿到的还是旧 DOM 状态。rea 在 flushUpdates 的最后一段会调用 commitEffects,把所有 pendingEffects 按顺序执行:

function commitEffects() { for (const effect of pendingEffects) { effect.instance.hookDeps[effect.index] = effect.deps; if (effect.instance.hookCleanups[effect.index]) { effect.instance.hookCleanups[effect.index](); } const cleanup = effect.callback(); if (typeof cleanup === 'function') { effect.instance.hookCleanups[effect.index] = cleanup; } } pendingEffects.length = 0; }

这里解决了两个关键问题:依赖没有变化时,回调不会重复执行;effect 返回的 cleanup 函数会在下一次 effect 运行前执行。为什么要保存 cleanup?因为常见的场景是事件绑定后需要解绑、定时器启动后需要清除。如果只执行回调而不清理上一次的副作用,组件频繁更新时会产生越来越多重复监听器和定时器。

需要特别注意 deps 没有传的情况。此时 changed 始终为 true,effect 每次渲染后都会执行。如果你写上useEffect(callback, []),则依赖永远不变,回调只会在首次挂载后执行一次。这也是很多性能问题的根源:依赖数组里漏写了某个状态,回调里读了它,却拿不到最新值。

5.3 hooks 的调用顺序为什么不能变:一次串值故障还原

我在 rea 的一个 Demo 组件里犯过典型的顺序错误。组件列表项默认不展开详情,只有用户点击后展开,而展开逻辑里我调用了额外的 useState:

function ListItem({ item, expanded }) { const [collapsed, setCollapsed] = useState(true); if (expanded) { const [detail, setDetail] = useState(''); // 使用 detail 的联动逻辑 } return ...; }

看起来没什么问题,但展开状态变化时,第二个 hooks 的存在与否改变了 hookIndex 的推进路径。第一次渲染时 expanded 为 true,调用两个 hooks,hookIndex 走到 2;收起后 expanded 为 false,只调用一个 hooks,hookIndex 走到 1。下一次再展开时,第一个 useState 拿到的却是之前第二个 useState 存进去的值,两个状态完全串位。

这段故障让我彻底理解了为什么 hooks 不能写在条件语句和循环里。按序号存取是极简实现,而任何跳过 hooks 的写法都会破坏序号与被存值的一一对应关系。真实框架也会类似地检查 hooks 链表是否对齐,只是在实现上包裹了更多容错逻辑。这类问题之所以可怕,是因为它不一定当场报错,而是等到下一次状态更新时悄悄产生错误数据。

6. 从 rea 沉淀下来的六个判断与一个起点

6.1 三个高频 bug:属性残留、副作用泄漏与事件监听覆盖

写 rea 的过程中,我遇到的 bug 基本都是三种类型,而它们恰好在真实项目里也经常出现:

第一种是属性残留。新旧 vnode 的 props 对比时只更新了“存在变化”的属性,没有删除“新 props 里已经不存在”的属性。表现是 DOM 上挂着过期属性,比如 title、aria-label 或一个旧的 style 值。修复方式是 updateProps 先遍历旧 props 删除缺失项,再遍历新 props 设置变化项。

第二种是副作用泄漏。组件从页面上移除时,它内部通过 useEffect 或事件绑定注册的定时器、监听器没有清理。rea 初版没有组件卸载钩子,路由切换后定时器依然在跑,某个接口回调还会错误地 setState 一个已经卸载的组件。修复方式是组件卸载时执行所有 hookCleanups,并把状态更新标记为“已卸载”后直接忽略。

第三种是事件监听覆盖。用 addEventListener 绑定同一个事件的多个 handler 时,如果不记录旧 handler,直接 removeEventListener 会删不掉,导致点击一次触发多次更新。rea 最终选择了原生 onclick 属性赋值方式,用覆盖代替增删,避免了这个坑。

6.2 接下来可以扩展的方向:边界、并发与 SSR

rea 目前只能算一个教学级渲染器,但它已经留好了几个可以继续深入的方向。第一个是错误边界:当组件函数抛异常时,如何让父级捕获并渲染降级 UI。现在 rea 的 createDOM 遇到函数抛错会直接断掉整棵树的渲染,加上边界处理需要把组件渲染包在 try/catch 里,并向上传递错误标记。第二个是并发更新:目前的调度能力只有微任务合并,还缺少可中断渲染和优先级调度,这块复杂度提升会非常明显。第三个是服务端渲染:如果能不依赖 document 而直接把 vnode 树序列化成 HTML 字符串,rea 就能用于首屏渲染场景,这需要把所有 DOM 操作集中到一个可替换的宿主层里。

如果你也想动手写一个类似 rea 的项目,我的建议是别急着加功能,先把它跑在一个只有表单和列表的小页面上,亲手走通一次 setState 到 diff 再到 patch 的完整循环。等这条路走顺了,你再回头看那些大型框架的源码,会发现很多抽象都没那么神秘了。我曾经以为虚拟 DOM 和 diff 是极高的门槛,真正写下来才明白,门槛不在算法的复杂度,而在“是否愿意把一个看起来理所当然的过程拆成一步一步的规则”。rea 教会我的,也正是这件事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询