2026/10/18 7:50:55

自研轻量渲染引擎rea:数据驱动到精准DOM更新的实践总结

自研轻量渲染引擎rea:数据驱动到精准DOM更新的实践总结 折腾了两周半我终于把内部代号叫rea的轻量渲染引擎跑通了。说是引擎其实没有想象中那么玄乎它解决的核心问题只有一句话如何把一段状态数据变成用户能看到的页面并且在状态变化时只更新该变化的部分。一开始我只是想接手一个内部支撑系统时把那些散落在各处的innerHTML和appendChild收拢一下顺手也给团队里的新同学留一份能看懂的“框架底层说明书”。结果写着写着发现光是渲染链路就够写一篇长文。这个代号rea就这么延续了下来它既不是某个成熟框架的变体也不是业务线的名字缩写纯粹是开发目录里的临时命名后来懒得改了。这篇文章不打算讲多深奥的编译原理也不会整段贴框架源码。我想按照自己实际动手的顺序把这个项目里的设计思路、关键实现、踩坑记录和实测数据一次性讲清楚。如果你正准备做类似的前端基建或者单纯好奇“框架是怎么把数据变成页面的”这篇应该能让你少走点弯路。1. 项目背景与核心需求拆解1.1 rea 到底要解决什么问题场景特别典型一个后台管理页面左侧筛选条件右侧实时列表。用户每点一次筛选接口回一批数据然后页面要做的事包括更新统计数字、重绘列表、切换空状态、调整分页信息。在旧代码里这些更新分散在各个回调函数中每来一次数据就操作一遍 DOM。小数据量时看不出毛病数据一过千页面就开始卡。原因是多方面的频繁触发重排重绘、没有节流、列表更新时把整块容器清空再重建、事件绑定在每次重建后丢失又得重新挂。这些都指向同一个本质问题状态和视图之间缺少一条结构化的对应关系。rea的目标就很清楚了提供一个最小的数据驱动渲染方案。开发者只需要维护数据渲染层根据数据变化自动计算出需要增删改的节点然后精准操作 DOM。不追求覆盖所有复杂业务但要把最核心的“数据到视图”链路做干净。1.2 为什么选择自研而不是直接套框架这是团队里被问得最多的一个问题。不是说成熟框架不好而是当前项目有几个客观限制。第一运行环境不是常规浏览器而是一个定制化的 WebView 容器很多框架依赖的高级 DOM API 在这里表现不稳定。第二业务包体有严格体积限制引入一个完整框架会吃掉大量预算。第三团队里不少新人对框架的原理其实是一知半解的遇到线上问题只能靠猜我们希望借这个项目把渲染流程彻底摊开。自研rea并不是“重造轮子”式的炫技而是一次非常务实的取舍。既然业务形态足够收敛那我要做的只是“渲染”这一件事就可以把体积和复杂度压到很低。框架本身的边界很清晰不处理路由、不处理状态管理、不处理网络请求只负责把数据同步成 DOM。2. 总体架构设计与方案选型2.1 先梳理数据流再动手写代码动手前我先画了一条单向数据流state - vnode - dom。状态对象是唯一的数据来源每次状态变更都会生成一棵新的虚拟节点树然后和上一次的虚拟节点树做对比最后把差异落到真实 DOM 上。为什么坚持单向因为双向绑定在小型项目里虽然写起来爽但数据流向一旦复杂排查问题就变成了“猜谁改了谁”。单向数据流的思路很简单数据只能从 state 流向视图视图要改数据只能通过明确的方法调用。这样做的好处是每个节点更新都能追溯到一次具体的状态变更调试时把变更日志打开就能看清全链路。当时我也考虑过用 Proxy 做自动依赖收集像某些响应式框架那样“改一个属性就自动更新对应视图”。但评估后发现定制 WebView 对 Proxy 的兼容性不够稳定而且依赖收集的边界条件较多首版还是选择最简单可靠的方案手动触发更新。也就是状态变更后调用一次update()方法由调度器统一处理。2.2 虚拟节点不是银弹但在这里性价比最高第一次接触虚拟节点的人可能会疑惑直接在真实 DOM 上做 diff 不就行了吗实测下来不太可行。真实 DOM 对象包含大量属性和方法直接遍历对比开销很大而且很多属性是只读的改起来要小心翼翼。rea的虚拟节点就是一个普通 JavaScript 对象结构极其轻量{ tag: div, props: { id: list, class: item }, children: [...], el: null, // 指向真实 DOM挂载后回填 key: unique-key }在虚拟节点层面做比较相当于比较两份“配置清单”成本远低于操作真实 DOM。等确认差异后再把 diff 结果一次性落到真实 DOM 上。打个比方整理一份文档如果每次都重新排版整个文件效率很低。虚拟节点方案相当于先把两份目录逐条对比只重印有变化的页码最后替换到文件里。页面越大这种策略的优势越明显。2.3 渲染管线分成四层我把整个渲染过程拆成了四层每一层只做一件事层级职责关键产出状态层维护业务数据提供更新入口最新的 state节点层根据 state 生成虚拟节点树vnode tree对比层对比新旧节点树计算差异patch list渲染层把差异应用到真实 DOM更新后的页面分层带来的最大好处是每一层都可以独立测试。状态层不碰 DOM节点层不关心数据从哪来对比层只做纯计算渲染层才真正操作浏览器 API。调试一个诡异问题的时候我只需要定位到具体在哪一层不用把整条链路重新捋一遍。这也是我建议所有想写渲染引擎的人先做的事别一上来就写 diff先把数据流的骨架搭好。哪怕第一版功能简单只要分层清晰后续加特性只是往对应层补代码。3. 核心实现细节与实操要点3.1 三个生命周期mount、update、destroyrea的节点生命周期很简单挂载、更新、销毁。分别对应三个内部方法mount、patch、destroy。挂载阶段做的事情是根据虚拟节点创建真实 DOM递归处理子节点最后把整个 DOM 插入容器。这一步的代码不长但有几个细节很关键。function mount(vnode, container) { const el document.createElement(vnode.tag); vnode.el el; // 处理属性 for (const key in vnode.props) { if (key.startsWith(on)) { el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key]); } else { el.setAttribute(key, vnode.props[key]); } } // 处理子节点 if (typeof vnode.children string) { el.textContent vnode.children; } else if (Array.isArray(vnode.children)) { vnode.children.forEach(child mount(child, el)); } container.appendChild(el); }注意这里的事件绑定逻辑在挂载阶段直接用 addEventListener 绑定。但这会带来一个隐患后面我会专门讲。更新阶段则是整个引擎的核心做的事情可以浓缩成一句话对比新旧虚拟节点找出差异只更新差异部分。如果新旧节点的tag不一样说明整个节点类型变了直接把旧的卸载、把新的挂载上去如果tag一样就复用真实 DOM逐项对比属性再递归处理子节点。3.2 子节点 diff先对齐再最小化更新子节点的对比是渲染引擎里最复杂的部分也是最容易写崩的地方。最简单的做法是把旧子节点和新子节点按顺序一一对比位置相同的就更新数量多出来的就新增数量不够的就删除。这种方案在“列表顺序稳定”的时候表现很好代码也简单。但业务里最常见的操作是列表重新排序比如把某一条数据置顶。如果只按顺序对比原本在第一条的节点和新的第一条节点对不上就会引发一连串无意义的更新。解决方法是引入key。每个带 key 的子节点在 diff 时会先建立一张以 key 为索引的映射表然后拿新旧列表做匹配。匹配上的节点复用没有匹配上的按新增处理旧列表里多余的按删除处理。function diffChildren(oldChildren, newChildren, parentEl) { const oldMap createKeyMap(oldChildren); const patches []; newChildren.forEach((newChild, index) { const oldChild oldMap.get(newChild.key); if (oldChild) { patches.push(patch(oldChild, newChild)); } else { patches.push({ type: MOUNT, vnode: newChild, index }); } }); oldChildren.forEach(oldChild { if (!newChildren.some(c c.key oldChild.key)) { patches.push({ type: REMOVE, el: oldChild.el }); } }); applyPatches(patches, parentEl); }代码是简化过的示意真实实现里还要考虑移动节点的处理。但核心思想是key 是对齐新旧节点的锚点不是随便写个 index 就完事。如果列表数据本身没有稳定唯一标识宁可额外生成一个 id也不要直接用数组下标做 key否则 diff 结果会非常诡异。3.3 属性更新要区分“变更”和“无变化”节点更新时属性对比看起来很小实际上坑很多。我最初的实现是每次更新都把新旧 props 里的所有属性重新 setAttribute 一遍。实测下来除了浪费性能还有两个明显问题一是 class 变化会触发样式重算即使 class 值没变二是部分属性在设置时会引发额外的副作用比如value属性在某些输入框里会导致光标跳动。后来改成按 key 遍历对比function patchProps(el, oldProps, newProps) { // 移除旧属性中不存在的 for (const key in oldProps) { if (!(key in newProps)) { el.removeAttribute(key); } } // 更新变化的属性 for (const key in newProps) { if (oldProps[key] ! newProps[key]) { el.setAttribute(key, newProps[key]); } } }这里的判断用的是!也就是说只有值真的变了才会去操作 DOM。对于引用类型的 props还需要进一步做深度对比。现实中我们通常限制 props 只能是字符串、数字或简单对象复杂数据建议放到状态层单独管理避免在渲染层引入过重的对比逻辑。3.4 事件绑定代理是更好的选择上文提到 mount 阶段用了 addEventListener这在删除节点时其实埋了个雷。节点被卸载后DOM 会被移除浏览器通常会回收事件监听问题不大。但有一种情况很麻烦节点复用但事件处理函数变了。如果只是简单地在 patch 阶段判断新旧事件函数不一致把旧的移除再绑新的容易漏掉而且频繁解绑绑定也有开销。更好的方案是事件代理在容器根节点挂一个统一监听器所有节点事件都通过冒泡触达根节点再由一个映射表根据事件类型和 node 标识找到对应的处理函数。container.addEventListener(click, (e) { const handler findEventHandler(e.target); if (handler) { handler(e); } });这样做的好处有两个第一节点挂载和卸载时完全不用关心事件绑定第二内存占用低不管页面有多少个可点击元素根节点只有一个监听器。代价是需要手动维护从节点到处理函数的映射同时要保证事件冒泡链路中的中间节点不会意外阻止事件传递。3.5 调度器把多次更新合并成一次渲染业务里经常出现这种场景一次状态变更同时改了列表、统计数字和筛选条件。如果每次变更都立刻执行 diff 和 DOM 更新同一帧内可能触发三次渲染白白浪费性能。我在rea里实现了一个极简调度器基于微任务做批量更新。原理非常简单let pending false; let dirty false; function scheduleUpdate() { dirty true; if (pending) return; pending true; Promise.resolve().then(() { pending false; if (dirty) { dirty false; performRender(); } }); }同一轮事件循环里不管调用多少次scheduleUpdate都只会执行一次performRender。这样做的收益在复杂页面上特别明显把同一帧内的多次更新合并成一次既减少了 diff 计算量也减少了 DOM 操作次数。调度的边界情况比想象中多。比如在微任务执行前又有新更新进来要保证不会漏掉又比如渲染过程中如果产生了新的更新需要避免死循环。这些我在调试阶段都遇到过后面会专门讲。4. 踩坑实录与问题排查技巧4.1 列表更新后节点错位问题出在 key 上做一个筛选列表时我遇到了一个诡异现象点“按时间排序”后列表内容没变但每个卡片上的图片错位了。排查了半天发现是数据源里的 key 用的不是业务 id而是前端根据数组下标拼出来的。问题在于排序之后数组下标完全变了diff 时新旧节点对不上导致节点虽然复用了但里面的内容还是旧数据。后来我把 key 换成了业务数据里稳定的主键错位问题立刻消失。这里有个经验key 必须和数据本身强相关而不能和数据位置相关。如果后端没有返回稳定 id宁可在前端生成一个和内容绑定的哈希值也不要图省事用 index。4.2 视图不更新原来是引用比较惹的祸还有一次列表数据明明变了页面却纹丝不动。后来定位到问题出在 diff 的前置判断我在更新节点时先比较了新旧 vnode 的 props而 props 是个对象。新对象和旧对象虽然是不同引用但内容一样于是对比逻辑认为“没有变化”直接跳过了更新。这个问题的根源是浅比较的误判。解决方式是渲染入口不比较整个 props 对象而是把 props 拆成一个个基础字段进行比较同时给业务方约定如果数据内容真的变了必须生成新的 props 对象而不是直接修改原对象。4.3 页面卡顿居然是因为“过度渲染”项目跑到后面我发现页面数据量一大就开始卡。打开性能面板一看绝大多数时间都花在 diff 上而不是 DOM 操作上。原因是每轮更新我都从根节点开始完整 diff 整棵虚拟节点树即使只有底部一个列表变了上层的导航、侧栏、页头也白白跑了一遍对比。优化方案是给节点加“脏检查”标记只有那些 props 或子节点引用发生变化的节点才需要进入 diff没有变化的整棵子树直接跳过。这个优化让大页面更新耗时下降了接近一半。但脏检查也会带来隐患如果业务代码在修改数据时不小心直接改原对象并保留了引用脏检查就判断不出变化。所以我在更新入口处做了强制约束每次更新必须传入一个新的 state 引用内部再用浅比较判断哪些子树需要真正 render。4.4 根容器清空时的事件代理失效用过事件代理后理论上节点卸载不再需要关心事件。但有次我直接把整个根容器的innerHTML清空了结果所有点击事件全部失效。一开始很费解代理不是挂在根容器上吗按理说子节点没了根容器还在事件应该还能触发。仔细一看才反应过来我把代理监听器也绑在根容器内部的一个子节点上清空innerHTML把这个子节点也干掉了。后来把代理监听器绑定在真正的外层容器上并保证它不被内部操作影响问题才解决。这个坑提醒我一个原则代理监听的挂载节点必须和渲染管理的节点严格分离。渲染代码可以清空内部内容但绝不能碰外层容器本身。4.5 常见问题速查表现象可能原因排查思路列表节点错位key 与数据内容无关检查 key 是否使用稳定业务标识页面完全没更新新旧 state 引用相同确认更新时生成了新的对象引用点击事件偶尔失效代理节点被误清空或阻止冒泡确认代理挂载节点是否安全大列表渲染卡顿完整 diff 整棵节点树增加脏检查跳过未变化子树输入框光标跳动value 属性被重复设置属性对比时增加相等判断5. 实测数据与优化取舍5.1 一个千级节点的对照实验为了验证效果我用同一个模拟页面做了组简单对比渲染 1000 个列表项每 200ms 更新一批数据。对照组用旧代码直接操作 DOM实验组跑rea的完整渲染链路。最终数据是这样的初始渲染耗时任旧代码约 320msrea 约 360ms稍慢但差距不大。差异在后续更新上拉开旧代码每轮更新平均 90msrea 首轮更新 45ms开启批量更新和脏检查后降到 22ms 左右。初始渲染变慢的原因是虚拟节点的构建和 diff 需要额外计算这在节点数少时可以忽略但是节点数量大了收益就体现在后续更新的稳定性上。对于实时刷新类页面体验提升是非常明显的旧代码在连续数据更新时会出现肉眼可见的卡顿和闪烁rea 则是平滑的局部更新。5.2 优化优先级先减少计算量再优化计算速度如果现在让我重新做一遍优化顺序会非常明确。第一优先是减少 DOM 操作。所有可以合并的更新尽量合并能复用的节点尽量复用。第二优先是减少对比范围。通过脏检查跳过未变化子树比优化 diff 本身的算法复杂度更有效。第三才是优化 diff 算法细节比如用双端指针、缓存映射表等等。很多人在调优时容易本末倒置一下就扎到算法细节里。以我的经验来看大部分性能问题都是“做了不该做的事”而不是“该做的事做得不够快”。rea的 diff 算法说实话不算多高级但配合调度和脏检查已经能覆盖掉绝大多数业务场景。5.3 这个项目后续可以怎么扩展做到这一步rea已经能支撑实际业务的日常维护了但离“完美引擎”还差得远。我后续规划了几个方向供参考。一是支持异步渲染。目前的渲染是同步的如果页面特别复杂一次更新依然可能阻塞主线程。可以借鉴时间切片的思想把 diff 拆分成多个小任务在每一帧的空闲时间执行。二是支持自定义渲染器。把 DOM 操作从核心逻辑中抽离出去理论上可以换一个canvas后端或者换到其他渲染目标上。当初分层设计时已经留了口子但真正抽离还需要些工作量。三是提供 DevTools 面板。把每次 diff 产生的补丁、耗时、节点变化量可视化地展示出来排查问题会更直观。这些扩展方向都不难难的是保持核心逻辑足够克制不加着加着就变成了另一个大型框架。最后再分享一个实操体会项目收尾后我最大的体会是不要急着写 diff 算法先把数据流和挂载流程跑通。一开始我花了很多时间在脑内设计一个“完美”的 diff结果代码写了又删进度停滞。后来干脆先用最笨的顺序对比方案把整条链路跑通再一步步加入 key 匹配、脏检查、批量更新。每加一个特性都能立刻看到效果排查问题也更容易定位。渲染引擎这种项目最怕的就是“想一步到位”的心态。只要状态到视图的对应关系足够干净后续所有优化都是水到渠成的事。如果你也在折腾类似的东西希望这篇总结能帮你省点时间。踩坑的细节还有很多等哪天我把 DevTools 面板做出来了再来分享第二轮。