Skip to content

Vue 3 的 Block Tree 动静分离

渲染器是 Vue 3 的核心,但它从不凭空工作。在代码运行之前,编译器已经完成了深度的"转换",将模板转化为携带优化信息的数据结构。

VNode:渲染器的原子单位

VNode(虚拟节点)是渲染器处理的基本单元,本质上就是一个普通的 JavaScript 对象。但在 Vue 2 和 Vue 3 中,它的角色发生了质的变化:

  • Vue 2 的 VNode:一张单纯的"快照",告诉渲染器"我是什么"
  • Vue 3 的 VNode:一份详细的"施工说明书",不仅包含节点信息,还附带了如何更新它的指令

VNode 的内部结构可以划分为三个核心区域:

  • 身份区:节点类型(type)和形状标志(shapeFlag),告诉渲染器这是 div 还是组件
  • 内容区:属性(props)和子节点(children),这是我们最熟悉的部分
  • 优化区patchFlagdynamicChildren,编译器留给渲染器的优化信息,指明哪些地方会变、哪些永远不变

最核心的五个属性

javascript
vnode = {
  type: 'div',              // 节点类型
  props: { class: 'box' },  // 属性对象
  children: [...],          // 子节点
  patchFlag: 9,             // 编译器标记:"只需要检查文本和 props"
  el: null                  // 挂载后指向真实 DOM
}

完整的 VNode 结构(Vue 3.5):

typescript
interface VNode {
  // 身份区
  __v_isVNode: true         // VNode 标识
  type: VNodeTypes          // 节点类型
  key: PropertyKey | null   // 用于 Diff 的唯一标识

  // 内容区
  props: VNodeProps | null  // 属性对象
  children: VNodeNormalizedChildren  // 子节点
  ref: VNodeNormalizedRef | null     // 模板 ref

  // DOM 关联
  el: HostNode | null       // 真实 DOM 引用
  anchor: HostNode | null   // Fragment 的结束锚点
  target: HostElement | null // Teleport 的目标容器

  // 优化区
  shapeFlag: number         // 节点形状标志
  patchFlag: number         // 更新标志
  dynamicProps: string[] | null      // 动态属性列表
  dynamicChildren: VNode[] | null    // 动态子节点列表

  // 组件关联
  component: ComponentInternalInstance | null  // 组件实例
  suspense: SuspenseBoundary | null  // Suspense 边界
  appContext: AppContext | null      // 应用上下文

  // Vue 3.5 新增
  ctx: ComponentInternalInstance | null  // 创建该 VNode 的组件
  memo?: any[]              // v-memo 缓存
  placeholder?: HostNode    // 异步组件占位符
}

VNode 对象本身很简单,真正的智慧在于编译器如何填充这些字段。接下来我们逐一分析这些关键属性的作用。

type 属性:节点类型的路由依据

type 决定了渲染器如何处理这个节点。它的取值可以归纳为四类:

'div' / 'span'        → 普通 HTML 元素
Component 对象        → Vue 组件
Text / Fragment       → 特殊节点类型(文本、多根包装器)
Teleport / Suspense   → 内置组件

渲染器根据 type 进行路由分发:

javascript
function patch(vnode) {
  if (typeof vnode.type === 'string') {
    mountElement(vnode)       // 处理普通元素
  } else if (vnode.type === Fragment) {
    mountChildren(vnode.children)  // 处理多根节点
  } else {
    mountComponent(vnode)     // 处理组件
  }
}

知道"它是什么类型"只是第一步。渲染器还需要知道"它有什么类型的子节点",才能选择最优的处理路径。这就是 ShapeFlags 要解决的问题。

ShapeFlags:位掩码编码的身份信息

渲染器在处理节点时,需要同时判断两件事:节点自身的类型子节点的结构

传统的逻辑判断需要多次检查:

javascript
// 两次类型检查
if (typeof vnode.type === 'object') {
  if (Array.isArray(vnode.children)) {
    // ...
  }
}

