Skip to content

渲染器的执行 —— 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.key
  • type 决定了节点的本质('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 的逻辑非常复杂,它们需要:

  • 自己控制渲染流程
  • 访问渲染器内部的 patchunmountmove 等方法
  • 在非标准位置插入内容(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,无需比较
}

关键特性oldChildrennewChildren 数组一一对应(索引相同的位置就是同一个节点)。

为什么这样快?

跳过了什么:

  • 静态节点:根本不在 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 可能被多次克隆、修改,同一个"逻辑节点"可能对应多个对象引用。所以必须通过 typekey 来判断身份。

问题 2:为什么 Fragment 要用两个空文本节点而不是一个?

一个锚点无法确定范围边界。两个锚点(start 和 end)才能明确:这段范围内的 DOM 都属于 Fragment,便于移动、卸载等操作。

问题 3:为什么某些场景必须降级到全量 diff?

因为编译器无法保证这些场景的节点对应关系。比如手写的 render 函数、运行时插槽,编译器无法提前分析,所以只能运行时用 Diff 算法。


下一章预告

本章我们理解了渲染器的调度逻辑,也看到了优化模式的威力。但当必须面对全量 diff(patchChildren)且子节点顺序被打乱时,Vue 3 如何以最小代价移动 DOM?下一章,我们将挖掘渲染器最复杂的部分——Diff 算法


扫描关注微信 - 前端小卒,获取更多 Vue 3 源码解析内容