尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

手写mini-vue:彻底搞懂虚拟DOM的children更新与patch流程

发布时间:2026/9/28 14:24:31

资讯中心
01
ARTICLE

手写mini-vue:彻底搞懂虚拟DOM的children更新与patch流程

手写mini-vue:彻底搞懂虚拟DOM的children更新与patch流程
最近把 mini-vue 系列推进到第31个版本这次要解决的是 element 更新时 children 的处理。这个版本做完我才算真正理解了虚拟 DOM 的 patch 流程之前写属性更新、事件更新的时候都是点对点替换唯独 children 这个位置旧节点和新节点的 children 可能是文本、可能是数组、也可能什么都没有四种组合对应四种完全不同的更新策略。如果你正在跟着 mini-vue 系列手写简化版 Vue或者读 Vue3 源码时被 patchChildren 那一大段分支绕晕这篇应该能帮你把线理清楚。先说明一个容易踩的误区这里的 element 不是 Element Plus 组件库。在虚拟 DOM 的世界里element 指的就是一个元素节点是 createVNode 出来的那棵树上挂着的某个节点。搜索时容易撞上element plus 主题切换这类热词但方向完全不同。这篇聊的是 mini-vue 项目里元素节点在更新阶段怎么把 children 正确刷到页面上。1. 项目背景与整体设计思路1.1 为什么到第31个版本才轮到 children 更新mini-vue 是沿着初始化渲染 - 响应式 - 组件更新 - diff这条主线推进的。前面十几个版本把 vnode 创建、render、mount、patch 都跑通了但绝大多数练习项目在最开始的更新阶段只处理了 props 的属性差异children 部分基本是整个容器重新 innerHTML或者无脑清空再挂载。到31版这个节点再绕过 children 更新就说不过去了。因为一个元素节点更新时真正高频变化的就是三块class/style 属性、事件监听器、子节点。前两块是单对单的对比写起来相对无脑子节点这块牵扯到类型判定、复用策略、批量挂载卸载是虚拟 DOM 里最容易写崩的位置。我当时的代码已经实现了patchElement拿到新旧 vnode 的 props调用patchProps完成属性更新。但 children 的 patch 还停留在注释阶段。这个版本要做的就是把这个注释换成实际逻辑让下面这种场景也能正常工作一个div的文本子节点从 hello 变成 world一个ul从没子节点变成有一批li一个容器里有旧子节点新子节点变成了文本1.2 核心目标复用 DOM 节点而不是重建写 children 更新前我给自己定了一条原则尽量复用现有 DOM 节点只在必要的时候增删改。这句话背后是一个很实际的收益问题。假设一个列表从[A, B, C]变成[A, X, C]如果采用清空容器再重新挂载的粗暴方案浏览器要做的是删除 3 个旧 DOM 节点、创建 3 个新 DOM 节点、执行 3 次插入。而如果能够复用 A 和 C我们只需要删除 B、创建一个 X、在 B 的位置插进去DOM 操作次数直接少了一半以上。更关键的是状态保留。一个input如果被删掉再重建它的输入内容、光标位置、当前焦点全部丢失一个组件节点如果被删掉再重建它的内部 data 都会被重置。用户刚填了一半的表单因为一次列表更新全部清空这种体验是不能接受的。所以这个版本的核心目标不是把界面变对而是用最少的 DOM 操作把界面变对。1.3 数据结构准备vnode 与 children 的两种形态动手写之前先把底层数据结构理清楚。mini-vue 的 vnode 一般是这样的interface VNode { type: string | object // 标签名或组件对象 props: Recordstring, any | null children: string | VNode[] | null shapeFlag: number // 用于快速判断类型 key: any // 用于节点复用对比 el: any // 对应真实 DOM 节点挂载位置 }其中children有两种主要形态文本形态字符串例如createVNode(div, null, hello)数组形态VNode[]例如createVNode(ul, null, [li1, li2])还有一种容易被忽略的形态空。也就是压根没传 children比如br/这种自闭合标签为了快速判断 children 类型mini-vue 沿用了 Vue3 的 shapeFlag 思路用二进制位标记export const enum ShapeFlags { TEXT_CHILDREN 1 0, // children 是文本 ARRAY_CHILDREN 1 1, // children 是数组 }在 createVNode 时根据 children 的类型打好标记后面 patch 时直接用位运算判断就行。这一步看起来简单但非常重要——后面所有 children 更新的分支逻辑都要依赖这个标记。2. children 更新的四种组合与方案选择2.1 新老 children 的类型判定更新 element 的 children 时我们需要面对一个二维的判定老 children 是什么类型新 children 是什么类型。每种组合的处理方式完全不同。在代码里我会先取出新旧 vnode 的 shapeFlag再通过位与运算判断类型const prevShapeFlag n1.shapeFlag const nextShapeFlag n2.shapeFlag const isPrevText prevShapeFlag ShapeFlags.TEXT_CHILDREN const isPrevArray prevShapeFlag ShapeFlags.ARRAY_CHILDREN const isNextText nextShapeFlag ShapeFlags.TEXT_CHILDREN const isNextArray nextShapeFlag ShapeFlags.ARRAY_CHILDREN这里最容易犯的一个错误是用去比较 shapeFlag 的值。比如nextShapeFlag ShapeFlags.TEXT_CHILDREN这种写法在简单场景可能碰巧成立但只要 vnode 同时挂了别的标记位比如ELEMENT | TEXT_CHILDREN整个判断就失效了。位运算就是用来解决这个问题的的结果只关心某一位是否为 1其他位完全不影响。2.2 四种组合与对应的更新策略把新旧类型组合一下只有四种有意义的情况旧 children新 children处理策略文本文本直接替换textContent最省事文本 / 空数组清空文本逐个挂载新子节点数组文本 / 空逐个卸载旧子节点再设置文本或清空数组数组进入 diff 逻辑尽量复用旧节点把这四种情况列出来之后结构一下就清晰了。Vue3 源码里的patchChildren主干就是按这个思路写的mini-vue 完全可以照搬这个骨架只是内部实现可以简化。为什么文本到文本可以直接替换因为textContent的赋值操作本身就是浏览器层面的清空旧文本再写入新文本不需要我们手动逐字对比。这也是 DOM 标准给我们的红利不用白不用。为什么文本到数组要先清空再挂载文本节点本质上没有结构无法复用旧文本只能被整体清除。清空之后再逐个调用patch(null, 新子节点, container)完成挂载。为什么数组到文本要先卸载再设置文本数组里的元素可能是组件节点有生命周期钩子需要触发可能绑定了事件需要解绑。直接container.textContent xxx虽然能把整个容器的内容替换成文本但不会触发子组件的卸载逻辑会造成内存泄漏和事件残留。所以必须先逐个unmount再设置文本。2.3 为什么数组到数组不能简单清空重建数组到数组是四种组合里唯一需要算法参与的情况。我知道很多人第一次实现的时候会想既然新旧都是数组我把旧的全删了新的全挂上去不也一样正确吗正确性上确实无懈可击但代价非常大。还是拿[A, B, C] - [A, X, C]这个例子说清空重建要做 3 次删除加 3 次创建加 3 次插入而 diff 只需要 1 次删除加 1 次创建加 1 次插入。当列表变成 1000 条时差距就是 2000 次无效的 DOM 操作。更麻烦的是状态丢失。一个带value的input在列表重排的时候如果被删掉再重建用户输入的内容直接没了。一个带滚动位置的容器重建之后滚动条会回到顶部。这些都不是看起来能工作就够了的真实项目里全是这种细节。所以数组到数组必须走 diff 逻辑在旧 children 里找出和新 children 中 key 相同的节点通过 patch 进行内容更新尽量保留下原 DOM 节点。这个 diff 可以做得简单也可以做到 Vue3 那种带最长递增子序列的极致优化关键取决于我们当前版本想做到什么程度。3. 核心实现patchChildren 编写全过程3.1 从 patchElement 入口切入在 mini-vue 里元素更新的入口是patchElement。它的职责比较单一拿到新旧 vnode确保n2.el复用n1.el的真实 DOM 节点然后更新 props最后更新 children。function patchElement(n1: VNode, n2: VNode, container: HTMLElement, parentInstance: any) { // 关键n2 必须先拿到 n1 的 el之后所有挂载操作都基于这个 el const el (n2.el n1.el) const oldProps n1.props || {} const newProps n2.props || {} // 先处理属性差异 patchProps(el, oldProps, newProps) // 这个版本的重点更新 children patchChildren(n1, n2, el, parentInstance) }这里有个执行顺序值得注意先属性后 children。为什么不是先 children 再属性因为我们 setAttribute 或者设置 style 的时候如果子节点还没挂载可能出现一些奇怪的中间状态比如父容器高度为 0 时子节点挂载后触发的 layout 计算。实践中先处理 props 再挂载子节点行为更接近 Vue3 的 patchElement 顺序也更容易排查问题。3.2 实现 patchChildren 主函数我实际写的patchChildren如下这段代码基本还原了 Vue3 的分支骨架但把内部逻辑大幅简化了function patchChildren( n1: VNode, n2: VNode, container: HTMLElement, parentInstance: any ) { const prevShapeFlag n1.shapeFlag const nextShapeFlag n2.shapeFlag const prevChildren n1.children const nextChildren n2.children // 新 children 是文本 if (nextShapeFlag ShapeFlags.TEXT_CHILDREN) { // 旧 children 是数组需要先逐个卸载 if (prevShapeFlag ShapeFlags.ARRAY_CHILDREN) { unmountChildren(prevChildren) } // 文本内容不一致时才真正写 DOM if (prevChildren ! nextChildren) { container.textContent nextChildren } } else { // 新 children 是数组或者为空 if (prevShapeFlag ShapeFlags.ARRAY_CHILDREN) { if (nextShapeFlag ShapeFlags.ARRAY_CHILDREN) { // 数组到数组进入 diff patchKeyedChildren(prevChildren, nextChildren, container, parentInstance) } else { // 新 children 为空直接卸载旧数组 unmountChildren(prevChildren) } } else { // 旧 children 是文本或空 if (prevChildren ! nextChildren) { container.textContent } if (nextShapeFlag ShapeFlags.ARRAY_CHILDREN) { mountChildren(nextChildren, container, parentInstance) } } } }这段代码我来逐块解释。第一块是新 children 是文本的分支。这里把旧 children 是数组单独拎出来是因为数组里的节点都有各自的生命周期和事件绑定必须由我们的unmountChildren一个一个卸载而不是简单地让textContent赋值把它们覆盖掉。否则组件卸载钩子不会触发。第二块是新 children 不是文本的分支。这里的核心判断是旧 children 是不是数组。如果新旧都是数组就进入 diff如果旧数组但新为空直接卸载旧数组如果旧是文本或空而新是数组就要先清空旧文本再挂载新数组。有一个细节很多新手会漏掉当旧 children 是文本、新 children 是空时需要把container.textContent清空。有人会说反正新 children 为空不处理也无所谓但这样旧文本会一直显示在页面上DOM 和 vnode 就不同步了。所以在旧文本或空的分支里第一步就是先判断prevChildren ! nextChildren并清空内容。3.3 数组到数组这个版本的简化 diff 策略数组到数组是重头戏。Vue3 源码里的patchKeyedChildren非常复杂有头部同步、尾部同步、剩余节点的新增删除、最长递增子序列移动等几大块。我决定在第31版先实现一个简化版本只做两件事从左侧开始对相同 key 的节点逐个 patch当遇到 key 不一致时停止然后处理尾部新增或尾部删除这对应的是 diff 中同步头部和处理新增删除两段具体代码是这样function patchKeyedChildren( prevChildren: VNode[], nextChildren: VNode[], container: HTMLElement, parentInstance: any ) { let i 0 const prevLength prevChildren.length const nextLength nextChildren.length const commonLength Math.min(prevLength, nextLength) // 第一轮从左边开始相同 key 就 patch直到 key 不同或越界 while (i commonLength) { const prevNode prevChildren[i] const nextNode nextChildren[i] if (prevNode.key nextNode.key) { // key 相同直接复用 patch这个调用会递归处理内容和属性 patch(prevNode, nextNode, container, parentInstance) i } else { // key 不一致立即退出循环 break } } // 新 children 还有剩余旧 children 已经全部处理完 - 新增 if (i nextLength i prevLength) { for (let j i; j nextLength; j) { patch(null, nextChildren[j], container, parentInstance) } } // 旧 children 还有剩余新 children 已经全部处理完 - 删除 if (i prevLength i nextLength) { for (let j i; j prevLength; j) { unmount(prevChildren[j]) } } }这个简化版本能解决一类非常常见的场景在数组尾部追加新元素或者在数组尾部删除元素。举个例子老列表是[A, B]新列表是[A, B, C, D]。循环先对比 A、B发现 key 相同逐个 patchi 走到 2。此时i nextLength成立而且i prevLength也成立走新增分支把 C 和 D 依次挂载到容器末尾。整个过程只创建了两个节点A 和 B 完全没有动。再比如老列表是[A, B, C, D]新列表是[A, B]。循环结束后 i 走到 2i prevLength且i nextLength成立走删除分支把 C 和 D 卸载掉。同样只操作了两个节点。3.4 处理中间替换与 key 不一致的情况但上面这个简化版本有个明显漏洞如果新旧数组从中间某一位开始不一致比如[A, B, C]更新成[A, X, C]会发生什么循环里 A 和 A 相同i 变成 1然后 B 和 X 的 key 不同直接 break。此时 i 1既没有触发新增分支因为i nextLength成立但i prevLength不成立也没有触发删除分支因为i prevLength成立但i nextLength不成立。结果就是什么都不会做B 还留在 DOM 里X 没有被创建页面是错的。我在写第一版时踩了这个坑。解决办法其实不复杂当 key 不一致时不要立刻放弃所有剩余节点而是把剩余的新旧节点分别收集起来用 key 做映射然后逐个判断。考虑到31版的目标是先把主流程跑通我最终采用了更保守的兜底策略// 在 while 循环 break 之后加一个兜底 if (i commonLength || prevLength ! nextLength) { // 从 i 位置开始把旧剩余节点全部卸载新剩余节点全部重新挂载 for (let j i; j prevLength; j) { unmount(prevChildren[j]) } for (let j i; j nextLength; j) { patch(null, nextChildren[j], container, parentInstance) } }这个兜底方案的正确性是没问题的如果中间某处 key 对不上就把从 i 开始的旧节点全部移除再把新节点全部挂载最终页面一定和新 vnode 一致。代价是不能复用中间那些本可以复用的节点会有一定的无效 DOM 操作。但对于一个练习项目来说先把正确性做对再去优化性能这是最稳的路径。我实际代码里的策略是while 循环结束后先判断是否满足纯尾部新增/删除的条件如果满足就走精准操作如果不满足就走兜底的全量卸载重建。搭配起来能覆盖大多数日常渲染场景。4. 场景走查用真实案例验证更新逻辑写完实现之后光看代码通过不了测试我习惯用几个具体的例子把更新逻辑从头到尾走一遍。这里选了四个最有代表性的场景每个都对应patchChildren里的一个分支。4.1 场景一纯文本到纯文本旧 vnodecreateVNode(div, null, hello)新 vnodecreateVNode(div, null, world)走查过程patchElement拿到同一个真实 DOM 节点先更新 props这里没有 props 差异跳过进入patchChildrennextShapeFlag带TEXT_CHILDREN标记走新 children 是文本分支旧 children 不是数组跳过unmountChildren由于hello ! world执行container.textContent world结果文本节点被整体替换。这个分支的关键点是textContent赋值是原子操作浏览器内部会处理清除旧文本、插入新文本的过程不需要我们手动拆分。这里还有个额外收益是惰性比较只要新旧文本相同连赋值都省了这点对高频更新很有用。4.2 场景二空 children 到数组旧 vnodecreateVNode(ul, null, null)新 vnodecreateVNode(ul, null, [liA, liB])走查过程旧shapeFlag没有TEXT_CHILDREN也没有ARRAY_CHILDREN新shapeFlag带ARRAY_CHILDREN进入patchChildren的 else 分支prevShapeFlag不是数组进入旧是文本或空分支prevChildren是 nullnextChildren是数组两者不等所以container.textContent 虽然是空但清空一下更稳健新 children 带数组标记调用mountChildren逐个挂载 liA、liB结果容器从空变成有两个li。这里我特意写了container.textContent 即使旧的本来就是 null。原因是容器在初次挂载后可能残留一些东西比如初始化时的纯文本被塞进来再次更新时如果没有清空旧的文本会和数组混在一起显示。4.3 场景三数组到文本旧 vnodecreateVNode(div, null, [spanA, spanB])新 vnodecreateVNode(div, null, after)走查过程新shapeFlag带TEXT_CHILDREN走文本分支旧 children 是数组调用unmountChildrenunmountChildren内部会遍历旧数组把每个 span 的 DOM 节点从父容器移除、触发必要的卸载逻辑prevChildren是数组对象nextChildren是字符串两者不等设置container.textContent after结果两个 span 被移除容器内只剩下 after 文本。这部分有个细节unmountChildren我写的是children.forEach(child unmount(child))而unmount里会执行el.remove()。如果只卸载 vnode 而不把真实 DOM 从容器里 remove 掉你会发现文本内容确实变了但旧 span 依然残留在页面上而且点开调试面板能看到一堆孤儿元素。这个坑我在后面踩坑部分专门说。4.4 场景四数组到数组中间发生替换旧 vnodecreateVNode(ul, null, [A, B, C])新 vnodecreateVNode(ul, null, [A, X, C])走查过程使用我最终采用的带兜底策略的版本patchKeyedChildren开始循环i0A 和 A key 相同patchi 变成 1i1B 和 X key 不同break此时不满足纯尾部新增/删除条件进入兜底从 i1 开始卸载 B、C再挂载 X、C结果最终页面是 A、X、C。虽然 B 被卸了、C 被重新创建不算最优解但页面正确。这里能看出来[A, X, C]其实可以只对 B 做替换就完成更新但简化版 diff 没有做到这是后续版本引入最长递增子序列要解决的事。4.5 场景五数组尾部新增和删除旧 vnodecreateVNode(ul, null, [A, B])新 vnodecreateVNode(ul, null, [A, B, C])走查过程i0A 和 A patchi1B 和 B patchi2等于 min(lenOld2, lenNew3)循环退出判断i nextLength且i prevLength命中新增分支从 i2 开始挂载 C这个场景是最舒服的一次 diff 只做一次创建。反过来[A, B, C] - [A, B]则命中删除分支只卸载 CA 和 B 原封不动。日常开发里这种尾部增删的比例相当高光是这个简化版 diff 就能覆盖掉大量真实需求。5. 踩坑记录与排查思路5.1 类型判断的坑位运算误当成普通比较我最初写patchChildren时直接用了if (prevShapeFlag ShapeFlags.TEXT_CHILDREN)。当时觉得自己写得没问题直到有一天我用createVNode(div, { class: box }, text)创建节点shapeFlag 的值是ELEMENT | TEXT_CHILDREN也就是1 | 1的复合值直接和TEXT_CHILDREN做比较当然不成立文本 children 分支全被跳过。排查过程很直接在patchChildren开头打印prevShapeFlag和nextShapeFlag的值一眼就发现复合标记问题。改成位与运算之后一切正常。这个坑值得记下来因为 Vue3 源码里到处都是判断很多人抄源码时会把顺手写成在形如ShapeFlags.TEXT_CHILDREN的纯净场景可能碰巧通过但稍微复杂一点的 vnode 就崩。5.2 卸载时忘记移出父容器这个坑出现在场景三的测试里。我第一次只实现了unmount里更新 vnode 的卸载状态没有调用 DOM 的 remove结果页面出现了旧节点还在新文本也显示的诡异局面。后来我在 unmount 里补上了el.remove()function unmount(vnode: VNode) { if (vnode.el) { vnode.el.remove() vnode.el null } }这里还有第二个细节vnode.el置空很关键。如果不置空后续这条 vnode 再次被 patch 时可能会拿着一个已经不在 DOM 树里的旧引用去挂载新内容造成幽灵节点。很多排查不出来的节点越来越多问题最后都出在这。5.3 中间替换场景无反应这个是简化版 diff 最初版本最严重的 bug[A, B, C] - [A, X, C]更新后页面完全不变。原因我在 3.4 已经分析过循环 break 后没有走任何分支。排查思路是加日志。我在 while 循环退出后打印i、prevLength、nextLength很快发现i停留在 1两个新增/删除分支条件都不成立。这个问题的根源是简化版不够完善但从工程角度说它提醒了我任何 diff 逻辑都必须考虑中间位置的场景不能只针对首尾。最后的兜底方案虽然粗暴但保证了正确性作为学习版本是可以接受的。5.4 常见问题速查表现象可能原因解决方式文本更新后旧元素还在页面残留unmount没有把真实 DOM 从父容器移除在unmount中调用el.remove()数组到数组中间替换后页面不变简化版 diff 在 key 不一致时 break 后无兜底增加剩余节点的卸载重建兜底逻辑shapeFlag 判断失效用比较复合标记值改用位与运算判断新增节点没有出现在预期位置新节点用patch(null, vnode)挂载时容器位置不对检查 patchElement 是否复用了正确的elprops 更新正常但 children 不更新patchChildren 压根没被调用确认 patchElement 里patchProps之后是否调用了patchChildren切换后组件状态被重置diff 没有复用 key 相同的节点走了卸载重建在 while 循环里确保相同 key 的节点走 patch 而不是 unmount5.5 这个版本给后续留的演进空间31版做的简化 diff 虽然能用但离 Vue3 的完整patchKeyedChildren还有距离。后面如果想继续深入有三个方向可以演进第一个方向是完善 key 映射查找。现在 while 循环遇到 key 不一致就直接 break 了但 Vue3 是通过 key 构建 Map在旧节点里快速找到可复用的节点而不是靠位置索引对齐。这样[A, B, C] - [B, A, C]这种整体平移也能识别出 B、A、C 都是可复用的。第二个方向是引入最长递增子序列Longest Increasing Subsequence。在剩余节点里如果通过 LIS 找出那些不需要移动的节点就可以只移动少量节点来达到最终顺序。比如[A, B, C] - [A, C, B]LIS 是 A 和 B实际只需移动 C操作量从全部重建降到 1 次插入。这是 Vue3 性能领先的关键。第三个方向是处理 key 相同但 type 不同的情况。现在判断只看了 key如果 key 一样但 type 变了直接 patch 会出问题。需要加一层isSameVNodeType判断不同 type 必须走卸载重建。6. 实操心得与版本验收这个版本做完之后我的一个重要体会是children 更新的难点不在于 diff 算法有多复杂而在于你需要在正确性和性能之间做取舍。31版的简化 diff 保证了页面一定正确代价是中间节点无法复用这在真实项目里是可以接受的因为大多数列表更新就是尾部追加和头部删除高频的使用方式已经被覆盖了。我强烈建议你在自己的 mini-vue 里跑一遍这五个场景不要只在纸上推演。做法很简单在patchKeyedChildren的 while 循环和两个分支处加上console.log然后打印i、commonLength、prevLength、nextLength的值对比你的预期。这个习惯能帮你快速定位很多看起来是页面问题其实是 diff 问题的 bug。最后再分享一个调试技巧给 vnode 挂一个debugId字段在创建和 patch 时打印。比如createVNode(li, { key: a }, A)时加一行日志这样 diff 过程中你就能清楚地看到哪个节点被 patch、哪个节点被 unmount、哪个节点被重新挂载。mini-vue 这种学习项目最大的优势就是代码可控善用日志能让你对虚拟 DOM 运行机制的认识提升一个档次。如果后续你有时间建议把带 key 的完整 diff 实现了步骤是先同步头部、再同步尾部、然后处理剩余节点的新增和删除最后用 LIS 做最少的 DOM 移动。到那一步你对 Vue3 更新机制的理解就会彻底打通。但前提是把这一版简化版的四个分支和兜底策略先吃透基础不牢算法再花哨也发挥不出来。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。