Vue 3 使用位掩码技术,将多个特征编码到一个数字中:

javascript
// 一次位运算,同时完成两个判断
if (vnode.shapeFlag & (COMPONENT | ARRAY_CHILDREN)) {
  // ...
}

ShapeFlags 的工作原理

每个 VNode 创建时,会根据自身特征设置标志位:

javascript
vnode.shapeFlag = ELEMENT  // 标记"我是元素"

if (typeof children === 'string') {
  vnode.shapeFlag |= TEXT_CHILDREN   // 追加"我有文本子节点"
} else if (Array.isArray(children)) {
  vnode.shapeFlag |= ARRAY_CHILDREN  // 追加"我有数组子节点"
}

以这个模板为例:

html
<div class="box">{{ msg }}</div>

编译后的 shapeFlag 计算过程:

shapeFlag = ELEMENT | TEXT_CHILDREN
          = 1       | 8
          = 9

检查时:
shapeFlag & ELEMENT?       → 9 & 1 = 1 ✓  是元素
shapeFlag & TEXT_CHILDREN? → 9 & 8 = 8 ✓  有文本子节点

一个数字,两个维度,O(1) 判断。编译器在创建 VNode 时就把这些特征"烧录"进了 shapeFlag,渲染器只需要看一眼这个数字。

知道"它是谁"只是第一步,知道"它哪里会变"才是性能飞跃的关键。这就是 PatchFlags 的作用。

PatchFlags:动态更新的精准导航

从全量 Diff 到靶向更新

Vue 2 中,只要组件状态变化,渲染器就必须对新旧 VNode 进行全量对比。Vue 3 的编译器会分析模板,找出动态的部分并打上标记——这就是 PatchFlag。

以这个模板为例:

html
<div :id="userId" class="box">{{ msg }}</div>

编译器分析后,发现两个动态点:id 属性和文本内容。

编译器的分析过程

生成的 VNode

javascript
vnode = {
  type: 'div',
  props: { id: userId, class: 'box' },
  children: msg,
  patchFlag: 9,           // TEXT(1) | PROPS(8) = 9
  dynamicProps: ['id'],   // 只有 id 是动态的
}

有了这份标记,渲染器的更新逻辑变得精准:

javascript
if (patchFlag & TEXT) {
  updateText(vnode)
}
if (patchFlag & PROPS) {
  // 只遍历 dynamicProps,跳过 class
  for (const key of vnode.dynamicProps) {
    updateProp(key)
  }
}

class 虽然在 props 中,但因为不在 dynamicProps 里,完全跳过了检查。

PatchFlags 常用标记

typescript
// packages/shared/src/patchFlags.ts
export enum PatchFlags {
  // 正数标记(可组合)
  TEXT = 1,              // 动态文本  {{ msg }}
  CLASS = 1 << 1,        // 动态 class  :class="xxx"
  STYLE = 1 << 2,        // 动态 style  :style="xxx"
  PROPS = 1 << 3,        // 动态属性  :id="xxx"
  FULL_PROPS = 1 << 4,   // 动态 key,需要全量 diff
  NEED_HYDRATION = 1 << 5,  // 需要 hydration(事件监听器)
  STABLE_FRAGMENT = 1 << 6, // 稳定的 Fragment
  KEYED_FRAGMENT = 1 << 7,  // 有 key 的 v-for
  UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 v-for
  NEED_PATCH = 1 << 9,   // 需要 patch(ref、指令)
  DYNAMIC_SLOTS = 1 << 10, // 动态插槽

  // 特殊标记(负数,不参与位运算)
  CACHED = -1,           // 静态提升,永不更新
  BAIL = -2,             // 退出优化模式
}

这些标记可以通过位运算组合,比如 TEXT | PROPS = 9 表示"既有动态文本,又有动态属性"。

| 版本 | PatchFlags 变化 | ||-| | Vue 3.0 | 基础标记系统 | | Vue 3.2 | 新增 NEED_HYDRATION(SSR 优化) | | Vue 3.3 | 新增 DEV_ROOT_FRAGMENT(开发模式) |

