Block Tree:动态节点追踪与靶向 diff 的设计
第12章:Block Tree——动态节点追踪与靶向 diff 的设计
即使有了 PatchFlag,Vue 3 仍然面临一个问题:怎么快速找到树中有 PatchFlag 的节点?Block Tree 的答案是:不要遍历,让动态节点自己"报到"。
本章核心问题:PatchFlag 告诉运行时"如何更新",Block Tree 告诉运行时"去哪找需要更新的节点"。这两者如何协作,让 diff 从 O(树节点数) 降到 O(动态节点数)?
读完本章你将理解:
- Block 的概念与
dynamicChildren数组的工作机制 - openBlock/createBlock 的创建流程
- v-if 分支如何以 Block 为单位做整块替换
- v-for 的 Fragment Block 与稳定/不稳定 Fragment 的区别
- Block Tree 优化的边界与局限
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 数组):
- 组件根节点:每个组件的根元素都是一个 Block
- v-if / v-else-if / v-else 块:每个条件分支是一个 Block
- v-for 列表:整个 v-for 是一个 Fragment Block
<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),
]))
当 show 从 true 变为 false:
- 旧 Block:
div#a(dynamicChildren: [span, em]) - 新 Block:
div#b(dynamicChildren: [p]) - 因为 key 不同("a" vs "b"),直接卸载旧 Block,挂载新 Block
- 不会尝试将 div#a 的结构复用到 div#b
这个设计的好处:结构不同的分支(如 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):
- 每个列表项都有稳定的 key
- 使用带 key 的 diff 算法(可以识别移动、新增、删除)
- 复杂度:O(n) with LIS(最长递增子序列)
不稳定 Fragment(UNKEYED_FRAGMENT, 256):
- 列表项没有 key,或 key 是 index(不稳定)
- 使用简单的就地更新策略(patch 同位置的节点)
- 不能识别移动,只能比较同位置
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=true,inner 从 true 变为 false 时:
- 外层 div Block 的 dynamicChildren 包含内层 v-if Block
- 内层 v-if 的分支变化是内层 Block 的完整替换
- 这个替换发生在 div Block 的 dynamicChildren 追踪范围内,是正常的 Block 优化
但如果 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 会:
- 卸载有 100 个子组件的 Block(触发所有组件的 unmounted 钩子)
- 挂载
<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,使用就地更新策略:
- 第 i 个新节点与第 i 个旧节点 patch
- 如果列表重排(如排序),会导致错误的组件复用
实际场景:原始列表 [A, B, C],排序后 [C, A, B]:
- 就地更新:将 A 的数据更新到位置1的组件,将 C 的数据更新到位置1... 导致组件状态混乱
- 带 key 的更新:识别出 C 移到了位置1,移动 DOM 节点,不重新创建组件
结论: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 展平。
本章小结
-
Block 的核心是 dynamicChildren 数组:它是动态节点的"花名册",更新时直接按名单处理,无需遍历整棵树。
openBlock()开始收集,createElementBlock()完成收集并关联到 VNode。 -
Block 栈管理嵌套 Block:每个
openBlock()压栈,closeBlock()弹栈,父 Block 在子 Block 完成后收集子 Block VNode 本身(而非子 Block 的 dynamicChildren 展开)。 -
v-if 的 Block 是整块替换单元:分支切换不做精细 diff,直接卸载旧 Block 挂载新 Block。适合结构差异大的分支;频繁切换且需要保留状态时,用 v-show 或 keep-alive。
-
v-for 的 Fragment Block 用 PatchFlag 区分有无 key:KEYED_FRAGMENT(128)使用 LIS keyed diff,可以识别移动;UNKEYED_FRAGMENT(256)使用就地更新,无法识别移动。永远给 v-for 加稳定的 key。
-
Block Tree 的优化有边界:v-if 内部的条件节点数量变化、动态结构中的嵌套 v-if 等场景会导致 Block 优化回退到完整 diff。理解边界,才能在设计模板结构时做出对编译器优化友好的选择。