Appearance
第 11 章 虚拟 DOM 与 Diff 算法
本章要点
- 虚拟 DOM 的本质:从 VNode 的数据结构到它如何成为 UI 的中间表示层
- VNode 的类型系统:ShapeFlag 位掩码如何用一个整数编码所有节点类型信息
- patch 函数的分发逻辑:如何根据 VNode 类型选择不同的处理路径
- 子节点 Diff 的核心算法:双端比较、最长递增子序列、key 的关键作用
- Block Tree 与 PatchFlag:编译时优化如何让 Diff 从 O(n) 降到 O(动态节点数)
- Fragment、Teleport、Suspense 等特殊 VNode 的处理策略
- Vue 3.6 Vapor Mode 对传统 VNode 体系的挑战与共存
在前面的章节中,我们已经深入了编译器和组件系统。编译器将模板转化为渲染函数,组件系统管理每个组件的生命和状态。但在这两者之间,还有一个至关重要的中间层——虚拟 DOM。
当渲染函数执行时,它不会直接操作真实 DOM,而是生成一棵由 JavaScript 对象构成的虚拟节点树。当组件状态变化时,新的虚拟树与旧的虚拟树进行对比——这个过程就是 Diff。Diff 的结果是一组最小化的 DOM 操作指令,精确地把界面从旧状态更新到新状态。
这一章,我们要完全拆解这个过程。
11.1 VNode:虚拟 DOM 的原子
VNode 的数据结构
每一个虚拟节点都是一个 VNode 对象。它的结构远比"一个标签名加一堆属性"复杂得多:
typescript
// packages/runtime-core/src/vnode.ts
export interface VNode<
HostNode = RendererNode,
HostElement = RendererElement,
ExtraProps = { [key: string]: any }
> {
__v_isVNode: true // VNode 标记
type: VNodeTypes // 节点类型:string | Component | Fragment | ...
props: (VNodeProps & ExtraProps) | null
key: string | number | symbol | null // Diff 的身份标识
ref: VNodeNormalizedRef | null // 模板 ref
children: VNodeNormalizedChildren // 子节点
// ---- 运行时状态 ----
el: HostNode | null // 对应的真实 DOM 节点
anchor: HostNode | null // Fragment 的锚点
component: ComponentInternalInstance | null // 组件实例引用
// ---- 优化标记 ----
shapeFlag: number // 节点形状位掩码
patchFlag: number // 编译器标记的动态类型
dynamicProps: string[] | null // 动态属性名列表
dynamicChildren: VNode[] | null // Block 收集的动态子节点
// ---- 其他 ----
dirs: DirectiveBinding[] | null // 指令绑定
transition: TransitionHooks | null
suspense: SuspenseBoundary | null
appContext: AppContext | null
}一个 VNode 既是 UI 的描述,又携带了优化信息,还反向引用了真实 DOM。它是连接"声明式意图"和"命令式操作"的桥梁。
ShapeFlag:用位运算编码节点类型
Vue 使用一个整数的不同位来标记 VNode 的类型信息,这是一种经典的位掩码模式:
typescript
// packages/shared/src/shapeFlags.ts
export enum ShapeFlags {
ELEMENT = 1, // 0000 0001 — 普通 HTML 元素
FUNCTIONAL_COMPONENT = 1 << 1, // 0000 0010 — 函数式组件
STATEFUL_COMPONENT = 1 << 2, // 0000 0100 — 有状态组件
TEXT_CHILDREN = 1 << 3, // 0000 1000 — 子节点是文本
ARRAY_CHILDREN = 1 << 4, // 0001 0000 — 子节点是数组
SLOTS_CHILDREN = 1 << 5, // 0010 0000 — 子节点是插槽
TELEPORT = 1 << 6, // 0100 0000 — Teleport 组件
SUSPENSE = 1 << 7, // 1000 0000 — Suspense 组件
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
COMPONENT_KEPT_ALIVE = 1 << 9,
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}为什么用位掩码而不是字符串枚举?因为位运算的判断只需要一条 CPU 指令:
typescript
// 判断是否是元素
if (shapeFlag & ShapeFlags.ELEMENT) { /* ... */ }
// 判断是否是组件且有数组子节点
if (shapeFlag & ShapeFlags.COMPONENT && shapeFlag & ShapeFlags.ARRAY_CHILDREN) { /* ... */ }
// 创建时组合标记
const shapeFlag = ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN在 Diff 的热路径上,每一个 if 判断都被执行成千上万次。位运算比字符串比较快一个数量级,这是框架级性能优化的典型手法。
createVNode:VNode 的工厂
渲染函数中的 h() 最终调用 createVNode 来创建 VNode:
typescript
// packages/runtime-core/src/vnode.ts(简化)
export function createVNode(
type: VNodeTypes,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag: number = 0,
dynamicProps: string[] | null = null,
isBlockNode = false
): VNode {
// 1. 规范化 type
if (isVNode(type)) {
// 克隆已有 VNode
return cloneVNode(type, props)
}
// 2. 类组件规范化
if (isClassComponent(type)) {
type = type.__vccOpts
}
// 3. 规范化 props(class、style 合并)
if (props) {
props = guardReactiveProps(props)
let { class: klass, style } = props
if (klass && !isString(klass)) {
props.class = normalizeClass(klass)
}
if (isObject(style)) {
props.style = normalizeStyle(style)
}
}
// 4. 计算 shapeFlag
const shapeFlag = isString(type)
? ShapeFlags.ELEMENT
: isSuspense(type)
? ShapeFlags.SUSPENSE
: isTeleport(type)
? ShapeFlags.TELEPORT
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT
: isFunction(type)
? ShapeFlags.FUNCTIONAL_COMPONENT
: 0
// 5. 创建 VNode 对象
return createBaseVNode(type, props, children, patchFlag, dynamicProps, shapeFlag, isBlockNode)
}注意 patchFlag 和 dynamicProps 参数——它们由编译器注入,运行时不会自己去分析哪些属性是动态的。这就是 Vue 3 的编译-运行时协作模型。
11.2 patch:万物的入口
patch 函数的分发逻辑
patch 是整个渲染器的核心入口。无论是首次渲染还是更新,都从 patch 开始:
typescript
// packages/runtime-core/src/renderer.ts(简化)
const patch: PatchFn = (
n1, // 旧 VNode(null 表示首次挂载)
n2, // 新 VNode
container, // DOM 容器
anchor = null,
parentComponent = null,
parentSuspense = null,
namespace = undefined,
slotScopeIds = null,
optimized = false
) => {
// 1. 如果新旧节点类型完全不同,直接卸载旧的
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null // 重置为 null,后续走挂载逻辑
}
// 2. 如果新节点标记为 BAIL,关闭优化
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}
// 3. 根据类型分发
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
processText(n1, n2, container, anchor)
break
case Comment:
processCommentNode(n1, n2, container, anchor)
break
case Static:
if (n1 == null) {
mountStaticNode(n2, container, anchor, namespace)
}
break
case Fragment:
processFragment(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
;(type as typeof TeleportImpl).process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals)
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals)
}
}
// 4. 设置 ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}这个函数的设计体现了一个重要原则:类型驱动的分发。不同类型的 VNode 走完全不同的处理路径,各自优化,互不干扰。
isSameVNodeType:身份判定
判断两个 VNode 是否"同一个"的逻辑极简:
typescript
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}两个条件——相同的类型,相同的 key。如果类型变了(比如从 <div> 变成 <span>),没有任何 Diff 的意义,直接替换。如果 key 变了,即使类型相同,也视为不同节点。这就是为什么 key 在列表渲染中如此重要。
11.3 元素的 Patch
当 patch 分发到 processElement 时,会区分挂载和更新两条路径:
typescript
const processElement = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean
) => {
if (n1 == null) {
mountElement(n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
} else {
patchElement(n1, n2, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
}
}patchElement:属性与子节点的更新
typescript
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean
) => {
const el = (n2.el = n1.el!) // 复用真实 DOM 节点
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
let { patchFlag, dynamicChildren, dirs } = n2
// ---- 属性更新 ----
if (patchFlag > 0) {
// 编译器告诉我们哪些属性是动态的
if (patchFlag & PatchFlags.FULL_PROPS) {
// 所有属性都可能变化,全量 diff
patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, namespace)
} else {
// 按需更新
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, namespace)
}
}
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, namespace)
}
if (patchFlag & PatchFlags.PROPS) {
// 只更新 dynamicProps 中列出的属性
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev || key === 'value') {
hostPatchProp(el, key, prev, next, namespace, parentComponent)
}
}
}
}
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (!optimized && dynamicChildren == null) {
// 没有编译器优化标记,全量 diff 属性
patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, namespace)
}
// ---- 子节点更新 ----
if (dynamicChildren) {
// Block 优化路径:只 patch 动态子节点
patchBlockChildren(n1.dynamicChildren!, dynamicChildren, el, parentComponent, parentSuspense, resolveChildrenNamespace(n2, namespace), slotScopeIds)
} else if (!optimized) {
// 全量子节点 diff
patchChildren(n1, n2, el, null, parentComponent, parentSuspense, resolveChildrenNamespace(n2, namespace), slotScopeIds, false)
}
}