虚拟 DOM 与 patch:渲染更新的核心
🎯 引言
前几篇我们讲清楚了「数据变了会触发更新」这条链路:reactive 拦截读写,effect 收集依赖,调度器把更新任务排队后异步执行。但还差一环:更新真正发生时,组件的 vnode 是怎么一步一步变成真实 DOM 的?
这一篇就来补上这一环。学完之后,你能够:
- 说清楚 VNode 是什么,它和真实 DOM 是什么关系。
- 理解
render与patch的主干流程:没有旧 vnode 走挂载,有旧 vnode 走更新。 - 知道 patch 如何按节点类型分流处理,元素节点又如何对比 props 和 children。
- 理解 Vue3 的编译优化思路:用 PatchFlag 标记动态内容,用 Block 收集动态节点,更新时跳过所有静态内容。
本篇源码基于 Vue 3.5.x(GitHub vuejs/core 仓库),主要涉及 packages/runtime-core/src/vnode.ts 和 packages/runtime-core/src/renderer.ts。示例代码可以直接用 Node.js v22 运行。
🧱 VNode 是什么
这一节先解决一个疑问:浏览器只认真实 DOM,Vue 为什么要在中间多搞一层「虚拟 DOM」?
VNode(虚拟节点)就是一个普通的 JavaScript 对象,用来描述界面上一个节点长什么样。「虚拟」的意思是:它只存在于 JS 里,浏览器看不到它。可以把它理解成真实 DOM 的一张「图纸」:上面写清了节点的类型、属性和内容,Vue 照着它创建和更新真实 DOM,所以它是真实 DOM 的轻量替身。
创建 VNode 用的是 h 函数(模板也会被编译成对 h 的调用)。它的核心字段很少,我们从源码里提炼出来看:
// 简化自 packages/runtime-core/src/vnode.ts
function createVNode(type, props = null, children = null) {
const vnode = {
// 节点类型:'div' 表示元素,Text 表示文本,对象表示组件
type,
// 节点属性:id、class、事件监听等
props,
// 子节点:可以是字符串,也可以是 vnode 数组
children,
// 指向真实 DOM 的引用,渲染完成后才有值,初始为 null
el: null,
// 编译器打上的动态标记,本篇后面会细讲
patchFlag: 0,
// Diff 时用来辨认节点身份的 key
key: props && props.key,
};
return vnode;
}
比如 createVNode('div', { id: 'app' }, 'hello'),对应的就是模板里的 <div id="app">hello</div>。记住三点就够了:
type决定节点身份:字符串是原生元素,Symbol(如Text、Fragment)是内置特殊节点,对象则是组件。el让 vnode 能找到真实 DOM:渲染完成后,vnode.el就指向真实 DOM,下次更新时直接拿它做修改。- 创建 vnode 非常便宜:只是
new一个普通对象,比操作真实 DOM 快得多。这就是「轻量」的含义。
🧱 render 与 patch 主干流程
vnode 有了,接下来的问题是:它怎么变成页面上的真实 DOM?这一节我们找到整个渲染流程的总入口。先认识两个本篇反复出现的词:**挂载(mount)**是第一次把 vnode 变成真实 DOM、插进页面,**卸载(unmount)**是把真实 DOM 从页面移除。
总入口是渲染器的 render 函数。「渲染器」就是把 vnode 变成真实 DOM 的那套函数,render 是它们的入口:
// 简化自 packages/runtime-core/src/renderer.ts
function render(vnode, container) {
if (vnode == null) {
// 这次没有新 vnode:说明要卸载整个容器里的内容
if (container._vnode) {
unmount(container._vnode);
}
} else {
// 有新 vnode:拿「上一次的 vnode」和这次的做对比
patch(container._vnode, vnode, container);
}
// 把这次的 vnode 记在容器上,下次更新时它就是「旧 vnode」
container._vnode = vnode;
}
主干逻辑就两种情况:
- 没有旧 vnode(
container._vnode为 null):说明是首次渲染,直接走挂载,从无到有创建真实 DOM。 - 有旧 vnode:说明是更新,新旧两个 vnode 进入
patch做对比更新,只改有变化的地方。
前几篇讲的组件更新走的正是第二条路:渲染 effect 重新执行,产出新的 vnode,然后调用 patch(旧vnode, 新vnode)。所以 patch 是更新时真正干活的地方。
_vnode 是 Vue 挂在 DOM 容器上的一个字段,记录上一次渲染的 vnode。没有它,下次更新就没有旧节点可以对比。🧱 patch 的分支处理
patch 拿到新旧两个 vnode 后先做什么?这一节拆开看:先判断「是不是同一个节点」,再按类型分流处理。
// 简化自 packages/runtime-core/src/renderer.ts
// 判断是不是同一个节点,标准很严格:类型和 key 都相同
function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
如果 <div> 变成了 <p>,或者 key 变了,Vue3 不会费力去对比,直接卸载旧的、挂载新的。这也回答了为什么 v-for 里的 key 很重要,问答环节会展开。通过判断后,patch 按 type 把节点分流到不同的处理函数:
// 简化自 packages/runtime-core/src/renderer.ts
function patch(n1, n2, container) {
// n1 存在但不是同一个节点:卸载旧的,之后按新节点重新挂载
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1);
n1 = null; // 置空后,后面会按「首次挂载」处理
}
const { type } = n2;
if (type === Text) {
// 文本节点
processText(n1, n2, container);
} else if (typeof type === 'string') {
// 原生元素节点,如 'div'、'span'
processElement(n1, n2, container);
} else if (typeof type === 'object') {
// 组件节点
processComponent(n1, n2, container);
}
}
分流之后,我们重点看最常见的元素节点:先决定挂载还是更新,更新时只对比 props 和 children。
// 简化自 packages/runtime-core/src/renderer.ts
function processElement(n1, n2, container) {
if (n1 == null) {
// 没有旧节点:走挂载,创建真实 DOM 并插入
mountElement(n2, container);
} else {
// 有旧节点:走对比更新
patchElement(n1, n2);
}
}
function patchElement(n1, n2) {
// 关键一步:复用旧的真实 DOM,不用重新创建
const el = (n2.el = n1.el);
// 1. 对比 props:class、style、事件等,有变化才改
patchProps(el, n1.props, n2.props);
// 2. 对比 children
patchChildren(n1, n2, el);
}
可以看到 Vue3 更新元素的整体思路:真实 DOM 能复用就复用,只改变化的部分。patchChildren 又会按子节点的形态分流:文本对文本直接替换文本;数组对数组就进入 Diff 流程,也就是「对比两组子节点、找出需要改动的地方」。
🧱 Diff 与编译优化
children 都是数组时,Vue2 会把两组子节点从头到尾做一次完整对比,节点一多成本就不小。这一节回答的疑问是:Vue3 凭什么能做到不全量对比?
答案是:把分析工作提前到编译期。编译期是构建工具把模板翻译成 render 函数的阶段(代码还没在浏览器里跑),浏览器里真正执行的阶段叫运行时。Vue3 在编译期就把「模板里哪些地方会变」分析好、记在 vnode 上,运行时直接读取这些标记来决定怎么更新。这些标记由两部分组成。
优化一:PatchFlag 标记变化类型
编译器分析每个节点:哪些部分永远不变(静态),哪些可能变(动态),动态的是哪一类变化。分析结果以一个数字标记 patchFlag 打在 vnode 上,每个数字对应一种变化类型:
// 简化自 packages/shared/src/patchFlags.ts
const PatchFlags = {
TEXT: 1, // 文本内容是动态的
CLASS: 2, // class 是动态的
STYLE: 4, // style 是动态的
PROPS: 8, // 普通属性是动态的
// ... 其他标记,了解即可
};
举个例子:
<div>
<p>静态内容,永远不变</p>
<p :class="cls">{{ msg }}</p>
</div>
编译后,第二个 <p> 会被打上 TEXT | CLASS 的标记(值为 1 | 2 = 3)。更新时 patch 一看这个标记就知道:这个节点只需要检查文本和 class,其他 props、事件监听统统不用碰。
优化二:Block 收集动态节点
光知道「单个节点哪里会变」还不够。如果动态节点藏在一棵很大的静态树里,更新时还是得一层层找下去。Vue3 的第二个优化是 Block:编译时把一棵子树里所有动态节点收集成一个扁平列表,挂在根节点的 dynamicChildren 上。
// 编译后的效果(概念示意,不是真实可运行代码)
const block = {
type: 'div',
children: [
/* 一百个静态节点... 和一个动态节点 */
],
// 动态节点被单独收集到这里,更新时直接遍历这个列表
dynamicChildren: [动态p节点的vnode],
};
这样更新这棵树时,只遍历 dynamicChildren 里那几个动态节点,静态内容全部跳过。哪怕静态节点有一百个,更新成本也只和动态节点的数量有关。
换句话说,Vue2 更新时必须逐个节点检查,不管它会不会变;Vue3 则在编译期就拿到了动态节点的列表,运行时只检查列表里的这几个节点,所以更新成本只和动态节点的数量有关,和静态节点多少无关。
⚡ 与 Vue2 patch 对比
如果你读过 Vue2 源码课程,可以把两个版本的 patch 放在一起看,差异一目了然:
- Vue2:运行时拿到新旧 vnode 树,走双端 Diff 做全量对比,每个节点都要比一轮,静态内容也逃不过。因为编译期没有留下任何标记,运行时只能全部比一遍。
- Vue3:编译期就分析好动静结构,用 PatchFlag 标记变化类型、用 Block 收集动态节点。运行时依据这些标记做定向更新,静态子树整体跳过。
所以 Vue3 渲染性能的提升,核心思路不是「Diff 算法写得更快」,而是把能做的事提前到编译期,让运行时少做事。
🧾 小节总结
- VNode 是描述界面的普通 JS 对象,核心字段是
type、props、children,它是真实 DOM 的轻量替身,创建成本很低。 render是渲染总入口:没有旧 vnode 走挂载,有旧 vnode 走 patch 对比更新,容器上的_vnode记录上一次的结果。patch先判断是不是同一个节点(类型和 key 都相同),不是就直接卸载重挂;是就按type分流,文本、元素、组件各走各的处理函数。- 元素更新时复用真实 DOM,只对比 props 和 children;children 都是数组时进入 Diff。
- Vue3 的编译优化:PatchFlag 标记动态内容的变化类型,Block 把动态节点收集成列表,更新时跳过所有静态内容,实现定向更新。
❓ 知识问答
Q1:既然浏览器最终还是要操作真实 DOM,中间加一层虚拟 DOM 有什么价值?
A:价值主要有两个。一是描述与渲染解耦:vnode 只是普通对象,同一套描述可以渲染到浏览器、原生端,甚至用来做测试快照,不限于 DOM。二是便于计算差异:在 JS 对象上对比新旧两棵树很便宜,比直接读写真实 DOM 划算得多。配合编译优化,真正昂贵的 DOM 操作被压缩到了必要的范围。
Q2:PatchFlag 是谁生成的?运行时还是编译时?
A:编译时。模板编译器分析模板后,在生成 render 函数的代码里把 PatchFlag 和 Block 结构写好。运行时只是读取这些标记来决定对比策略。这也是为什么手写 render 函数享受不到这份优化,下一篇讲模板编译时会看到它们具体是怎么生成的。
Q3:v-for 里的 key 为什么重要?
A:patch 和 Diff 都靠 key 判断「两个 vnode 是不是同一个节点」。有了稳定唯一的 key,Vue 能准确识别每个节点的新位置,复用对应的真实 DOM 和组件状态。如果 key 用 index,列表顺序变化时 key 会和节点错位,导致复用错误的 DOM,出现输入框内容串位、状态混乱这类问题。
Q4:类型不同的两个 vnode,Vue3 会怎么处理?
A:直接放弃对比。isSameVNodeType 判断类型或 key 不同后,旧节点被卸载,新节点重新挂载。Vue 不做「勉强复用」的尝试,因为跨类型的对比成本往往比直接重建还高。
Q5:Block 优化后,传统的 Diff 算法还用得上吗?
A:用得上。Block 处理的是「模板里动静结构明确」的常见场景;当 children 是完全动态的数组(比如手写 render 动态生成列表),编译器无法预知结构,就需要退回数组 Diff,靠 key 和双端比较来求出更少的移动次数。
🧪 小练习
不查资料,手写一个迷你 h 函数:接收 type、props、children 三个参数,返回一个 vnode 对象(至少包含 type、props、children、key 四个字段),然后用它描述一棵两层的界面结构并打印出来。用 Node.js v22 保存为 .js 文件直接运行验证。
function h(type, props, children) {
// 请在这里编写代码
}
// 用 h 描述:<div id="app"><p class="msg">hello</p></div>
const vnode = h('div', { id: 'app' }, [
// 请在这里编写代码:创建 p 节点的 vnode
]);
console.log(JSON.stringify(vnode, null, 2));
完成后给自己加个挑战:给你的 vnode 加上 patchFlag 字段,如果 children 是字符串就标记为 1(动态文本),否则标记为 0。想一想:这个判断在真实的 Vue 里是在哪一步做的?
🎉 恭喜你已经掌握虚拟 DOM 与 patch 的核心原理啦!这一篇我们反复提到「编译器会打上 PatchFlag、生成 Block」,下一篇我们就走进模板编译,看看模板是如何一步步变成带优化标记的 render 函数的。
