资讯动态

手写迷你渲染引擎:从零实现虚拟DOM与diff更新

发布时间:2026/10/21 5:46:52 来源:尧图企业网站定制
三个字母的代号“rea”对我来说不是某个框架的缩略语而是我亲手写过的一个迷你渲染引擎。全称我习惯叫它 reactive rendering engine architecture也有点“重新思考渲染”的意思。这篇文章想完整复盘它为什么写、怎么设计、核心模块怎么实现、实际写 Demo 时踩过哪些坑以及最终我从这个项目里真正理解了什么。如果你也被某些主流视图库的源码劝退过想彻底搞懂虚拟 DOM 和状态更新的底层链路这个项目应该对你有用。1. 项目动机为什么我会手写一个渲染引擎1.1 啃源码啃到怀疑人生索性自己写一个很久之前我有段时间在翻某知名视图库的源码目标是一个月内把它的渲染流程理清楚。结果越看越不对劲外面一层 API 看起来很简单内部却藏着调度优先级、可中断渲染、过期任务处理、不同平台适配。我这种半吊子水平看几层抽象之后就头晕了。后来我换了个思路与其从源码入口往下钻不如反过来从零往上写一个“只解决核心问题”的渲染引擎。于是就有了 rea。它不是要复刻某个框架的全部能力而是要复现一条最核心的链路——状态变了界面怎么跟着变。这条链路一旦亲手实现过再回头读源码那些抽象层的存在原因就变得特别好理解了。1.2 设计边界只做必要的其他一律砍掉写这种项目最怕的就是贪多。我以前有个毛病一上来就想做支持并发渲染、服务端渲染、分层架构的“全家桶”结果代码写到一半就被自己写崩了。rea 这次我严格要求自己只保留这几件事函数组件也就是把组件定义成普通函数虚拟 DOMvnode的创建、挂载和更新同层 diff key 列表复用简单的 useState 和批量调度更新不做的事情更多不做类组件、不做 ref、不做副作用钩子、不做异步渲染、不做服务端输出。这些能力在真实项目里当然重要但对理解“渲染引擎到底是什么”这件事它们都属于衍生话题。项目语言也做了减法。我听过很多人问为什么不用 JSX 写组件其实 rea 刻意不用 JSX而是用h(div, { className: app }, ...)这种函数调用形式创建节点。原因很简单JSX 需要编译步骤而我想让整个项目可以在浏览器里直接跑起来打开 HTML 就能看到效果省掉构建环节把注意力全部集中在渲染逻辑上。2. 整体设计从数据结构到渲染管线的拆解2.1 vnode 的数据结构界面的“图纸”渲染引擎处理的核心不是 DOM而是虚拟 DOM 节点我管它叫 vnode。rea 里每个 vnode 长这样{ tag: div, // 标签名 props: { className: app }, // 属性集合 children: [], // 子节点数组 key: null, // 列表复用标识 el: null // 对应真实 DOM挂载后填充 }几个字段都很朴素但每个字段都有它的必要性。tag决定创建什么元素props存属性和事件children存子节点。key是我在设计列表更新时主动加进去的——如果列表里有可复用的元素但没有唯一标识diff 时只能按位置硬碰容易造成状态错位。el字段则是 vnode 和真实 DOM 的桥梁diff 更新时直接在原 DOM 上打补丁不需要重新查找节点。2.2 从状态到界面的完整链路整个 rea 的运行逻辑可以浓缩成一句话渲染函数根据状态生成 vnode渲染引擎根据新旧 vnode 的差异更新 DOM。我用装修来类比这件事你手里有一份旧图纸和一份新图纸装修师傅不会把整栋房子拆了重盖而是对比两版图纸找出哪堵墙变了、哪个房间要新增、哪个柜子要拆掉然后只动那些变化的部分。渲染引擎里的 diff 就是那个对比图纸的人目标是尽量减少对真实 DOM 的操作因为 DOM 操作恰恰是页面里最贵的操作之一。状态更新之后渲染函数会被重新执行产出新的 vnode 树再由 patch 函数拿这棵新树去和旧树对比。这个“变状态 - 生成新 vnode - 对比并更新真实 DOM”的流程是所有声明式界面库的核心模型。2.3 批量调度为什么 setState 需要排队设计调度器时我遇到一个原始问题如果一次事件处理里调用了五次状态更新是不是界面也要连续更新五次显然不应该。连续同步渲染五次会造成浪费还会让用户看到中间状态的闪烁。rea 的做法是把每次更新函数推入队列然后通过微任务统一刷新。所有更新在同一个事件循环 tick 内被合并到一次渲染周期里这就是我理解的批量更新。流程就是状态变更 - 更新入队 - 标记“已安排刷新” - 微任务触发 - 清空队列并统一渲染实测下来这个调度策略最大好处是不管同一 tick 里触发了多少次 setState最终 commit 到真实 DOM 的次数只有一次。2.4 源码结构和代码量项目代码量我控制在 300 行以内原因前面也说了砍掉一切不必要功能。文件划分也很简单文件职责大概行数h.js创建 vnode20mount.js首次挂载 vnode 到真实 DOM60patch.js新旧 vnode 对比和更新80props.js属性、事件、input value 的处理50scheduler.js批量更新队列和微任务调度40hooks.jsuseState 最小实现35每份文件只干一件事拆到一个中等代码能力的开发者能一口气读完的程度。3. 核心模块实现与关键参数解析3.1 创建节点h 函数到底做了什么在 rea 里h是组件的“画笔”负责把参数整理成标准 vnode。最关键的是对 children 的处理因为调用方可能传入数组、嵌套数组、字符串、数字甚至 null 或 undefined。我统一处理成扁平数组并且过滤掉空节点这样可以避免后续遍历子节点时到处判空。function h(tag, props, ...children) { const flatChildren children .flat(Infinity) .filter((item) item ! null item ! undefined); return { tag, props: props || {}, children: flatChildren, key: (props props.key) ? props.key : null, el: null }; }一个容易被忽略的细节key不能留在props里否则挂载时会试图给真实 DOM 设置key属性既多余又在 diff 时造成干扰。所以我把 key 单独取出来存到 vnode 顶层字段props 里剩下的才是真正要渲染到 DOM 的属性。3.2 首次挂载把 vnode 变成真实 DOMmount函数就是 vnode 的“落地过程”。流程很直接创建元素、处理属性、递归挂载子节点、追加到容器。function mount(vnode, container) { const el document.createElement(vnode.tag); vnode.el el; for (const [name, value] of Object.entries(vnode.props)) { if (name.startsWith(on)) { el.addEventListener(name.slice(2).toLowerCase(), value); } else if (name value || name checked) { el[name] value; } else if (name ! key) { el.setAttribute(name, value); } } for (const child of vnode.children) { if (child.tag) { mount(child, el); } else { el.appendChild(document.createTextNode(child)); } } container.appendChild(el); }事件绑定我没有直接用el.onclick fn而是走addEventListener。原因是后者支持同一个元素上挂多个处理函数后续 diff 时卸载旧事件也更可控。对于 value 和 checked 这类属性用 setAttribute 管理 input 的受控状态很容易出问题直接赋值给 DOM 属性是最稳的方式。3.3 最小 diff同层比较和 key 复用diff 是 rea 的核心但我没有做复杂的树 diff而是采用同层比较策略。真实场景里跨层级的节点移动非常少同层 diff 已经能覆盖绝大多数业务更新并且性能足够好。function patch(oldVnode, newVnode) { // 标签不同直接替换整个节点 if (oldVnode.tag ! newVnode.tag) { const parent oldVnode.el.parentNode; mount(newVnode, parent); parent.removeChild(oldVnode.el); return; } const el (newVnode.el oldVnode.el); updateProps(el, oldVnode.props || {}, newVnode.props || {}); const oldChildren oldVnode.children; const newChildren newVnode.children; if (isTextNode(newVnode)) { if (oldVnode.props.textContent ! newVnode.props.textContent) { el.textContent newVnode.props.textContent; } return; } // 子节点对比前面公共部分逐对 patch尾部多退少补 const commonLen Math.min(oldChildren.length, newChildren.length); for (let i 0; i commonLen; i) { patch(oldChildren[i], newChildren[i]); } if (newChildren.length commonLen) { for (let i commonLen; i newChildren.length; i) { mount(newChildren[i], el); } } if (oldChildren.length commonLen) { for (let i commonLen; i oldChildren.length; i) { el.removeChild(oldChildren[i].el); } } }这段代码里我刻意保留了最朴素的“按位置对比 尾部增删”版本因为它最容易看懂。它的问题也很明显如果列表顺序变了比如 [A, B, C] 变成 [C, A, B]按位置对比会把三个子节点全部判为变化导致不必要的重建。所以在带 key 的场景里我在 patch 前加了一步“按 key 查找旧节点复用”的优化。逻辑是新列表里的某个节点如果能在旧列表的同一层级里找到相同 tag 且相同 key 的节点就直接复用那个真实 DOM然后继续 diff 它的属性而不是重新创建。这一步改造让列表排序、插删场景下的 DOM 操作大幅减少也让 rea 在列表更新上的表现立刻有了质的提升。3.4 属性更新处理普通属性和事件的区别updateProps是我踩坑最多的函数。最初版本只是简单遍历新旧属性然后 setAttribute结果受控 input 输入一个字光标就跳到最后原因是我每次 diff 都把整个 input 的 value 属性重设了一遍。后来改成对value、checked这种属性直接赋值并且当新旧值相同直接跳过问题就消失了。function updateProps(el, oldProps, newProps) { const allNames new Set([ ...Object.keys(oldProps || {}), ...Object.keys(newProps || {}) ]); for (const name of allNames) { if (name key) continue; const oldVal oldProps[name]; const newVal newProps[name]; if (oldVal newVal) continue; if (name.startsWith(on)) { if (oldVal) el.removeEventListener(name.slice(2).toLowerCase(), oldVal); if (newVal) el.addEventListener(name.slice(2).toLowerCase(), newVal); } else if (name value || name checked) { el[name] newVal; } else if (typeof newVal function) { el[name] newVal; } else if (newVal null || newVal false) { el.removeAttribute(name); } else { el.setAttribute(name, newVal); } } }这里有个细节事件函数也会被包裹在on前缀属性里diff 时老绑定要卸载新绑定要挂载。如果不做卸载每次更新都会叠加一个监听器点击一次触发多次回调这类问题在追查时特别隐蔽。3.5 状态管理用 hooks 数组实现 useState组件是一个普通函数每次渲染都会重新执行一遍。那么问题来了函数执行完局部变量就销毁了下一次渲染怎么拿到上一次的状态rea 的做法和很多 hooks 实现思路一致维护一个“当前渲染组件”的引用以及一个自增的 hook index。每次组件渲染时按调用顺序把 hook 数据存到组件自己的数组里。同一组件的同一个 useState 调用在每次渲染里拿到同一数组下标从而读到上一次的状态。let currentComponent null; let hookIndex 0; const updateQueue []; let renderScheduled false; function useState(initial) { const component currentComponent; const index hookIndex; if (!component.hooks[index]) { component.hooks[index] { value: initial, queue: [] }; } const hook component.hooks[index]; function setState(nextValue) { hook.value typeof nextValue function ? nextValue(hook.value) : nextValue; updateQueue.push(component.render); scheduleFlush(); } return [hook.value, setState]; } function scheduleFlush() { if (renderScheduled) return; renderScheduled true; Promise.resolve().then(flushQueue); } function flushQueue() { renderScheduled false; while (updateQueue.length) { updateQueue.shift()(); } }这个实现的关键约束是 hooks 的调用顺序不能变。如果你把某个 useState 放在 if 条件里第二次渲染时条件不成立hook index 就会错位后面的 hook 全部拿到错误状态。这也是 a 同学后来在 Demo 里踩过的一个典型坑。3.6 性能实测一个 1000 行表格渲染我在本机浏览器里做了个很简单但有效的实验渲染一个 1000 行的数据表格然后随意修改其中三行数据用两种方式对比耗时。操作全量 innerHTML 重建rea diff 更新首次渲染约 40ms约 35ms修改三行后更新约 40ms整体重建约 4ms差异主要在更新阶段。innerHTML 会把整个表格销毁然后重新生成rea 只重建真正变化的那三行。首屏差距不大因为都要创建 1000 次 DOM 节点更新的差距却接近十倍。这个实验也解释了为什么现在的主流视图库宁可引入虚拟 DOM 的对比开销也要把 DOM 操作降到最低。4. 实操过程用 rea 写一个完整 Demo4.1 从零搭建项目环境rea 不需要依赖任何工具链。我建了一个demo目录里面放三个文件就开跑rea/ src/h.js src/mount.js src/patch.js src/props.js src/scheduler.js src/hooks.js demo/todo.html demo/todo.jstodo.html只需要引入 script 文件然后挂载到页面的一个容器节点上。由于没有 JSX 编译步骤任何浏览器打开就可以直接运行排查问题非常方便。4.2 ToDo 组件的完整代码写一个经典 ToDo 列表来验证 rea 的核心能力再合适不过了。它同时涉及状态更新、列表渲染、受控输入、事件绑定这些最基础但最关键的功能。function App() { const [list, setList] useState([]); const [text, setText] useState(); const add () { if (!text.trim()) return; setList([...list, { id: Date.now(), text }]); setText(); }; return h(div, { className: todo }, h(h3, {}, rea 的最小 ToDo), h(input, { value: text, onInput: (e) setText(e.target.value) }), h(button, { onClick: add }, 添加), h(ul, {}, list.map((item) h(li, { key: item.id }, item.text) ) ) ); } const vnode h(App); render(vnode, document.getElementById(root));这个 Demo 虽然只有几十行但里面包含了几个重要场景input 属于受控组件每次输入都触发 setText 更新列表的每个元素都带了唯一的 id 作为 key添加操作同时更新 list 和清空输入框两次 setState 会被批量调度合并成一次渲染。4.3 调试技巧给自己写一个 vnode 对比日志调试 diff 逻辑时直接看 DOM 变化很难定位问题。我给自己写了一个非常简陋但好使的 debug 函数打印新旧 vnode 的差异。[vnode diff] old: div.todo(2 children) [vnode diff] new: div.todo(3 children) [vnode diff] add li#key-167: 写文章这个日志帮我快速看清 patch 函数到底在执行替换还是复用。如果你也打算自己写一个渲染引擎非常建议一开始就加上类似日志它能省掉大量“盯着断点猜状态”的时间。4.4 测试策略用断言代替测试框架最小项目没必要引入测试库我用一个非常朴素的断言函数手写了几组用例重点覆盖三种情况标签不同的替换、属性变化的更新、带 key 列表的复用。跑一遍测试后基本上能确认 diff 的核心行为没有明显问题。核心经验是渲染引擎这类项目最值得测的不是某个具体功能而是“diff 决策是否符合预期”。比如旧树 [A,B,C]、新树 [A,B,D]diff 后只应该替换 C 节点D 节点不能触发多余 DOM 操作。这种断言一旦写出来后面的所有改动都有了安全网。5. 常见问题与排查技巧实录5.1 key 不匹配导致的状态串位症状列表项内部有 input 时输入 A 的内容另一行的 input 也跟着变排序后列表顺序和输入内容错配。原因列表元素没有设置 key或者用了数组 index 作为 key。用 index 做 key 时如果列表头部插入一行后面的行 index 全部变了diff 会错误地复用 DOM状态自然也跟着错位。排查手段打开 debug 日志看新的 vnode 在旧列表里能不能匹配到同 key 的节点。匹配不到就说明 key 没有唯一性。建议 key 选业务 id而不是 index。5.2 批量更新失效渲染次数突然变多症状一次事件处理里连续调用三次 setState按预期只渲染一次但控制台日志显示执行了三次渲染。原因我的第一版调度器用了一个高度简化标记如果在同一个 tick 里用户代码执行完后、微任务执行前又插入了新的更新标记可能已经被重置导致多跑一轮。解决办法flushQueue 在开始时先把标记置为 false但要在队列全部清空之前检查是否又有新任务进来。调整后调度器即使遇到嵌套更新也能在下一轮继续处理不会漏也不会过度渲染。5.3 闭包捕获旧状态症状在setInterval回调里调用 setState拿到的 state 永远是最初的值。原因组件渲染时产出的是这个 tick 的闭包定时器回调保存的是闭包里的旧 state。只要组件没有重新渲染定时器每次调用的都是旧状态。解决办法不直接依赖组件重新渲染去更新闭包而是在 useState 的 hook 对象上保存最新值setState 的内部读取始终从 hook 对象取。这个点让我真正理解了为什么有些框架要提供额外 api 来转发可变数据。5.4 事件监听器叠加症状按钮点击一次回调执行两次甚至更多次。原因属性 diff 里只挂了新事件没有卸载旧事件每次更新都往同一个 DOM 上叠加监听器。排查手段在钩子上打印当前已注册事件数量叠几次就一目了然。修复思路就是 updateProps 里对on开头的属性先移除旧值再加新值。5.5 常见问题速查表问题可能原因排查思路解决方式列表状态串位缺少 key 或 key 用 index查看 key 匹配日志换用业务唯一 id更新次数过多调度标记状态错误日志打印 flush 次数修正标记重置时机定时器拿到旧状态闭包捕获旧值检查 hook 对象是否被修改从 hook 对象读最新值事件触发多次旧监听器未卸载检查 addEventListener 叠加updateProps 先卸载再挂载input 光标跳动value setAttribute 全量重设对比新旧 value 是否相同value 跳过相同赋值6. 复盘与个人心得做完 rea 之后我的感觉是那些主流视图库之所以命名复杂、层级深不是故意为难人而是它们需要在更大规模、更复杂的场景下保证一致性、性能和开发体验。但你只需要实现一条最小链路就会对整棵依赖树豁然开朗。最有价值的体会不是我把 diff 写出来了而是我理解了为什么需要 vnode它是在 JS 层面描述页面结构的一棵树让更新可以“先算后做”。真实 DOM 操作不划算所以先在内存里把差异算清楚再一次性地、最小化地触碰真实 DOM。这个项目后续还可以扩展的方向我也不藏着把 diff 改成可中断的增量渲染、增加 ref、支持多个根节点、把组件挂载逻辑抽出去做服务端输出。但这些都是建立在一条清晰的核心链路之上没有核心链路别的都是空中楼阁。如果让我重新做一遍我开头会直接砍掉所有“以后可能用到”的设计先写一个能跑通状态的极简版本再一点点把功能加回去。这个经验无论是写渲染引擎还是做任何个人项目我觉得都通用。

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

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

免费获取报价 →
↑