第 12 章

Block Tree:动态节点追踪与靶向 diff 的设计

第12章:Block Tree——动态节点追踪与靶向 diff 的设计

即使有了 PatchFlag,Vue 3 仍然面临一个问题:怎么快速找到树中有 PatchFlag 的节点?Block Tree 的答案是:不要遍历,让动态节点自己"报到"。

本章核心问题:PatchFlag 告诉运行时"如何更新",Block Tree 告诉运行时"去哪找需要更新的节点"。这两者如何协作,让 diff 从 O(树节点数) 降到 O(动态节点数)?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

传统 diff 的问题

即使有了 PatchFlag 标注每个节点"哪部分是动态的",运行时仍然需要遍历整棵 VNode 树才能找到这些需要更新的节点:

组件 VNode 树:
div                    ← 静态
  h1 "标题"            ← 静态
  ul                   ← 静态
    li "项目1"         ← 静态
    li "项目2"         ← 静态
    li "项目3"         ← 静态
    li {{ count }}     ← 动态(PatchFlag: TEXT)
    li "项目5"         ← 静态
  footer "版权"        ← 静态

在这棵树中,只有 1 个节点需要更新,但传统方式需要遍历全部 9 个节点才能找到它。在有 1000 个节点、只有 10 个动态节点的情况下,99% 的遍历是浪费。

Block 的核心思想

Block 是一种特殊的 VNode,它维护一个 dynamicChildren 数组,直接记录了自身子树中所有动态节点(带 PatchFlag 的节点)的扁平列表。

Block VNode(div)
├── children: [h1, ul, footer]    // 普通 children(完整树结构)
└── dynamicChildren: [li#4]      // 动态子节点的扁平列表

更新时,只需遍历 dynamicChildren,直接找到需要更新的节点,不需要遍历完整树:

传统 diff:遍历所有 9 个节点 → 找到 1 个需要更新
Block diff:直接访问 dynamicChildren → 找到 1 个 → 更新

哪些节点是 Block?

在 Vue 3 中,以下类型的节点会成为 Block(创建 dynamicChildren 数组):

  1. 组件根节点:每个组件的根元素都是一个 Block
  2. v-if / v-else-if / v-else 块:每个条件分支是一个 Block
  3. v-for 列表:整个 v-for 是一个 Fragment Block
  4. <template v-if><template v-for>:template 本身是 Block
<!-- 整个组件的模板 -->
<template>
  <div>                    <!-- Block 1:组件根节点 -->
    <p v-if="show">        <!-- Block 2:v-if 分支 -->
      {{ text }}           <!-- 动态节点,加入 Block 2 的 dynamicChildren -->
    </p>
    <ul>
      <li v-for="item in list" :key="item.id">  <!-- Block 3:v-for -->
        {{ item.name }}    <!-- 动态节点,加入 Block 3 的 dynamicChildren -->
      </li>
    </ul>
  </div>
</template>

编译输出中的 Block 标记

<div>
  <h1>静态</h1>
  <p>{{ dynamic }}</p>
</div>

编译结果:

import { openBlock, createElementBlock, createElementVNode, toDisplayString } from "vue"

const _hoisted_1 = _createElementVNode("h1", null, "静态", -1)

// openBlock() 初始化 dynamicChildren 收集器
// createElementBlock() 创建 Block VNode,关闭收集器
function render(_ctx) {
  return (openBlock(), createElementBlock("div", null, [
    _hoisted_1,
    createElementVNode("p", null, toDisplayString(_ctx.dynamic), 1 /* TEXT */)
    //  ↑ 这个节点有 PatchFlag(1),会被自动收集到父 Block 的 dynamicChildren 中
  ]))
}

Level 2 · 它是怎么运行的(3-5年经验)

openBlock 与 createBlock 的工作机制

Block 的收集机制基于两个全局变量协作:

// packages/runtime-core/src/vnode.ts

// 当前正在收集动态节点的 Block 的 dynamicChildren 数组
export let currentBlock: VNode[] | null = null
// Block 栈(用于嵌套 Block)
export const blockStack: (VNode[] | null)[] = []

openBlock():开始一个新的动态节点收集会话

export function openBlock(disableTracking = false) {
  blockStack.push((currentBlock = disableTracking ? null : []))
}

closeBlock():结束收集会话,恢复上一个 Block 的收集状态

export function closeBlock() {
  blockStack.pop()
  currentBlock = blockStack[blockStack.length - 1] || null
}

createElementVNode(普通 VNode 创建):如果有 PatchFlag,自动推入 currentBlock

export function createVNode(
  type: VNodeTypes,
  props?: (Data & VNodeProps) | null,
  children?: unknown,
  patchFlag: number = 0,
  dynamicProps?: string[] | null,
  isBlockNode: boolean = false,  // 是否是 Block 节点本身
): VNode {
  const vnode = { /* ... */ patchFlag, dynamicProps }
  
  // 如果有 patchFlag 且不是 Block 节点本身,推入 currentBlock
  if (
    isBlockTreeEnabled > 0 &&
    !isBlockNode &&
    currentBlock &&
    (vnode.patchFlag > 0 ||
      shapeFlag & ShapeFlags.COMPONENT) &&
    vnode.patchFlag !== PatchFlags.NEED_HYDRATION
  ) {
    currentBlock.push(vnode)
  }
  
  return vnode
}

createElementBlock(Block VNode 创建)

export function createElementBlock(
  type: string | typeof Fragment,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[],
  shapeFlag?: number,
) {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true,  // isBlock = true
    ),
  )
}

