Vue2 源码简介

虚拟 DOM 与 patch:VNode 是如何变成真实 DOM 的

理解 VNode 的结构与 patch 的更新流程,掌握 Vue2 双端 Diff 的核心思路。

🎯 引言

模板编译出的 render 函数会产出一棵 VNode 树,但 VNode 只是一个普通的 JS 对象,浏览器并不认识它。中间还缺一步:把 VNode 变成真实 DOM,以及在数据变化时高效地更新 DOM。这一步就是 patch

学完这篇,你能说清三件事:VNode 长什么样、patch 如何完成首次渲染和更新、双端 Diff 的四次比较顺序。


🧱 前置衔接

Vue2 的虚拟 DOM 实现参考了 Snabbdom,VNode 的结构、patch 的流程、双端 Diff 的思想都和它一脉相承。如果你还没学过,建议先修 Snabbdom 课程,其中的 hpatch、双端指针等概念会直接在本篇的源码里出现。

本篇站在 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.jssrc/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.jsupdateChildren,多了 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)。

四次比较的顺序不需要背,理解"为什么是这个顺序"更重要:Vue 假设列表更新时头尾不变的情况更常见(如追加、删除中间项),所以优先比较头头、尾尾,命中率高、移动少。

🔑 key 的作用回顾

到这里 key 的意义就水落石出了:key 是节点的"身份证",它和 tag 一起决定 sameVnode 的判断结果,也就决定了节点能否被复用。列表渲染时给每项一个稳定且唯一的 key,能让 Diff 一直走"复用 + 移动"的高效路径,而不是按位置就地复用、改写内容。


🧾 小节总结

  • VNode 是用 JS 对象描述 DOM 的结构,核心字段有 tagdatachildrentextelmkey
  • _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 分别为 abc),不写代码,用注释推演双端比较的完整过程:每一轮命中哪次比较、指针怎么移动、做了什么 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 渲染机制的主干。