PatchFlags 解决了单个节点的更新问题,但如果节点嵌套很深怎么办?逐层遍历依然很慢。这就是 Block Tree 要解决的问题。

Block Tree:将树拍平为列表

传统虚拟 DOM 的痛点

传统虚拟 DOM 的问题在于:为了找到深层的一个动态节点,必须遍历整个树结构。即便中间隔着 10 层静态的 div,渲染器也得一层层爬下去。

Block Tree 的核心思想是动静分离:编译器将模板切割成一个个 Block,在每个 Block 内部,所有动态节点(无论藏得多深)都被提取到一个扁平数组 dynamicChildren 中。

以这个模板为例:

html
<div>
  <p>Hello</p>           <!-- 静态 -->
  <p>{{ msg }}</p>       <!-- 动态 -->
  <span>World</span>     <!-- 静态 -->
  <span>{{ tip }}</span> <!-- 动态 -->
</div>

如果每次更新都遍历全部 4 个子节点,2 个静态节点会被白白检查。

传统树 vs Block 列表

输入模板

html
<div>
  <section>
    <p>静态文本</p>
    <span>{{ count }}</span>
  </section>
</div>

输出 Block 结构

编译器忽略中间的 sectionp,直接生成:

javascript
const blockVNode = {
  type: 'div',
  dynamicChildren: [
    { type: 'span', patchFlag: TEXT },  // 无论藏多深,直接提取
  ],
}

Block 的收集规则

javascript
// 编译器生成的代码
openBlock()  // 开始收集动态节点

createVNode('p', null, 'Hello')        // 静态,不收集
createVNode('p', null, msg, TEXT)      // 有 patchFlag,收集 ✓
createVNode('span', null, 'World')     // 静态,不收集
createVNode('span', null, tip, TEXT)   // 有 patchFlag,收集 ✓

setupBlock(vnode)  // 收集结束,附加到 vnode.dynamicChildren

运行时更新逻辑:

javascript
newNode.dynamicChildren.forEach((child, index) => {
  patch(oldNode.dynamicChildren[index], child)
})
// 静态节点完全不在循环中

核心规则:只有 patchFlag > 0 的节点才会被收集。

Block 的边界:v-if 和 v-for

v-if 和 v-for 会创建新的 Block 边界,因为它们会改变 DOM 结构的稳定性:

html
<div>
  <p v-if="ok">{{ msg }}</p>           <!-- v-if 分支,独立 Block -->
  <li v-for="item in items" :key="item.id">  <!-- v-for 列表,独立 Block -->
    {{ item.name }}
  </li>
</div>

原因:

  • v-if:分支切换导致节点增删,结构不稳定
  • v-for:列表长度变化导致节点数量变化,结构不稳定

Block 就像把模板"分区",每个稳定的区域是一个独立 Block。当某个 Block 内部变化时,只更新这个 Block 的动态节点,其他区域完全跳过。

四个真实场景

场景一:纯静态模板

输入

html
<div class="box"><h1>Hello</h1></div>

编译器分析:没有动态绑定,内容完全固定 → patchFlag = -1 (CACHED),提升到模块顶层。

输出 VNode

javascript
{
  type: 'div',
  props: { class: 'box' },
  children: [{ type: 'h1', children: 'Hello' }],
  patchFlag: -1  // 永不更新
}

运行时行为:每次都复用同一个 VNode,0 次 Diff。

场景二:动态文本

输入

html
<p>{{ message }}</p>

编译器分析message 是插值表达式 → patchFlag = 1 (TEXT)

输出 VNode

javascript
{
  type: 'p',
  children: message,
  patchFlag: 1
}

运行时行为

javascript
if (patchFlag & TEXT) {
  if (newChildren !== oldChildren) {
    setText(el, newChildren)
  }
}

只检查文本,跳过 props 检查。

场景三:v-for 列表