function setupBlock(vnode: VNode) {
  // 将当前 currentBlock 赋给 vnode.dynamicChildren
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
  
  // 关闭这个 Block 的收集
  closeBlock()
  
  // 将这个 Block VNode 本身推入父 Block 的 currentBlock(如果有的话)
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  
  return vnode
}

完整的 Block 创建与收集流程

以下面的模板为例:

<div>
  <p>静态</p>
  <span>{{ msg }}</span>
  <em>{{ count }}</em>
</div>

执行时序:

① render() 开始执行
   │
② openBlock() 被调用
   currentBlock = []  ← 新建数组,准备收集
   blockStack = [[]]
   │
③ 创建 <p> VNode(无 PatchFlag)
   patchFlag = 0,不推入 currentBlock
   │
④ 创建 <span> VNode(PatchFlag = 1,TEXT)
   patchFlag = 1 > 0,推入 currentBlock
   currentBlock = [spanVNode]
   │
⑤ 创建 <em> VNode(PatchFlag = 1,TEXT)
   patchFlag = 1 > 0,推入 currentBlock
   currentBlock = [spanVNode, emVNode]
   │
⑥ createElementBlock("div", ...) 被调用
   vnode.dynamicChildren = currentBlock = [spanVNode, emVNode]
   closeBlock() → blockStack.pop() → currentBlock = null
   │
⑦ render() 返回 divVNode(Block)
   divVNode.dynamicChildren = [spanVNode, emVNode]

更新时的靶向 diff

数据变化 → 组件重新渲染 → 新旧 Block VNode diff
          │
          ▼
检查 newVNode.dynamicChildren 是否存在
          │
          ├─ YES → patchBlockChildren(oldDynamicChildren, newDynamicChildren)
          │         只 diff 动态节点列表,不遍历整棵树
          │         时间复杂度:O(动态节点数)
          │
          └─ NO → patchChildren(普通 diff)

嵌套 Block 的处理:Block 栈

Block 可以嵌套,通过 blockStack 管理:

<div>            <!-- Block A(组件根)-->
  <p v-if="x">  <!-- Block B(v-if 分支)-->
    {{ text }}   <!-- 动态节点:加入 Block B 的 dynamicChildren -->
  </p>
</div>

执行时序:

① openBlock() → currentBlock = [] (Block A)
   blockStack = [[]]  ← Stack: [A]
   │
② 处理 v-if:openBlock() → currentBlock = [] (Block B)
   blockStack = [[], []]  ← Stack: [A, B]
   │
③ {{ text }} 创建 → 推入 currentBlock (Block B)
   Block B's dynamicChildren = [textVNode]
   │
④ createBlock("p", ...) → Block B 完成
   pVNode.dynamicChildren = [textVNode]
   closeBlock() → currentBlock = [] (恢复 Block A)
   blockStack = [[]]  ← Stack: [A]
   pVNode(Block B)推入 Block A 的 currentBlock
   Block A's dynamicChildren = [pVNode]
   │
⑤ createElementBlock("div", ...) → Block A 完成
   divVNode.dynamicChildren = [pVNode]
   closeBlock() → currentBlock = null

最终的 Block Tree 结构

div Block
  └── dynamicChildren: [pVNode]
        └── pVNode Block
              └── dynamicChildren: [textVNode]

diff 时,只需沿着 dynamicChildren 链追踪,而不是遍历完整的 VNode 树。

v-if 的 Block 处理:整块替换

v-if 的每个分支(if/else-if/else)都是一个独立的 Block。当条件改变时,不做跨 Block 的精细 diff,而是整块替换

