前面了解了虚拟 DOM 的本质和 Diff 算法的策略,这一节把整个过程串联起来:组件渲染时,VNode 是如何从无到有被创建出来的?数据更新后,新旧 VNode 怎么对比?最终又是如何变成真实的 DOM 操作的?
整个流程可以拆成三个阶段:创建 VNode → Diff 对比 → patch 打补丁。下面以一个简单的计数器组件为例,跟着它的首次渲染和更新,走一遍完整链路。
<template>
<div>
<span>{{ count }}</span>
<button @click="increment">+1</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
第一阶段:创建 VNode(挂载时)
1. 组件实例调用渲染函数
当组件首次挂载时,Vue 会执行该组件的渲染函数。这个渲染函数由模板编译而来,它的职责是返回一棵 VNode 树。对于上面的模板,编译后的渲染函数大致等价于:
function render() {
return h('div', null, [
h('span', null, count.value),
h('button', { onClick: increment }, '+1')
])
}
h 函数会根据传入的参数创建对应的 VNode 对象。
2. VNode 对象的结构
每个 VNode 就是这样一个普通的 JavaScript 对象(简化版):
const vnode = {
type: 'div', // 标签名或组件对象
props: null, // 属性、事件等
children: [ // 子节点数组
{
type: 'span',
props: null,
children: '0' // 文本子节点
},
{
type: 'button',
props: { onClick: increment },
children: '+1'
}
],
el: null, // 对应真实 DOM 的引用,初始为空
key: undefined,
patchFlag: undefined, // 编译优化标记
// ... 其他内部属性
}
此时 VNode 只是纯数据描述,还没有挂载任何真实的 DOM 节点。
3. 递归生成真实 DOM
创建完根 VNode 后,Vue 调用 patch 函数,传入 null(旧节点不存在)和新 VNode,意味着这是一个挂载操作。patch 内部会判断这是哪种类型的 VNode:
- 如果是元素类型(
type为字符串),就调用document.createElement创建真实元素,将其保存在 VNode 的el属性中。 - 如果有子节点,就对子节点数组递归调用
patch,将子节点的 DOM 插入到父元素中。 - 最终整个 VNode 树被完整地转化为真实 DOM 树,并一次性挂载到页面容器中。
首次渲染流程总结:
模板编译 → 渲染函数执行 → 产生 VNode 树 → patch(null, vnode) → 创建真实 DOM
第二阶段:对比新旧 VNode(更新时)
当用户点击按钮,count.value 变为 1,触发组件更新。此时渲染函数会再次执行,生成一棵全新的 VNode 树。
1. 产生新的 VNode
// 新生成的 VNode
const newVNode = {
type: 'div',
props: null,
children: [
{ type: 'span', children: '1' }, // 文本从 '0' 变为 '1'
{ type: 'button', children: '+1' } // 无变化
]
}
2. 触发 patch 对比
这次调用 patch(oldVNode, newVNode),oldVNode 是上一次渲染时缓存下来的 VNode 树,newVNode 是刚刚生成的。由于类型相同(都是 'div'),Vue 会进入同层比较逻辑。
3. 同层 Diff:只比动态子节点
根据我们在 5.3 节提到的 Vue 3 Diff 优化,根 div 被标记为稳定节点(没有动态属性),所以它自身的 props 会被跳过。接着对比子节点列表。这里按钮是静态节点,span 是动态文本节点,Vue 会使用基于最长递增子序列的算法来最小化节点移动,但本例中结构未变,只是文本变了,所以它直接进入 patch 文本内容 的步骤。
- 比较
oldVNode.children[0](span)和newVNode.children[0],类型相同,都是span。 span没有动态属性,只有一个文本子节点,所以递归调用patch(oldTextVNode, newTextVNode),发现文本从'0'变为'1'。
整个对比过程中,静态的按钮 VNode 被完全跳过,不会进行任何比对,这正是编译优化的实战体现。
第三阶段:patch 打补丁(最小化 DOM 更新)
当对比发现差异后,patch 内部会执行具体的 DOM 操作。在这个例子中,唯一的真实 DOM 操作是修改 span 元素的文本内容:
// 伪代码示意
function patchElement(oldVNode, newVNode) {
const el = (newVNode.el = oldVNode.el) // 复用旧的 DOM
// ... 更新属性(这里没有变化,跳过)
patchChildren(oldVNode.children, newVNode.children, el)
}
function patchChildren(oldChildren, newChildren, container) {
// 通过 diff 算法比对后,只对发生变化的子节点执行 patch
// 最终调用到 setElementText
setElementText(oldChild.el, newChild.children) // 把 span 的 textContent 改为 '1'
}
patch 的核心原则是复用与最小变更:
- 复用 DOM 节点:新旧 VNode 同类型时,直接复用
oldVNode.el指向的真实 DOM,避免销毁重建。 - 按需更新属性:只修改变化的属性,例如 class、style、事件等。
- 精细化操作子节点:通过 Diff 算法计算出最少的添加、删除、移动操作,不动那些没改变的节点。
整个更新过程只产生了一个真实的 DOM 操作——修改 <span> 的文本。其他的标签、属性、事件监听器都完好地保留下来,完全没有被触碰。
完整流程串联
无论是挂载还是更新,Vue 的渲染管线都可以统一描述为:
数据变更 → 触发渲染函数 → 生成新 VNode → patch(旧 VNode, 新 VNode) → 真实 DOM 操作
- 创建:
h函数将模板描述转换为 VNode 对象。 - 对比:
patch函数同层比较新旧 VNode,利用静态标记、PatchFlags 和双端/LIS 算法快速定位差异。 - 更新:根据对比结果直接操作真实 DOM,完成最小的视图同步。
这套机制让开发者可以用声明式的方式描述界面,而将繁琐的 DOM 操作交给 Vue 的虚拟 DOM 系统自动处理。理解这一流程后,你在使用 key、处理列表、排查更新失效问题时,会更容易看到它们背后的运作逻辑。