人人都会AI编程

5.5 VNode 的创建、对比、 patch 全流程

更新时间:2026-07-11

前面了解了虚拟 DOM 的本质和 Diff 算法的策略,这一节把整个过程串联起来:组件渲染时,VNode 是如何从无到有被创建出来的?数据更新后,新旧 VNode 怎么对比?最终又是如何变成真实的 DOM 操作的?

整个流程可以拆成三个阶段:创建 VNodeDiff 对比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、处理列表、排查更新失效问题时,会更容易看到它们背后的运作逻辑。