<template>
  <div v-if="show" key="a">
    <span>{{ text1 }}</span>
    <em>{{ text2 }}</em>
  </div>
  <div v-else key="b">
    <p>{{ text3 }}</p>
  </div>
</template>

编译结果:

show.value
  ? (openBlock(), createBlock("div", { key: "a" }, [
      createVNode("span", null, text1.value, 1),
      createVNode("em", null, text2.value, 1),
    ]))
  : (openBlock(), createBlock("div", { key: "b" }, [
      createVNode("p", null, text3.value, 1),
    ]))

showtrue 变为 false

这个设计的好处:结构不同的分支(如 v-if 显示表单,v-else 显示预览)不会做无意义的 diff,直接重建。

v-for 的 Fragment Block

v-for 整体是一个 Fragment Block:

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

编译结果:

createElementVNode("ul", null, [
  (openBlock(true), createBlock(Fragment, null,
    renderList(list.value, (item) => {
      return (openBlock(), createBlock("li", { key: item.id },
        toDisplayString(item.name),
        1 /* TEXT */
      ))
    }),
    128 /* KEYED_FRAGMENT */  ← PatchFlag 表明这是带 key 的 Fragment
  ))
])

稳定 Fragment(KEYED_FRAGMENT, 128)

不稳定 Fragment(UNKEYED_FRAGMENT, 256)


Level 3 · 设计文档与源码(资深开发者)

Block Tree 的激活控制

文件路径packages/runtime-core/src/vnode.ts

Block Tree 优化有一个开关:isBlockTreeEnabled。这个开关在以下场景下被禁用:

export let isBlockTreeEnabled = 1

// 禁用 Block Tree(临时)
export function setBlockTracking(value: number) {
  isBlockTreeEnabled += value
}

在 v-once 处理中禁用

// packages/runtime-core/src/helpers/renderHelpers.ts
export function withMemo(
  memo: any[],
  render: () => VNode<any, any>,
  cache: any[],
  index: number,
) {
  const cached = cache[index] as VNode | undefined
  if (cached && isMemoSame(cached, memo)) {
    return cached
  }
  const ret = render()
  // ...
  return (cache[index] = ret)
}

v-once 的实现中(renderHelpers.ts),创建缓存的 VNode 时会临时关闭 Block tracking,避免已缓存的节点被错误地添加到父 Block 的 dynamicChildren。

patchBlockChildren:靶向 diff 的核心

文件路径packages/runtime-core/src/renderer.ts

const patchBlockChildren: PatchBlockChildrenFn = (
  oldChildren: VNode[],
  newChildren: VNode[],
  fallbackContainer: RendererElement,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
) => {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const newVNode = newChildren[i]
    // 确定容器(el 的父节点)
    const container =
      // v-html 的场景需要特殊处理
      oldVNode.el &&
      (oldVNode.type === Fragment ||
        !isSameVNodeType(oldVNode, newVNode) ||
        oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
        ? hostParentNode(oldVNode.el!)!
        : fallbackContainer
    patch(
      oldVNode,
      newVNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      true,  // optimized = true:使用 PatchFlag 路径
    )
  }
}

关键点patchBlockChildren 直接遍历 dynamicChildren 数组,对每个动态节点调用 patch(),并传入 optimized = true 参数,让 patch 使用 PatchFlag 做精确更新。

这与普通的 patchChildren(遍历所有 children 做完整 diff)形成对比:

patchBlockChildren(Block 优化路径):
  for each (old, new) in zip(oldDynamicChildren, newDynamicChildren)
    patch(old, new, ..., optimized=true)
  → 只处理动态节点

patchChildren(普通路径):
  if keyed → patchKeyedChildren(LIS diff)
  else → patchUnkeyedChildren(顺序 diff)
  → 处理所有 children

v-for 的 Fragment Block 详解

当 v-for 的 key 是稳定的(非 index),编译器会设置 PatchFlags.KEYED_FRAGMENT,运行时使用高效的 keyed diff:

文件路径packages/runtime-core/src/renderer.ts

const patchChildren: PatchChildrenFn = (
  n1: VNode | null,
  n2: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean = false,
) => {
  const c1 = n1 && n1.children
  const prevShapeFlag = n1 ? n1.shapeFlag : 0
  const c2 = n2.children
  const { patchFlag, shapeFlag } = n2
  
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
      // 带 key 的 v-for:使用 LIS 算法的 keyed diff
      patchKeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, ...)
      return
    } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
      // 不带 key 的 v-for:就地更新
      patchUnkeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, ...)
      return
    }
  }
  
  // 无 patchFlag 的普通 children 处理...
}

Block Tree 的局限:v-if 内部的嵌套结构变化

