Appearance
渲染器的执行 —— Patch 路由与优化模式
在上一章中,我们了解了 VNode 的数据结构和编译器如何生成带有优化标记的虚拟节点。本章我们将深入渲染器的核心——patch 函数,解析它是如何消费编译器产出的 VNode,并将其转化为真实 DOM 的。
Patch 的核心
patch 函数是 Vue 3 渲染器的"分发器"。所有的挂载、更新、卸载操作都从这里开始分发。
两层路由分发机制
Patch 函数面临一个关键问题:给定一个 VNode,我应该怎么处理它?
答案是:根据 VNode 的"type"分类处理。
输入:VNode { type: ?, shapeFlag: ?, key: ? }
↓
路由 1:Type 匹配(Symbol 类型)
├─ Text → processText()
├─ Comment → processComment()
├─ Fragment → processFragment()
└─ Static → processStatic()
路由 2:ShapeFlag 匹配(位掩码)
├─ ShapeFlags.ELEMENT → processElement()
├─ ShapeFlags.COMPONENT → processComponent()
├─ ShapeFlags.TELEPORT → TeleportImpl.process()
└─ ShapeFlags.SUSPENSE → SuspenseImpl.process()
↓
输出:DOM 已挂载或已更新为什么要分成两层?
- 第一层(Type 匹配):Text、Comment、Fragment、Static 是特殊的符号类型,Vue 内部定义的常量,用
===直接比较即可 - 第二层(ShapeFlag 匹配):Element 和 Component 的
type可能是任意字符串(如'div')或组件对象,无法穷举,因此用编译期计算好的位掩码来快速判断
位运算的威力:shapeFlag & ShapeFlags.ELEMENT 只需一次按位与操作,时间复杂度 O(1)。
"同一个节点"的判断
Patch 还要做一件重要的事:判断新旧两个节点是否可以复用。
ts
// 伪代码
if (n1 === n2) {
return // 同一个对象引用,什么都不用做
}
if (n1 && !isSameVNodeType(n1, n2)) {
// type 或 key 不同 → 两个完全不同的节点
unmount(n1) // 卸载旧节点
n1 = null // 接下来当作挂载处理
}
// 如果走到这里,n1 和 n2 是同一个"身份"的节点,可以复用判断标准很简单:只需比较两个属性
ts
isSameVNodeType(n1, n2):
return n1.type === n2.type && n1.key === n2.keytype决定了节点的本质('div'vs'span'?不同组件?)key决定了节点的身份(列表中的同一项?)
BAIL 机制:优化模式的"应急闸"
某些异常情况下,优化模式需要被强制关闭:
ts
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}什么情况下会触发 BAIL?
- 手动
cloneVNode:克隆后的节点与原节点对应关系被破坏 - 非编译插槽:运行时生成的插槽内容结构不稳定
- 动态组件
<component :is="vnode">:传入现有 VNode
Teleport 和 Suspense 的"越权"设计
这两个组件没有走 shapeFlag 分支,而是直接调用自己的 process() 方法:
ts
// 伪代码
type.process(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
internals // 传入渲染器的 patch、unmount 等内部方法
)为什么它们需要"越权"?
因为 Teleport 和 Suspense 的逻辑非常复杂,它们需要:
- 自己控制渲染流程
- 访问渲染器内部的
patch、unmount、move等方法 - 在非标准位置插入内容(Teleport 的 target)或异步处理(Suspense 的 fallback)
所以 Vue 给了它们"控制反转"的权限,让它们自己负责整个 patch 流程。
基础节点处理 —— 原子操作
processText:最简单的更新
文本节点没有属性、没有子节点,它的全部内容就是一个字符串:
ts
// 伪代码
if (n1 == null) {
// 挂载:创建文本节点并插入
el = createTextNode(n2.children)
insert(el, container)
} else {
// 更新:只需比较字符串
if (n2.children !== n1.children) {
setText(el, n2.children) // 改变文本内容
}
}没有 Diff?
对,没有。因为文本没有结构,比较就只需一行代码:字符串相等?如果不相等就替换,完成。
processComment:占位符
注释节点也很特殊:
ts
// 伪代码
if (n1 == null) {
el = createComment(n2.children || '')
insert(el, container)
} else {
// 更新时直接复用旧的 el,不改内容
el = n1.el
}为什么不更新内容?
因为注释节点在页面上不可见,用户看不到内容变化。注释的真实用途是:
- 作为
v-if的 else 分支占位符 - 作为 Fragment 的边界标记(下文会讲)
Static 节点:编译期的胜利
静态节点是编译器识别出的完全不会变化的 DOM 结构。这是编译器和渲染器的合作成果:
输入:Template
<div class="static">不会变</div>
↓
编译器识别:这是 static node
↓
Codegen 生成:HTML 字符串
↓
输出:{ type: Static, children: '<div class="static">不会变</div>' }运行时处理就很简单了:
ts
// 伪代码
// 挂载时
el = hostInsertStaticContent(n2.children) // 用 innerHTML 一次性插入
// 更新时(仅开发环境 HMR 场景)
if (n2.children !== n1.children) {
removeStaticNode(n1)
el = hostInsertStaticContent(n2.children)
} else {
el = n1.el // 复用,什么都不做
}为什么静态节点这么快?
因为它跳过了"创建 DOM 节点 → 设置属性 → 插入子节点"的每一步,而是直接用 innerHTML 一次性插入完整的 HTML 字符串。这是最高效的插入方式。
普通元素处理 —— mountElement vs patchElement
这是最常见的情况:HTML 标签如 <div class="app">。
mountElement:元素的诞生
挂载一个元素需要按顺序完成几个步骤:
步骤 1:创建 DOM 元素
hostCreateElement('div')
↓ el = <div></div>
步骤 2:设置内容(文本或子节点)
if (TEXT_CHILDREN) setText(el, "content")
if (ARRAY_CHILDREN) mountChildren(...)
↓ el = <div>content</div>
步骤 3:应用指令 hook (created)
invokeDirectiveHook(vnode, 'created')
↓
步骤 4:应用 Props(class/style/事件监听等)
for (key in props) {
patchProp(el, key, null, props[key])
}
↓ el = <div class="app">content</div>
步骤 5:插入 DOM 树
insert(el, container)
↓ 已挂载到页面
步骤 6:异步调用 mounted hook
queuePostRenderEffect(() => {
invokeVNodeHook(onVnodeMounted, vnode)
})为什么先设置内容后设置 props?
这看起来反直觉,但很关键。某些 HTML 属性(如 <select> 的 value)依赖子节点的存在,所以必须先挂载子节点。
patchElement:元素的进化
更新一个元素涉及两个关键决策:
输入:n1(旧 VNode)、n2(新 VNode)
↓
决策 1:子节点如何更新?
┌─ n2.dynamicChildren 存在?
│ ├─ YES → 优化模式:patchBlockChildren(只更新动态节点)
│ └─ NO → 全量模式:patchChildren(9 种情况的完整 diff)
│
决策 2:Props 如何更新?
┌─ n2.patchFlag > 0?
│ ├─ YES → 靶向更新:只改变标记为动态的属性
│ │ ├─ patchFlag & CLASS? → 只更新 class
│ │ ├─ patchFlag & STYLE? → 只更新 style
│ │ ├─ patchFlag & PROPS? → 只更新 dynamicProps 数组中的属性
│ │ └─ patchFlag & TEXT? → 只更新文本内容
│ └─ NO → 全量 Diff:比较所有 props
│
输出:n1.el === n2.el(同一个 DOM 元素,内容已更新)靶向更新的威力
想象有一个元素:
html
<div class="app" :id="userId" style="color: red">{{ msg }}</div>编译器会生成:
ts
patchFlag: CLASS | PROPS | TEXT
dynamicProps: ['id'] // 只有 id 是动态的运行时更新时:
ts
if (patchFlag & CLASS) {
// ✓ 更新 class
}
if (patchFlag & PROPS) {
// ✓ 更新 id(dynamicProps[0])
// ✗ 跳过 style(不在 dynamicProps 中)
}
if (patchFlag & TEXT) {
// ✓ 更新文本内容
}通过位运算和预计算的 dynamicProps 数组,编译器告诉渲染器"我已经帮你分析好了,只需要改这些"。
Fragment 的锚点机制 —— 无形容器
Vue 3 支持多根组件:
html
<template>
<div>A</div>
<div>B</div>
</template>但 Fragment 本身不会产生真实 DOM,那它的子节点怎么定位呢?
两个空文本节点的妙用
ts
// 伪代码
const startAnchor = createText('') // 开始锚点
const endAnchor = createText('') // 结束锚点
insert(startAnchor, container) // <empty-text>
mountChildren(children, container, endAnchor) // 在两者之间插入子节点
insert(endAnchor, container) // <empty-text>可视化:
页面结构:
┌─────────────────────────────────┐
│ [StartAnchor] 🔵 │
│ <div>A</div> 🔵 │
│ <div>B</div> 🔵 │
│ [EndAnchor] 🔵 │
└─────────────────────────────────┘
↑ Fragment 的虚拟边界这样做的好处:
- 定位范围:Fragment 移动时,只需遍历 startAnchor 到 endAnchor 之间的所有节点
- 插入参照:新子节点以 endAnchor 为锚点,确保插入顺序正确
- 卸载边界:卸载时,删除两个锚点之间的所有节点
通俗比喻:就像在书上夹两个书签,标记出一个段落的开头和结尾,中间夹着的就是 Fragment 的内容。
稳定 Fragment vs 普通 Fragment
ts
// 伪代码
if (patchFlag & STABLE_FRAGMENT && dynamicChildren) {
// 稳定 Fragment:编译期确定的多根节点
// 如:<template> 的模板根、v-for 根
patchBlockChildren(...) // 快速路径
} else {
// 普通 Fragment:手写的多根或动态生成
patchChildren(...) // 完整 diff
}优化模式 vs 全量模式 —— 走捷径与完整遍历
这是 Vue 3 运行时性能的精髓。
patchBlockChildren:优化模式的快速更新
当编译器标记了 Block Tree,运行时可以走快速路径:
ts
// 伪代码
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
patch(oldVNode, newVNode, ...) // 直接 patch,无需比较
}关键特性:oldChildren 和 newChildren 数组一一对应(索引相同的位置就是同一个节点)。
为什么这样快?
跳过了什么:
- 静态节点:根本不在 dynamicChildren 中
- 层级遍历:dynamicChildren 是扁平化的,跳过了嵌套
- 节点对比:不需要 isSameVNodeType 判断
只做了什么:
- 逐个 patch 动态节点
数据演变示例:
模板:<div><p>static</p>{{ msg }}<span v-if="ok">{{ tip }}</span></div>
编译器识别动态节点:
oldChildren: [{{ msg }}, <span>{{ tip }}</span>]
newChildren: [{{ msg }}, <span>{{ tip }}</span>]
↑ 位置 0 ↑ 位置 1
更新时:
patch(oldChildren[0], newChildren[0], ...) // 更新插值
patch(oldChildren[1], newChildren[1], ...) // 更新 span 及其内容
静态的 <p> 节点?完全跳过,不处理优化模式是理想情况。但当无法使用优化模式时,就必须走全量模式。
patchChildren:全量模式的 9 种情况
当无法使用优化模式时(比如手写的 render 函数),必须处理子节点的所有组合:
新子节点 \ 旧子节点 文本 数组 空
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
文本 比较 卸载数组 设置文本
字符串 设置文本
数组 卸载文本 完整diff 挂载数组
设置数组 (patchKeyedChildren)
空 卸载文本 卸载数组 什么都不做最复杂的情况是“数组 → 数组”,需要 Diff 算法来计算最小移动方案。这就是下一章的主题。
了解了 patch 的核心逻辑后,让我们看一个渲染器的辅助机制。
normalizeVNode:输入的统一化处理
用户可以在 render 函数中返回各种类型的值,渲染器需要统一转换为 VNode:
ts
// 伪代码
function normalizeVNode(child) {
if (child == null || typeof child === 'boolean') {
return createVNode(Comment) // null/undefined/false → 注释占位符
} else if (isArray(child)) {
return createVNode(Fragment, null, child) // 数组 → Fragment
} else if (isVNode(child)) {
return cloneIfMounted(child) // 已挂载的 VNode → 克隆
} else {
return createVNode(Text, null, String(child)) // 字符串/数字 → Text
}
}为什么已挂载的 VNode 需要克隆?
一个 VNode 如果已经挂载(child.el !== null),意味着它的 el 指向某个真实 DOM 节点。如果在多个位置复用这个 VNode:
ts
const sharedVNode = h('div')
return [sharedVNode, sharedVNode] // 错误!两个位置会引用同一个 DOM 节点,造成冲突。所以必须克隆:
ts
return [cloneVNode(sharedVNode), cloneVNode(sharedVNode)] // 正确现在,让我们用一个完整的流程图来总结 Patch 的执行过程。
Patch 流程总览
贯穿案例的四阶段旅程
让我们用 <div class="app"></div> 追踪它从 Codegen 到 Patch 的完整过程:
随堂测试
问题 1:为什么不能直接比较对象引用判断节点变化?
因为 VNode 可能被多次克隆、修改,同一个"逻辑节点"可能对应多个对象引用。所以必须通过 type 和 key 来判断身份。
问题 2:为什么 Fragment 要用两个空文本节点而不是一个?
一个锚点无法确定范围边界。两个锚点(start 和 end)才能明确:这段范围内的 DOM 都属于 Fragment,便于移动、卸载等操作。
问题 3:为什么某些场景必须降级到全量 diff?
因为编译器无法保证这些场景的节点对应关系。比如手写的 render 函数、运行时插槽,编译器无法提前分析,所以只能运行时用 Diff 算法。
下一章预告:
本章我们理解了渲染器的调度逻辑,也看到了优化模式的威力。但当必须面对全量 diff(patchChildren)且子节点顺序被打乱时,Vue 3 如何以最小代价移动 DOM?下一章,我们将挖掘渲染器最复杂的部分——Diff 算法。
