Appearance
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),这是我们最熟悉的部分 - 优化区:
patchFlag和dynamicChildren,编译器留给渲染器的优化信息,指明哪些地方会变、哪些永远不变
最核心的五个属性:
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 结构:
编译器忽略中间的 section 和 p,直接生成:
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 的渲染机制,我们看到的是一条精心设计的流水线:
- Compiler 分析模板,提取特征
- VNode 携带
shapeFlag(我是谁)和patchFlag(怎么变) - Block Tree 收集动态节点,忽略静态噪音
- 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 跳过静态节点
- 性能与动态内容数量成正比随堂测试
Block Tree 通过
dynamicChildren将动态节点扁平化收集,但 v-if 和 v-for 会打断这个收集。如果一个模板中有大量嵌套的 v-if,会对性能产生什么影响?有什么优化策略?patchFlag = -1 (CACHED)表示静态节点永不更新。但如果静态节点内部包含ref,Vue 是如何处理的?静态提升会影响 ref 的绑定吗?ShapeFlags 使用位掩码编码节点类型和子节点结构。如果 Vue 未来需要支持更多的节点类型,这种设计会遇到什么限制?JavaScript 的位运算有什么边界?
编译器生成的
dynamicProps数组只包含动态属性名。如果一个组件有 100 个 props,其中只有 1 个是动态的,这种设计相比全量 props diff 能节省多少开销?
下一章预告:我们已经理解了编译器准备的"数据契约"。下一章,我们将进入渲染器内部,看它如何将 VNode 转化为真实的 DOM,以及如何利用这些优化标记实现高效的更新。
