虚拟 DOM 的 Diff 算法有一个核心约束:只对比同一层级的节点,不跨层级对比。这是 Vue 和 React 等主流框架共同遵循的策略,也是 Diff 算法能保持线性的关键所在。
为什么必须同层比较
想象一下,如果 Diff 算法允许跨层对比,那么一个 <div> 可能被移动到完全不同的 DOM 树位置,算法就需要尝试所有可能的节点匹配组合,时间复杂度会达到 O(n³),在真实应用中这将导致不可接受的性能灾难。而绝大多数前端界面更新并不涉及跨层级的节点移动——组件的结构在运行时基本是稳定的,你更多是在添加、删除或修改同一层级的列表项。
同层比较将复杂度降到了 O(n),让虚拟 DOM 的 Diff 在毫秒级内完成。它的规则很简单:当对比两个虚拟 DOM 树时,先比第一层,再深入对比子节点,一旦发现节点类型不同,直接删除重建,绝不尝试到兄弟层级里找“可能移过来的节点”。
Vue 中的实际运作方式
Vue 的 patch 过程严格遵循同层遍历:
<!-- 旧虚拟 DOM 树 -->
<div> <!-- level 1 -->
<p>Hello</p> <!-- level 2 -->
<span>A</span> <!-- level 2 -->
</div>
<!-- 新虚拟 DOM 树 -->
<div> <!-- level 1:对比 → 相同 div,继续深入 -->
<span>A</span> <!-- level 2:对比旧 p → 类型不同(p vs span),删除旧 p,创建新 span -->
<span>B</span> <!-- level 2:对比旧 span → 类型相同,复用 DOM,只更新文本 -->
</div>
在上面的例子中,比对完第一层(两个 div 相同),开始遍历第二层子节点:
- 新子节点
<span>与旧子节点<p>标签不同,直接卸载旧<p>并创建新的<span>; - 新子节点第二个
<span>与旧子节点第二个<span>类型相同,则复用该 DOM 元素,仅更新其文本内容。
Vue 不会因为“旧树里已经有了一个 span”而尝试把那个 span 挪到新树的第一个位置,也不会去比较<p>和<span>是否可以通过某种“转换”来复用——不同类型,直接重建。这看似粗暴,但换来了极快的判断速度和极低的内存开销。
列表场景的特殊处理:key 属性
同层比较在遇到列表时,需要一个key来辅助识别节点是否可以复用。没有 key 时,Vue 会使用“就地复用”策略,按顺序逐个对比:
<!-- 旧 -->
<li>A</li>
<li>B</li>
<li>C</li>
<!-- 新(插入一条到最前面) -->
<li>D</li>
<li>A</li>
<li>B</li>
<li>C</li>
无 key 时,Vue 会依次对比:旧[0] vs 新[0] → A vs D → 文本不同,更新 A 为 D;旧[1] vs 新[1] → B vs A → 更新 B 为 A……最终所有节点都被更新了一次,而其实只需要新增一个 <li>D</li> 并保留原节点。
有了唯一的 key,Vue 就能在同一层级内进行更智能的节点复用:它先对比新旧两组的 key 值,找出可复用的节点并移动位置,只创建新 key 对应的 DOM,删除废弃的。这个过程发生的范围仍然是当前这个列表的层级内,不会跳到父级或子级中去。
对开发者的实际启示
- 保持组件结构稳定,不要做跨层级的 DOM 移动。如果确实需要在视觉上把一段内容从 A 区域移动到 B 区域,不要用跨层的新旧虚拟 DOM 结构来“希望 Diff 能识别”,而是通过数据驱动(改变数据渲染位置)或使用
<Teleport>等专门机制来保证结构的同构。 - 列表渲染时务必绑定稳定且唯一的 key,并且不要用数组索引作为 key。这样做 Vue 才能在同一层内精准识别节点身份。
- 知道“类型不同即重建”的代价:如果你频繁切换一个元素的标签类型(如从
<section>变为<article>),该组件内部的子组件会被全部销毁再重建,丢失私有状态。如果需要保留状态,应保持标签类型不变,改用v-if/v-show或动态组件来控制显示。
总结一句话
同层比较就是 Diff 的“车道线”:算法只在自己这一条道里比较和调整节点顺序,绝不会跑到上一道或下一道去串门。这既保证了运行效率,也让你在写模板时养成“结构对齐”的好习惯。