虚拟 DOM 与 patch:VNode 是如何变成真实 DOM 的
🎯 引言
模板编译出的 render 函数会产出一棵 VNode 树,但 VNode 只是一个普通的 JS 对象,浏览器并不认识它。中间还缺一步:把 VNode 变成真实 DOM,以及在数据变化时高效地更新 DOM。这一步就是 patch。
学完这篇,你能说清三件事:VNode 长什么样、patch 如何完成首次渲染和更新、双端 Diff 的四次比较顺序。
🧱 前置衔接
Vue2 的虚拟 DOM 实现参考了 Snabbdom,VNode 的结构、patch 的流程、双端 Diff 的思想都和它一脉相承。如果你还没学过,建议先修 Snabbdom 课程,其中的 h、patch、双端指针等概念会直接在本篇的源码里出现。
本篇站在 Vue2 源码(vue@2.7.16)的角度呼应这些概念,不再重复细讲原理细节,重点看"Vue 是怎么用它落地的"。
🧱 VNode:用 JS 对象描述 DOM
VNode(Virtual Node)就是一个普通的 JS 对象,用字段描述一个 DOM 节点的全部信息。 源码中的定义在 src/core/vdom/vnode.js,简化后核心字段如下:
// 简化自 src/core/vdom/vnode.js
export default class VNode {
constructor(tag, data, children, text, elm, key, componentOptions) {
this.tag = tag // 标签名,如 'div',组件节点则为 undefined
this.data = data // 属性、事件、class 等数据对象
this.children = children // 子 VNode 数组
this.text = text // 文本内容(文本节点专用)
this.elm = elm // 对应的真实 DOM 元素
this.key = key // 节点的唯一标识
this.componentOptions = componentOptions // 组件节点的选项(组件专用)
}
}
看一个具体例子就明白了。下面这个真实 DOM:
<div id="app">
<p>hello</p>
</div>
对应的 VNode 大致是:
{
tag: 'div',
data: { attrs: { id: 'app' } },
children: [
{
tag: 'p',
data: undefined,
children: undefined,
text: undefined,
elm: undefined // 渲染后会被赋值为真实的 <p> 元素
}
]
}
其中 elm 字段很关键:它是 VNode 和真实 DOM 之间的桥梁。patch 创建出真实元素后,会把引用存回 elm,后续更新时就能直接操作对应的真实节点,不用再满页面找。
🧱 _render 与 _update:从 render 函数到 patch
渲染分两步走,对应实例上的两个方法(简化自 src/core/instance/render.js 与 src/core/instance/lifecycle.js):
// 第一步:执行 render 函数,生成 VNode 树
Vue.prototype._render = function () {
const vm = this
const { render } = vm.$options
// render 内部通过 $createElement(即 h 函数)创建 VNode
const vnode = render.call(vm, vm.$createElement)
return vnode
}
// 第二步:把 VNode 变成真实 DOM
Vue.prototype._update = function (vnode) {
const vm = this
const prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// 首次渲染:oldVnode 是真实的挂载点元素
vm.$el = vm.__patch__(vm.$el, vnode)
} else {
// 更新渲染:oldVnode 是上一棵 VNode 树
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
流程一句话概括:_render() 产出 VNode,_update() 调 __patch__ 把它落到页面上。注意 _update 里的分支:首次渲染和更新渲染走的都是 __patch__,只是传入的第一个参数不同,这正是下一节的内容。
🧱 patch 的两条路径
patch 的入口逻辑(简化自 src/core/vdom/patch.js)按 oldVnode 的形态分两条路:
// 简化自 src/core/vdom/patch.js
function patch(oldVnode, vnode) {
if (!oldVnode) {
// 路径一:oldVnode 为空(如组件初次渲染)
// 没有可对比的对象,直接按 vnode 创建整棵 DOM
createElm(vnode)
} else {
if (sameVnode(oldVnode, vnode)) {
// 路径二:新旧节点是"同一个",精细对比更新
patchVnode(oldVnode, vnode)
} else {
// 不是同一个节点:用 vnode 创建新元素,替换并销毁旧的
const elm = oldVnode.elm
const parentElm = elm.parentNode
createElm(vnode, parentElm, elm)
removeVnodes([oldVnode], 0, 0)
}
}
return vnode.elm
}
- 首次渲染:
oldVnode是空的(或是待替换的挂载点),没有对比的意义,直接createElm递归创建整棵 DOM 树插入页面。 - 更新渲染:新旧两棵 VNode 树都在,先过
sameVnode这关,决定是"复用更新"还是"销毁重建"。
⚖️ sameVnode 与 patchVnode
sameVnode:是不是同一个节点
// 简化自 src/core/vdom/patch.js
function sameVnode(a, b) {
return (
a.key === b.key &&
a.tag === b.tag &&
// 双方同为注释节点、都定义了 data 等辅助判断
isDef(a.data) === isDef(b.data)
)
}
核心就两条:key 相同、tag 相同。判断结果决定更新路径:
- 相同 → 进入
patchVnode,复用真实元素,只更新内容。 - 不同 → 旧元素销毁,新元素重建。
patchVnode:复用节点的三类情况
能进 patchVnode,说明新旧节点"是同一个人",真实元素(elm)直接复用,要处理的只是它的内容。简化后的分支:
// 简化自 src/core/vdom/patch.js
function patchVnode(oldVnode, vnode) {
const elm = (vnode.elm = oldVnode.elm) // 复用真实元素
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(vnode.text)) {
// 情况一:新节点是文本,直接覆盖
elm.textContent = vnode.text
} else if (isDef(oldCh) && isDef(ch)) {
// 情况二:新旧都有子节点,进入双端 Diff
updateChildren(elm, oldCh, ch)
} else if (isDef(ch)) {
// 情况三:只有新节点有子节点,先清空旧文本再批量插入
elm.textContent = ''
addVnodes(elm, null, ch, 0, ch.length - 1)
} else if (isDef(oldCh)) {
// 新节点没有内容,删掉所有旧子节点
removeVnodes(oldCh, 0, oldCh.length - 1)
}
}
这和 Snabbdom 课程里讲的更新规则完全一致:文本直接覆盖、都有 children 走 updateChildren、一方有子节点则增删。Vue2 只是多处理了组件节点等场景,思想没有变化。
🧠 updateChildren:双端 Diff 的四次比较
当新旧节点都有 children 时,进入 updateChildren。Vue2 沿用了 Snabbdom 的双端指针策略:在旧、新两个子数组上各放头、尾两个指针,每一轮按固定顺序做四次比较。
比较顺序和处理方式:
| 顺序 | 比较 | 命中说明 | 处理方式 |
|---|---|---|---|
| ① | 旧头 vs 新头 | 开头没变 | patchVnode 更新内容,两个头指针后移 |
| ② | 旧尾 vs 新尾 | 结尾没变 | patchVnode 更新内容,两个尾指针前移 |
| ③ | 旧头 vs 新尾 | 旧的开头被移到了最后 | 更新内容,把旧头的真实元素移到末尾 |
| ④ | 旧尾 vs 新头 | 旧的结尾被移到了最前 | 更新内容,把旧尾的真实元素移到开头 |
简化的伪代码如下(真实源码在 src/core/vdom/patch.js 的 updateChildren,多了 key 映射表等分支,这里只保留主干):
// 简化自 src/core/vdom/patch.js 的 updateChildren
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0, oldEndIdx = oldCh.length - 1
let newStartIdx = 0, newEndIdx = newCh.length - 1
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (sameVnode(oldCh[oldStartIdx], newCh[newStartIdx])) {
// ① 旧头 vs 新头
patchVnode(oldCh[oldStartIdx], newCh[newStartIdx])
oldStartIdx++; newStartIdx++
} else if (sameVnode(oldCh[oldEndIdx], newCh[newEndIdx])) {
// ② 旧尾 vs 新尾
patchVnode(oldCh[oldEndIdx], newCh[newEndIdx])
oldEndIdx--; newEndIdx--
} else if (sameVnode(oldCh[oldStartIdx], newCh[newEndIdx])) {
// ③ 旧头 vs 新尾:旧头被移到了最后
patchVnode(oldCh[oldStartIdx], newCh[newEndIdx])
parentElm.insertBefore(oldCh[oldStartIdx].elm, null) // 移到末尾
oldStartIdx++; newEndIdx--
} else if (sameVnode(oldCh[oldEndIdx], newCh[newStartIdx])) {
// ④ 旧尾 vs 新头:旧尾被移到了最前
patchVnode(oldCh[oldEndIdx], newCh[newStartIdx])
parentElm.insertBefore(oldCh[oldEndIdx].elm, oldCh[oldStartIdx].elm)
oldEndIdx--; newStartIdx++
} else {
// 四次都不命中:用旧子节点的 key 建映射表查找
// 找得到就复用并移动,找不到就新建
}
}
// 循环结束后:旧数组有剩余则删除,新数组有剩余则插入
}
记忆这个流程有个小技巧:先两端、再交叉。①② 处理"原地不动"的常见情况,③④ 处理"整体翻转/搬运"的情况,都不命中才动用 key 映射表兜底。每一步都是 O(1),整体复杂度 O(n)。
🔑 key 的作用回顾
到这里 key 的意义就水落石出了:key 是节点的"身份证",它和 tag 一起决定 sameVnode 的判断结果,也就决定了节点能否被复用。列表渲染时给每项一个稳定且唯一的 key,能让 Diff 一直走"复用 + 移动"的高效路径,而不是按位置就地复用、改写内容。
🧾 小节总结
- VNode 是用 JS 对象描述 DOM 的结构,核心字段有
tag、data、children、text、elm、key。 _render()执行 render 函数生成 VNode 树,_update()调__patch__把它变成真实 DOM。- patch 分两条路径:
oldVnode为空时直接createElm整棵创建,否则先过sameVnode判断。 sameVnode要求 key 和 tag 相同:相同走patchVnode复用更新,不同则销毁重建。patchVnode按内容分三类:新节点是文本直接覆盖、双方都有 children 走updateChildren、一方有子节点则增删。updateChildren采用双端指针:旧前新前、旧后新后、旧前新后、旧后新前四次比较,不命中再用 key 映射表兜底。
❓ 知识问答
Q1:首次渲染也需要 patch 吗?
A:需要。首次渲染时 oldVnode 是空的(或挂载点元素),patch 走 createElm 路径,直接按 VNode 树创建整棵 DOM,不涉及对比。
Q2:sameVnode 相同就一定复用吗?
A:是的,进入 patchVnode 后真实元素(elm)直接被新 VNode 继承,后续只更新属性和内容,元素本身不重建。
Q3:双端比较四次都不命中时怎么办?
A:用旧子节点的 key 建一个映射表,拿新头节点的 key 去查:查得到就复用那个旧节点并把它的真实元素移到正确位置,查不到就为它新建一个元素。
Q4:为什么 updateChildren 不直接用双重循环一一对比?
A:双重循环是 O(n²),双端指针把常见更新(头尾不变、整体移动)都收敛成了 O(1) 的指针移动,整体只需 O(n),且减少了真实 DOM 的移动次数。
🧪 小练习
假设页面列表从 [A, B, C] 更新为 [C, A, B](三个 li 的 key 分别为 a、b、c),不写代码,用注释推演双端比较的完整过程:每一轮命中哪次比较、指针怎么移动、做了什么 DOM 操作。
// 旧子节点: [A(key:a), B(key:b), C(key:c)]
// 新子节点: [C(key:c), A(key:a), B(key:b)]
//
// 第 1 轮:命中第 ___ 次比较(提示:先比旧头 vs 新头,再比旧尾 vs 新尾)
// 请在这里编写代码(用文字描述本轮的比较结果和指针移动)
//
// 第 2 轮:
// 请在这里编写代码
//
// 最终 DOM 操作统计:
// 请在这里编写代码(共移动了几次真实元素?有没有新建或删除?)
🎉 恭喜你已经掌握 Vue2 虚拟 DOM 与 patch 的核心流程啦!VNode 负责描述、patch 负责落地、双端 Diff 负责高效更新,这条链路就是 Vue2 渲染机制的主干。
