最近把 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: Record<string, 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 相同,逐个 patch;i 走到 2。此时i < nextLength成立,而且i >= prevLength也成立,走"新增"分支,把 C 和 D 依次挂载到容器末尾。整个过程只创建了两个节点,A 和 B 完全没有动。
再比如老列表是[A, B, C, D],新列表是[A, B]。循环结束后 i 走到 2,i < 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 场景一:纯文本到纯文本
旧 vnode:createVNode('div', null, 'hello')新 vnode:createVNode('div', null, 'world')
走查过程:
patchElement拿到同一个真实 DOM 节点,先更新 props(这里没有 props 差异,跳过)- 进入
patchChildren nextShapeFlag带TEXT_CHILDREN标记,走"新 children 是文本"分支- 旧 children 不是数组,跳过
unmountChildren - 由于
'hello' !== 'world',执行container.textContent = 'world'
结果:文本节点被整体替换。这个分支的关键点是textContent赋值是原子操作,浏览器内部会处理清除旧文本、插入新文本的过程,不需要我们手动拆分。这里还有个额外收益是惰性比较:只要新旧文本相同,连赋值都省了,这点对高频更新很有用。
4.2 场景二:空 children 到数组
旧 vnode:createVNode('ul', null, null)新 vnode:createVNode('ul', null, [liA, liB])
走查过程:
- 旧
shapeFlag没有TEXT_CHILDREN也没有ARRAY_CHILDREN - 新
shapeFlag带ARRAY_CHILDREN - 进入
patchChildren的 else 分支 prevShapeFlag不是数组,进入"旧是文本或空"分支prevChildren是 null,nextChildren是数组,两者不等,所以container.textContent = ''(虽然是空,但清空一下更稳健)- 新 children 带数组标记,调用
mountChildren逐个挂载 liA、liB
结果:容器从空变成有两个<li>。这里我特意写了container.textContent = '',即使旧的本来就是 null。原因是容器在初次挂载后可能残留一些东西,比如初始化时的纯文本被塞进来,再次更新时如果没有清空,旧的文本会和数组混在一起显示。
4.3 场景三:数组到文本
旧 vnode:createVNode('div', null, [spanA, spanB])新 vnode:createVNode('div', null, 'after')
走查过程:
- 新
shapeFlag带TEXT_CHILDREN,走文本分支 - 旧 children 是数组,调用
unmountChildren unmountChildren内部会遍历旧数组,把每个 span 的 DOM 节点从父容器移除、触发必要的卸载逻辑prevChildren是数组对象,nextChildren是字符串,两者不等,设置container.textContent = 'after'
结果:两个 span 被移除,容器内只剩下 after 文本。
这部分有个细节:unmountChildren我写的是children.forEach(child => unmount(child)),而unmount里会执行el.remove()。如果只卸载 vnode 而不把真实 DOM 从容器里 remove 掉,你会发现文本内容确实变了,但旧 span 依然残留在页面上,而且点开调试面板能看到一堆孤儿元素。这个坑我在后面踩坑部分专门说。
4.4 场景四:数组到数组,中间发生替换
旧 vnode:createVNode('ul', null, [A, B, C])新 vnode:createVNode('ul', null, [A, X, C])
走查过程(使用我最终采用的带兜底策略的版本):
patchKeyedChildren开始循环- i=0:A 和 A key 相同,patch,i 变成 1
- i=1:B 和 X key 不同,break
- 此时不满足"纯尾部新增/删除"条件,进入兜底
- 从 i=1 开始,卸载 B、C,再挂载 X、C
结果:最终页面是 A、X、C。虽然 B 被卸了、C 被重新创建,不算最优解,但页面正确。这里能看出来[A, X, C]其实可以只对 B 做替换就完成更新,但简化版 diff 没有做到,这是后续版本引入最长递增子序列要解决的事。
4.5 场景五:数组尾部新增和删除
旧 vnode:createVNode('ul', null, [A, B])新 vnode:createVNode('ul', null, [A, B, C])
走查过程:
- i=0:A 和 A patch
- i=1:B 和 B patch
- i=2:等于 min(lenOld=2, lenNew=3),循环退出
- 判断
i < nextLength且i >= prevLength,命中新增分支 - 从 i=2 开始挂载 C
这个场景是最舒服的,一次 diff 只做一次创建。反过来[A, B, C] -> [A, B]则命中删除分支,只卸载 C,A 和 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 是否复用了正确的el |
| props 更新正常但 children 不更新 | patchChildren 压根没被调用 | 确认 patchElement 里patchProps之后是否调用了patchChildren |
| 切换后组件状态被重置 | diff 没有复用 key 相同的节点,走了卸载重建 | 在 while 循环里确保相同 key 的节点走 patch 而不是 unmount |
5.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 更新机制的理解就会彻底打通。但前提是把这一版简化版的四个分支和兜底策略先吃透,基础不牢,算法再花哨也发挥不出来。