ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手写 mini-vue:更新 Element children 的 patch/diff 原理与实战

手写 mini-vue:更新 Element children 的 patch/diff 原理与实战 这篇内容我打算完整拆解一下手写mini-vue中“更新element的children”这一环。很多人学着学着会卡在patch和diff上尤其是从“挂载”切到“更新”时面对一个元素的子节点到底该先卸载谁、再挂载谁、要不要复用很容易绕晕。这里我会从原理到代码再到实战踩坑一条线讲清楚。1. 内容整体设计与思路拆解1.1 这门“31”号功课到底在做什么mini-vue是一套为了读懂Vue3运行时核心而拆出来的极简实现它把模板编译、响应式、虚拟DOM、渲染器等模块像剥洋葱一样一层层拆开。而这节课或者说这个commit聚焦的内容非常具体当一个元素的children发生变化时渲染器应该如何用最小的代价把新内容同步到真实DOM。比如页面上有一个div第一帧它的children是一个文本节点“hello”第二帧它的children变成了两个span标签。如果每次变化都把div整个清空再重新创建功能上没问题但性能、状态、焦点、输入框内容全都会丢。所以mini-vue要做的是保留div元素本身只对children部分做精准的增删改。这也是所有现代前端框架运行时最核心的看家本领之一。我之前带过不少朋友读Vue源码大家普遍反映最值得手写一遍的其实不是响应式反而就是这个“更新element的children”流程。它短小精悍牵涉到的分支判断、diff策略、DOM操作技巧都非常典型写一遍之后很多关于性能优化的玄学就变成肉眼可见的代码逻辑了。1.2 children的三种形态以及为什么要分情况处理在模板编译和手写h函数时一个元素的children有几种典型形态。字符串。表示单一文本节点例如h(div, null, hello)。数组。表示由多个子vnode组成例如h(div, null, [h(span), h(span)])。空。没有children或者children显式传了null、undefined。这三种基础形态交叉组合就构成了更新逻辑的全部分支。旧children是文本、新children是数组旧children是数组、新children是文本两边都是数组两边都是文本一边为空等等。每一种情况的处理方式完全不同。Vue3源码里处理这些分支的主要依据是shapeFlag它用一个整数上的不同bit位来标记当前vnode的children类型。之所以用位运算而不用typeof加Array.isArray直接判断一方面是为了把类型信息压缩进一个数值方便vnode对象更轻量另一方面位运算判断速度极快在渲染每秒几十帧的高频场景下这种设计能省掉不少不必要的函数调用和类型判断。关于这一点后面我会专门展开讲。1.3 为什么宁可写diff也不直接重挂整个DOM先想一个最简单的问题浏览器里把一个DOM元素从父节点上移走再重新插回去它会经历什么布局重算、样式重算、事件绑定失效、输入框内容被清空、滚动位置丢失、焦点丢失甚至还会触发可访问性树的重建。这意味着如果你用“把元素整体innerHTML替换掉”来更新页面那不只是浪费性能而是在破坏用户体验。所以我一直建议凡是做前端框架相关学习或自研一定要自己写一遍patchChildren。因为你一旦亲手实现过“能复用的绝不清空、能移动的绝不重建”这一整套逻辑就会真正明白为什么框架比自己手动拼字符串插DOM要高级。这不是所谓的“黑魔法”而是对DOM生命周期、节点引用、状态持有的精细管理。2. 核心细节解析与实操要点2.1 vnode里的shapeFlag标记位在开始写更新逻辑之前必须先理解vnode对象上shapeFlag的作用。我们在mini-vue里可以这么定义vnode创建函数const ShapeFlags { ELEMENT: 1, TEXT: 1 1, ARRAY_CHILDREN: 1 2, TEXT_CHILDREN: 1 3 } function createVNode(type, props, children) { const vnode { type, props, children, el: null } // 元素节点 if (typeof type string) { vnode.shapeFlag ShapeFlags.ELEMENT } // 子节点类型标记 if (typeof children string) { vnode.shapeFlag | ShapeFlags.TEXT_CHILDREN } else if (Array.isArray(children)) { vnode.shapeFlag | ShapeFlags.ARRAY_CHILDREN } return vnode }这里TEXT_CHILDREN和ARRAY_CHILDREN分别是12和13它们在二进制上占用不同的位。如果子节点是数组shapeFlag的值就是ELEMENT | ARRAY_CHILDREN也就是1 | 4。当我们需要判断某个vnode的子节点类型时只需要做一个与运算if (shapeFlag ShapeFlags.TEXT_CHILDREN) { // 处理文本子节点 } else if (shapeFlag ShapeFlags.ARRAY_CHILDREN) { // 处理数组子节点 }为什么不用简单的typeof children string因为真实场景里还要考虑children可能是Fragment、可能是带插槽作用的组件等等字段多了之后连续的if判断会把代码拖得非常啰嗦。位运算把这些判断统一收敛成一次按位与性能高且语义集中。这也是阅读Vue3源码时一个特别容易迷惑但非常优雅的点。2.2 渲染器的分层设计mini-vue里的渲染器和浏览器DOM是解耦的。也就是说我们不直接调用document.createElement而是通过一个options对象传入平台的DOM操作函数。function createRenderer(options) { const { createElement, insert, setElementText, remove } options function mountElement(vnode, container) { const el createElement(vnode.type) vnode.el el if (vnode.shapeFlag ShapeFlags.TEXT_CHILDREN) { setElementText(el, vnode.children) } else if (vnode.shapeFlag ShapeFlags.ARRAY_CHILDREN) { vnode.children.forEach(child patch(null, child, el)) } insert(el, container) } function patch(n1, n2, container) { if (n1 null) { mountElement(n2, container) } else { patchElement(n1, n2) } } return { render(vnode, container) { if (vnode null) { // 卸载逻辑 } else { patch(container._vnode, vnode, container) } container._vnode vnode } } }这样做最大的好处是同一套渲染器逻辑可以跑在浏览器DOM上也可以跑在jsdom、小程序模拟器甚至Canvas上。只需要替换掉这四个平台函数即可。我自己的体会是写mini-vue时不要急着把所有逻辑塞进一个大文件一定要保持这种分层。否则后面想加SVG支持或者自定义渲染目标时你会后悔当初偷懒。2.3 patch调用链render - patch - patchElement - patchChildren更新的整体调用链是这样的render(vnode, container) └─ patch(container._vnode, vnode, container) └─ patchElement(n1, n2) └─ patchChildren(n1, n2, el)render第一次渲染时容器上没有旧vnode所以n1为null走mount流程。第二次调用render时container._vnode存的是上一次的vnode于是进入更新流程patch会先判断两个vnode的type是否相同如果不同就直接把旧节点卸载、新节点整体挂载。如果type相同就进入patchElement说明我们要复用这个DOM元素只更新它的props和children。这里需要特别注意patchElement接收的n2.el应该复用n1.el。也就是说新的vnode对象本身没有DOM引用我们要把旧vnode上的DOM元素挂到新vnode上。这个操作很多人第一次写都会漏掉导致后面更新props时找不到元素。function patchElement(n1, n2) { const el (n2.el n1.el) // 属性更新省略重点是children patchChildren(n1, n2, el) }3. 实操过程与核心环节实现3.1 搭建一个最小可运行工程建议直接用Vite建一个原生TS项目不需要引入Vue只需要一个核心渲染器文件和一个测试页面。目录结构大致这样renderer.js渲染器核心。vnode.jsvnode创建与shapeFlags定义。index.html页面容器。main.js写测试逻辑。这一步没什么神秘核心是让浏览器能跑起来、能看到控制台日志。我习惯打开DevTools的“Breakpoints”来单步跟踪patch过程后文会讲具体调试方法。3.2 手写mountElement与unmountChildren挂载阶段我们要把vnode递归渲染成真实DOM。注意children是数组时需要循环调用patch因为每个子vnode可能还是元素节点也可能继续嵌套。function mountElement(vnode, container) { const el createElement(vnode.type) vnode.el el if (vnode.shapeFlag ShapeFlags.TEXT_CHILDREN) { setElementText(el, vnode.children) } else if (vnode.shapeFlag ShapeFlags.ARRAY_CHILDREN) { for (let i 0; i vnode.children.length; i) { patch(null, vnode.children[i], el) } } insert(el, container) }卸载子节点时不能只用container.innerHTML 这种粗暴方式。虽然最终效果看起来差不多但真实框架中每个组件都有自己的生命周期卸载需要触发beforeUnmount和unmounted钩子还要移除事件监听。mini-vue里至少要做到递归地对数组里的每个子vnode调用remove把对应DOM从父节点上摘下来。function unmountChildren(children) { for (let i 0; i children.length; i) { const child children[i] if (child.el) { remove(child.el) } child.el null } }3.3 更新children的核心分支判断这一步是整个题目的灵魂。我直接给出参考实现并附上每一步的意图注释。function patchChildren(n1, n2, container) { const prevShapeFlag n1.shapeFlag const shapeFlag n2.shapeFlag // 新children是文本 if (shapeFlag ShapeFlags.TEXT_CHILDREN) { // 旧children是数组先清掉所有旧子节点 if (prevShapeFlag ShapeFlags.ARRAY_CHILDREN) { unmountChildren(n1.children) } // 文本内容不一样才更新DOM减少重复写入 if (n1.children ! n2.children) { setElementText(container, n2.children) } } else { // 新children是数组或者为空 if (prevShapeFlag ShapeFlags.ARRAY_CHILDREN) { if (shapeFlag ShapeFlags.ARRAY_CHILDREN) { // 两边都是数组进入diff算法 patchKeyedChildren(n1.children, n2.children, container) } else { // 旧的是数组新的是空直接卸载旧的 unmountChildren(n1.children) } } else { // 旧children可能是文本也可能是空 if (prevShapeFlag ShapeFlags.TEXT_CHILDREN) { // 旧文本先清空 setElementText(container, ) } // 新children是数组就逐个挂载 if (shapeFlag ShapeFlags.ARRAY_CHILDREN) { for (let i 0; i n2.children.length; i) { patch(null, n2.children[i], container) } } } } }这段逻辑看起来分支很多其实真正处理下来就四个大方向新文本旧数组先卸载旧数组再改文本。新文本旧文本直接比对文本内容不同才更新。新数组旧文本先清空旧文本再挂载新数组。新数组旧数组进入diff尝试复用旧节点。很多人会漏掉“旧文本新空”的情况比如旧children是字符串新children传了null。上面的代码里shapeFlag既不是TEXT_CHILDREN也不是ARRAY_CHILDREN走else分支然后因为旧children是文本直接把文本清空新children没有数组自然不挂载任何东西。这样容器就变成了一个空元素逻辑是完整的。3.4 简单diff刷新数组复用能力当新旧children都是数组时最笨的办法是把旧的全卸载、新的全挂载。这能保证正确性但会造成大量DOM操作。mini-vue里第一步要做到的优化就是“同索引位置复用”。function patchKeyedChildren(c1, c2, container) { const oldLen c1.length const newLen c2.length let i 0 // 从前往后比对能patch就patch while (i oldLen i newLen) { patch(c1[i], c2[i], container) i } // 新数组更长剩余部分全部挂载 if (newLen oldLen) { while (i newLen) { patch(null, c2[i], container) i } } else { // 旧数组更长多余部分全部卸载 while (i oldLen) { unmountChildren([c1[i]]) i } } }这种简单diff只按位置匹配节点本身是什么类型它并不关心。一旦列表顺序发生改变或者某个节点被移动了位置这个简单diff还是会拆掉重建。所以真实Vue3里用的是带key的双端diff。mini-vue学习过程中我建议先把简单diff跑通再去看双端diff的双指针优化这两种写法在同一个patchKeyedChildren函数里替换实现即可。3.5 三个测试场景跑通更新链路场景一从文本更新为数组。const container document.querySelector(#app) const renderer createRenderer({ createElement(tag) { return document.createElement(tag) }, insert(el, parent) { parent.appendChild(el) }, setElementText(el, text) { el.textContent text }, remove(el) { el.parentNode.removeChild(el) } }) const vnode1 createVNode(div, {}, hello) renderer.render(vnode1, container) const vnode2 createVNode(div, {}, [ createVNode(span, {}, a), createVNode(span, {}, b) ]) renderer.render(vnode2, container)运行后#app里应该还是那个div但内部从纯文本变成了两个span。如果这个过程内div上绑定了事件点击依然有效这就是复用的意义。场景二从数组更新为文本。renderer.render(vnode3, container) // vnode3 createVNode(div, {}, world)此时页面上两个span应该被移除div文本变成world。关键验证点移除的是两个span节点而不是把div整体删除重来。打开DevTools看元素面板div本身一直在。场景三从数组更新为另一个数组。const vnode4 createVNode(div, {}, [ createVNode(span, {}, x), createVNode(span, {}, y), createVNode(span, {}, z) ]) renderer.render(vnode4, container)第一个span会从“a”更新成“x”第二个从“b”更新成“y”第三个新挂载。这是简单diff的典型效果。4. 常见问题与排查技巧实录4.1 日志里子节点重复挂载如果你看到#app里的子元素越积越多比如更新一次就多一层首先要检查patchElement是否设置了n2.el n1.el。如果没设置新vnode跑到patchChildren时传入的container是undefined或者是一个没有关联到真实DOM的虚拟对象那么所有insert最终都会追加到错误的位置。另一个容易犯的错是在patchChildren数组分支里新旧数组同时存在时误调用了mountElement。请记住只要n1不为null就永远不要主动走mount逻辑而是先调用patch。递归更新的前提条件就是旧节点存在。4.2 数组变文本时旧节点没清干净这个问题的典型表现是div的textContent被设成了新文本但旧的span还挂在DOM里。为什么会这样因为setElementText这个操作在浏览器里等价于el.textContent text它会清空元素的所有子节点。所以理论上即使你不调unmountChildren只要最后设置了文本旧节点也会被清掉。但mini-vue里绝不能依赖这一点因为卸载逻辑不只是移除DOM还要清理事件、响应式副作用等资源。如果你发现旧节点残留在DOM里大概率是shapeFlag判断出了问题比如旧vnode的children明明是数组但prevShapeFlag里没有ARRAY_CHILDREN标记。这种情况一般发生在手写vnode时没有正确设置shapeFlag可以打个断点看一下创建vnode时的位运算结果。4.3 浏览器控制台出现blocked aria-hidden警告实际项目里当更新children导致某个元素被移除或移动时如果该元素内部还保留着焦点浏览器会打印类似这样的警告blocked aria-hidden on an element because its descendant retained focus.这句话很多人第一次看到会懵不知道跟自己的渲染器有什么关系。核心原因很简单可访问性树要求一个被标记为aria-hidden的元素里不能有仍然持有键盘焦点的后代。当你切换页面结构、关闭弹层、切换tab时如果旧节点内部恰好是聚焦状态又因为你使用了移动节点或者设置aria-hidden的更新逻辑浏览器就会拒绝这个属性的变更并输出警告。这个问题我建议这样排查先在unmountChildren里打印当前环境的document.activeElement看看即将卸载的节点是不是焦点所在。如果是在卸载前先把焦点移到body或新的可见区域。很多组件库包括Element Plus在处理弹层关闭和列表更新时都处理过类似问题。这不是渲染器本身的功能缺陷而是DOM更新过程中的一个UX细节但如果你不处理用户使用读屏工具时就会遇到焦点丢失或警告刷屏的情况。4.4 状态丢失为什么列表更新后表单内容全清了你在做一个动态列表每一行里有一个输入框。用户正在输入你把列表数组重新set了一遍。结果输入框内容全空了。原因在于旧children和新children按索引比对后发现类型不同简单diff就直接拆掉了旧的输入框新建了一个新的空输入框。新的DOM元素当然是空的。解决办法是给子节点提供稳定且唯一的key然后diff算法根据key匹配复用的节点。mini-vue里可以先给vnode加一个key属性再在patchKeyedChildren里改用key查找对应旧节点。这一步做完你会发现输入框里的文字能被保留因为Vue认为这是同一个节点只是位置变了或props变了而不是重建。这也是为什么我会反复强调如果你要写自己的框架或渲染器key机制必须在diff这一层就设计好不要等出问题了再补。加key只是给vnode多存一个字段但带来的用户体验差异非常大。4.5 与真实组件库的联系Element Plus的工场在哪看到这里你会发现mini-vue里更新的“element”指的是虚拟DOM中的元素节点而很多同学会把它联想成Element UI、Element Plus组件库。两者其实不冲突Element Plus本质上就是把各种业务组件按钮、弹窗、表格编译成vnode再交给Vue运行时去渲染和更新。我们前面手写的patchChildren逻辑就是组件库所有动态内容更新的底层工场。Element Plus里最常见的Table表格列切换、Dialog内容替换、Menu菜单高亮更新底层都在做同一件事对比新旧vnode的children能补就补能删就删能移就移。理解了mini-vue这一节你再看Element Plus那些表格或弹窗在数据更新时偶尔出现的闪烁、焦点丢失问题就会有非常具体的排查方向。4.6 调试diff的几个小技巧调试这种递归型渲染代码最忌讳直接打console.log看最终DOM因为递归过程很长你根本看不出来是哪一步错了。我建议做三件事在patchChildren入口打印新旧children的type和key确认分支是否走对。在setElementText和insert里打印节点信息确认DOM操作顺序。用浏览器Performance面板录制一段更新动画看是否有大量节点被重新创建。如果diff生效大部分节点应该是“已存在仅更新文本/属性”。我给自己的mini-vue项目加了一个“追踪开关”只有环境变量开启时才打印这些细节。否则每次更新刷出一百行日志日志本身就成了新的噪音。我个人在实际操作中比较深的体会是手写mini-vue的过程里“更新children”是分水岭。没写之前你觉得自己懂了虚拟DOM写完再调试完才算真正看清了框架控制DOM的粒度。如果你也想顺着这条线继续深挖下一步可以试着给vnode加上key实现Vue3同款的双端diff再接着去实现带有移动逻辑的完整diff。等那一步做完再回头看组件库里的列表更新和过渡动画会顺畅很多。
返回列表