Block Tree 无法优化以下场景:

<div v-if="show">
  <span v-if="inner">内层1</span>
  <span v-else>内层2</span>
</div>

当外层 show=trueinnertrue 变为 false 时:

但如果 v-if 的结构导致动态节点集合发生结构性变化(动态节点的数量或顺序改变),Block 优化就无法使用:

<div>
  <span v-if="a">A</span>  <!-- 动态内容 -->
  <span v-if="b">B</span>  <!-- 动态内容 -->
</div>

a 从 true 变为 false,外层 div Block 的 dynamicChildren 从 [spanA, spanB] 变为 [spanB]——数量变了!Block 无法简单地对应 diff,会回退到完整 diff。


Level 4 · 边界与陷阱(全体适用)

陷阱1:v-if 分支结构不对称时的 Block 替换代价

<!-- ❌ 结构差异很大的 v-if 分支 -->
<div v-if="complexCondition">
  <!-- 100 个子节点 -->
  <component-a />
  <component-b />
  <!-- ... -->
</div>
<div v-else>
  <p>简单提示</p>
</div>

每次 complexCondition 切换,Vue 3 会:

  1. 卸载有 100 个子组件的 Block(触发所有组件的 unmounted 钩子)
  2. 挂载 <p> Block 或反向操作

这在频繁切换时可能有性能问题。

优化方案:用 v-show 代替 v-if(不卸载 DOM,只切换 display);或者用 <keep-alive> 缓存组件状态。

陷阱2:v-for 不带 key 时的意外行为

<!-- ❌ 不带 key 的 v-for -->
<div v-for="item in list">
  <ChildComponent :data="item" />
</div>

不带 key 时,PatchFlag = UNKEYED_FRAGMENT,使用就地更新策略:

实际场景:原始列表 [A, B, C],排序后 [C, A, B]

结论:v-for 必须加 key,且 key 不能用 index(index 在列表重排时不稳定)。

陷阱3:在 createElementBlock 之前使用 openBlock(true)

// openBlock(true) 表示 disableTracking = true
// 用于某些特殊场景(如 v-once)
openBlock(true)
// 在 true 模式下,createVNode 不会把动态节点推入 currentBlock

这个 API 通常在内部使用,不应该在业务代码中直接调用。错误使用 openBlock(true) 会导致动态节点丢失,视图不更新。

陷阱4:dynamicChildren 的扁平化与深度嵌套的注意点

Block 的 dynamicChildren扁平的,不是树形的。嵌套 Block 内部的动态节点不会出现在外部 Block 的 dynamicChildren 中:

<div>              <!-- Block A -->
  {{ a }}          <!-- 在 Block A 的 dynamicChildren 中 -->
  <p v-if="x">    <!-- Block B(v-if):整个 p 在 Block A 的 dynamicChildren 中 -->
    {{ b }}        <!-- 在 Block B 的 dynamicChildren 中,不在 Block A 中 -->
  </p>
</div>

Block A 的 dynamicChildren = [aVNode, pVNode](pVNode 是整个 Block B) Block B 的 dynamicChildren = [bVNode]

这意味着:diff 时先更新 Block A 的 dynamicChildren(aVNode 和 pVNode),pVNode 是 Block B,进一步用 Block B 的 dynamicChildren(bVNode)做靶向 diff。层层下钻,不跨 Block 展平


本章小结

  1. Block 的核心是 dynamicChildren 数组:它是动态节点的"花名册",更新时直接按名单处理,无需遍历整棵树。openBlock() 开始收集,createElementBlock() 完成收集并关联到 VNode。

  2. Block 栈管理嵌套 Block:每个 openBlock() 压栈,closeBlock() 弹栈,父 Block 在子 Block 完成后收集子 Block VNode 本身(而非子 Block 的 dynamicChildren 展开)。

  3. v-if 的 Block 是整块替换单元:分支切换不做精细 diff,直接卸载旧 Block 挂载新 Block。适合结构差异大的分支;频繁切换且需要保留状态时,用 v-show 或 keep-alive。

  4. v-for 的 Fragment Block 用 PatchFlag 区分有无 key:KEYED_FRAGMENT(128)使用 LIS keyed diff,可以识别移动;UNKEYED_FRAGMENT(256)使用就地更新,无法识别移动。永远给 v-for 加稳定的 key。

  5. Block Tree 的优化有边界:v-if 内部的条件节点数量变化、动态结构中的嵌套 v-if 等场景会导致 Block 优化回退到完整 diff。理解边界,才能在设计模板结构时做出对编译器优化友好的选择。

本章评分
4.6  / 5  (27 评分)

💬 留言讨论