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

从零手写迷你渲染库:拆解虚拟DOM与diff更新原理

从零手写迷你渲染库:拆解虚拟DOM与diff更新原理 ★ FEATURED ARTICLE
我在整理内部一个小型视图运行时的时候项目代号一直叫 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 函数。它接收节点类型、属性和子节点参数经过扁平化和过滤后生成 vnodefunction 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 上如果带一个稳定且唯一的 keyrea 就能在更新时先找旧 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 始终为 trueeffect 每次渲染后都会执行。如果你写上useEffect(callback, [])则依赖永远不变回调只会在首次挂载后执行一次。这也是很多性能问题的根源依赖数组里漏写了某个状态回调里读了它却拿不到最新值。5.3 hooks 的调用顺序为什么不能变一次串值故障还原我在 rea 的一个 Demo 组件里犯过典型的顺序错误。组件列表项默认不展开详情只有用户点击后展开而展开逻辑里我调用了额外的 useStatefunction ListItem({ item, expanded }) { const [collapsed, setCollapsed] useState(true); if (expanded) { const [detail, setDetail] useState(); // 使用 detail 的联动逻辑 } return ...; }看起来没什么问题但展开状态变化时第二个 hooks 的存在与否改变了 hookIndex 的推进路径。第一次渲染时 expanded 为 true调用两个 hookshookIndex 走到 2收起后 expanded 为 false只调用一个 hookshookIndex 走到 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 接下来可以扩展的方向边界、并发与 SSRrea 目前只能算一个教学级渲染器但它已经留好了几个可以继续深入的方向。第一个是错误边界当组件函数抛异常时如何让父级捕获并渲染降级 UI。现在 rea 的 createDOM 遇到函数抛错会直接断掉整棵树的渲染加上边界处理需要把组件渲染包在 try/catch 里并向上传递错误标记。第二个是并发更新目前的调度能力只有微任务合并还缺少可中断渲染和优先级调度这块复杂度提升会非常明显。第三个是服务端渲染如果能不依赖 document 而直接把 vnode 树序列化成 HTML 字符串rea 就能用于首屏渲染场景这需要把所有 DOM 操作集中到一个可替换的宿主层里。如果你也想动手写一个类似 rea 的项目我的建议是别急着加功能先把它跑在一个只有表单和列表的小页面上亲手走通一次 setState 到 diff 再到 patch 的完整循环。等这条路走顺了你再回头看那些大型框架的源码会发现很多抽象都没那么神秘了。我曾经以为虚拟 DOM 和 diff 是极高的门槛真正写下来才明白门槛不在算法的复杂度而在“是否愿意把一个看起来理所当然的过程拆成一步一步的规则”。rea 教会我的也正是这件事。
阅读完成 · 觉得有帮助?
咨询建站