输入

html
<ul>
  <li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>

编译器分析:v-for 导致子节点数量不固定 → 用 Fragment 包装,patchFlag = 128 (KEYED_FRAGMENT)

输出 VNode

javascript
{
  type: Fragment,
  patchFlag: 128,
  dynamicChildren: [
    { type: 'li', key: 1, patchFlag: 1 },
    { type: 'li', key: 2, patchFlag: 1 },
  ]
}

运行时行为

javascript
if (patchFlag & KEYED_FRAGMENT) {
  patchKeyedChildren(oldChildren, newChildren)  // 带 key 的 Diff 算法
}

场景四:v-once 缓存

输入

html
<p v-once>{{ expensiveValue }}</p>

编译器分析:v-once 表示永不更新 → 首次渲染后缓存,禁用 Block 追踪。

输出代码

javascript
if (!cache[0]) {
  cache[0] = createVNode('p', null, expensiveValue)
}
return cache[0]  // 后续直接返回缓存

运行时行为:首次渲染后永久缓存,0 次后续处理。

编译器与渲染器的契约

回顾 Vue 3 的渲染机制,我们看到的是一条精心设计的流水线:

  1. Compiler 分析模板,提取特征
  2. VNode 携带 shapeFlag(我是谁)和 patchFlag(怎么变)
  3. Block Tree 收集动态节点,忽略静态噪音
  4. Renderer 拿到这份优化信息,以最快速度完成更新
编译器的承诺:
- patchFlag 告诉你哪些需要更新
- dynamicProps 告诉你哪些属性是动态的
- dynamicChildren 告诉你哪些子节点是动态的
- shapeFlag 编码了节点的类型信息

渲染器的职责:
- 信任这些标记
- 跳过静态内容
- 精准更新标记的动态部分

版本演进

| 版本 | 变化 | 说明 | |||| | Vue 2.x | 传统 VNode | 无编译时优化,全量 Diff | | Vue 3.0 | ShapeFlags + PatchFlags | 位掩码标记,靶向更新 | | Vue 3.0 | Block Tree | 动态节点扁平化收集 | | Vue 3.2 | 静态提升增强 | 更激进的静态节点提升 | | Vue 3.2 | v-memo | 手动控制子树缓存 | | Vue 3.3 | defineSlots | 插槽类型推导优化 | | Vue 3.5 | ctx 属性 | VNode 关联创建它的组件实例 | | Vue 3.5 | placeholder | 异步组件占位符支持 |

从 Vue 2 到 Vue 3 的质变

Vue 2 VNode:
  - 纯粹的虚拟 DOM 描述
  - 运行时全量比较
  - 性能与模板大小成正比

Vue 3 VNode:
  - 携带编译时优化信息
  - ShapeFlags 快速判断节点类型
  - PatchFlags 精确标记动态内容
  - Block Tree 跳过静态节点
  - 性能与动态内容数量成正比

随堂测试

  1. Block Tree 通过 dynamicChildren 将动态节点扁平化收集,但 v-if 和 v-for 会打断这个收集。如果一个模板中有大量嵌套的 v-if,会对性能产生什么影响?有什么优化策略?

  2. patchFlag = -1 (CACHED) 表示静态节点永不更新。但如果静态节点内部包含 ref,Vue 是如何处理的?静态提升会影响 ref 的绑定吗?

  3. ShapeFlags 使用位掩码编码节点类型和子节点结构。如果 Vue 未来需要支持更多的节点类型,这种设计会遇到什么限制?JavaScript 的位运算有什么边界?

  4. 编译器生成的 dynamicProps 数组只包含动态属性名。如果一个组件有 100 个 props,其中只有 1 个是动态的,这种设计相比全量 props diff 能节省多少开销?

下一章预告:我们已经理解了编译器准备的"数据契约"。下一章,我们将进入渲染器内部,看它如何将 VNode 转化为真实的 DOM,以及如何利用这些优化标记实现高效的更新。


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