Vue 3 核心源码深度剖析系列(v3.5.44)-运行时内幕(一):VNode 设计与渲染器主流程
本章对应
packages/runtime-core/src/vnode.ts(875 行)、packages/runtime-core/src/renderer.ts(2517 行)、packages/runtime-core/src/h.ts(869 行)、packages/shared/src/shapeFlags.ts、packages/shared/src/patchFlags.ts、packages/runtime-dom/src/nodeOps.ts等文件,所有行号对应vuejs/core@3.5.44。读完本章你将能回答:为什么
runtime-core里一行document都没有,却能在浏览器里渲染出真实 DOM?一个VNode对象到底有哪些字段、谁在读、谁在写?shapeFlag和patchFlag这两个"魔法数字"分别编码了什么?patch(n1, n2)是如何在"未挂载 / 替换 / 更新"三条路径之间做分派的?以及一个最常被面试官追问的问题:vnode.el到底什么时候才可用?
导语:从模板到 DOM 之间,站着两样东西
如果只记住本章一句话,请记住这句:Vue 3 的运行时由「一个纯数据的 VNode 树」和「一台平台无关的 patch 机器」组成,前者描述"想要什么",后者描述"怎么把它变成宿主环境里的节点"。
vnode.ts定义的是数据:一个VNode就是一个普通 JS 对象,它携带类型、属性、子节点、优化标记,以及对宿主节点的引用。它不依赖任何 DOM API。renderer.ts定义的是算法:patch、mountElement、patchElement、unmount等一系列函数。它同样不依赖任何 DOM API,因为所有的节点操作都被抽出成RendererOptions接口,由平台层(runtime-dom的nodeOps)注入。h.ts定义的是面向人类的入口:把h('div', { id: 'a' }, 'hello')这种写起来舒服的调用,归一化成编译器产出的那种createVNode(...)调用。shapeFlags.ts/patchFlags.ts定义的是编码:把"这是个元素还是组件""这个元素哪些部分会变"压缩进 JS 的整数位里,让运行时可以在 O(1) 时间里知道"要不要处理、处理哪一部分"。
这四者叠加起来,就是"Vue 3 比 Vue 2 快"在运行时侧最核心的答案:Vue 3 不再是"每次更新都从头 diff 整棵树",而是"编译器提前标好了哪些地方会变,运行时只碰这些地方"。 这个"提前标好"的载体,正是 patchFlag 与 dynamicChildren(Block 树),而它们的存在又依赖于 VNode 这个数据结构。所以我们先讲数据,再讲算法。
本章结构:11.1 讲渲染器为什么能做到平台无关;11.2 讲 VNode 全字段;11.3 讲 ShapeFlags 位枚举;11.4 讲类型体系与判定优先级;11.5 讲创建与规范化;11.6 讲 h();11.7–11.12 逐段走读 patch 及其子过程;11.13 给可运行实战;11.14 回顾。patchChildren 与 patchKeyedChildren(含最长递增子序列)是第 12 章的主题,本章只在 11.7 里点明它被调用的位置。
11.1 渲染器的可插拔架构:为什么 runtime-core 里没有 document
11.1.1 问题的起点:同一套虚拟 DOM 逻辑要跑在多少种平台
Vue 3 想在浏览器 DOM、SSR(字符串拼接)、自定义渲染器(Canvas、WebGL、小程序、NativeScript、终端 UI) 上复用同一套 diff 逻辑。要做到这一点,逻辑层就绝不能直接调用 document.createElement。这不是洁癖,而是硬约束:runtime-core 的构建产物会在 Node.js 环境(SSR)里被执行,那里根本没有 document,一旦写死 DOM API,SSR 就会在导入阶段直接崩溃。
于是设计被拆成两层:
runtime-core:纯逻辑。它只知道"有一个宿主节点类型HostNode、有一个宿主元素类型HostElement",以及"你可以插入、删除、创建、比较属性"。它通过泛型 + 依赖注入来使用这些能力。runtime-dom:平台适配。它把真实的document操作包成一组函数(nodeOps),再配上一个patchProp,一起塞给createRenderer。
11.1.2 RendererOptions:宿主能力的抽象接口
平台能力被抽象为一个接口 RendererOptions,定义在 packages/runtime-core/src/renderer.ts:117-154:
export interface RendererOptions<
HostNode = RendererNode,
HostElement = RendererElement,
> {
patchProp(
el: HostElement,
key: string,
prevValue: any,
nextValue: any,
namespace?: ElementNamespace,
parentComponent?: ComponentInternalInstance | null,
): void
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
remove(el: HostNode): void
createElement(
type: string,
namespace?: ElementNamespace,
isCustomizedBuiltIn?: string,
vnodeProps?: (VNodeProps & { [key: string]: any }) | null,
): HostElement
createText(text: string): HostNode
createComment(text: string): HostNode
setText(node: HostNode, text: string): void
setElementText(node: HostElement, text: string): void
parentNode(node: HostNode): HostElement | null
nextSibling(node: HostNode): HostNode | null
querySelector?(selector: string): HostElement | null
setScopeId?(el: HostElement, id: string): void
cloneNode?(node: HostNode): HostNode
insertStaticContent?(content: string, parent: HostElement, anchor: HostNode | null,
namespace: ElementNamespace, start?: HostNode | null, end?: HostNode | null): [HostNode, HostNode]
}
这 13 个方法就是一个"渲染器后端"的全部契约。按职责可以分成四组:
| 分组 | 方法 | 语义 | 谁调用 |
|---|---|---|---|
| 创建 | createElement / createText / createComment / cloneNode? | 造出新节点 | mountElement、processText、processCommentNode |
| 插入/删除 | insert / remove | 把节点挂到父节点、从父节点摘掉 | mountElement、move、remove |
| 导航 | parentNode / nextSibling | 找父节点、找下一个兄弟(算出 anchor) | patchBlockChildren、getNextHostNode、remove |
| 内容/属性 | patchProp / setText / setElementText | 改文本、改属性 | mountElement、patchElement、patchProps |
RendererNode / RendererElement 的定义极简(renderer.ts:160-164):
export interface RendererNode {
[key: string | symbol]: any
}
export interface RendererElement extends RendererNode {}
注意源码注释(renderer.ts:156-159):"在 core 渲染器的逻辑中,Node 技术上可以是任何对象——它们从不被直接操作,永远只是被传给 options 里的节点操作函数。" 这句话是理解可插拔架构的钥匙:RendererNode 只是个"索引签名占位类型",真正的类型约束由使用方(如 runtime-dom 的 Node)提供。
易错点:
RendererOptions里querySelector、setScopeId、cloneNode、insertStaticContent都是可选的(带?)。这意味着自定义渲染器可以省略它们——但一旦省略,对应的优化路径就会失效:没有insertStaticContent,静态节点(createStaticVNode)就无法挂载(renderer.ts:554-555明确写着"静态节点只在配合 compiler-dom/runtime-dom 时存在,因为那保证了hostInsertStaticContent的存在");没有setScopeId,SFC 的 scoped 样式 id 就不会被写入。
11.1.3 createRenderer:一个把 options 变成闭包机器的工厂
runtime-core 对外只暴露两个工厂(renderer.ts:322-336):
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement,
>(options: RendererOptions<HostNode, HostElement>): Renderer<HostElement> {
return baseCreateRenderer<HostNode, HostElement>(options)
}
// 单独的、启用 hydration 的渲染器入口;hydration 逻辑只在调用此函数时被打包,
// 从而可被 tree-shake。
export function createHydrationRenderer(
options: RendererOptions<Node, Element>,
): HydrationRenderer {
return baseCreateRenderer(options, createHydrationFunctions)
}
createRenderer 与 createHydrationRenderer 的唯一差别,是后者多传了一个 createHydrationFunctions,把 SSR 水合逻辑引入进来。这样不需要水合的客户端构建里,整个 hydration.ts 会被 tree-shake 掉——这是"功能开关通过函数注入实现"的经典手法。
真正的实现是 baseCreateRenderer(renderer.ts:351-354)。它是一个巨型闭包:所有 patch/mountElement/unmount 都定义在这个闭包内部(源码注释 renderer.ts:381-382:"闭包内的函数应写成 const xxx = () => {} 形式,以防被压缩器内联")。这个设计有三个动机:
- 共享 options 而不污染全局:
hostInsert、hostPatchProp等从options解构出来后,被闭包内所有函数引用(renderer.ts:366-379),无需层层传参。 - 天然支持多实例:你可以在同一个页面创建多个"渲染器实例"(例如一个渲染 DOM、一个渲染 Canvas),它们各自持有自己的一套
hostInsert,互不干扰。 - 闭包函数不可被外部篡改:引用稳定,
RendererInternals(见 11.1.5)把它们传给 Teleport/Suspense 等"可 tree-shake 的内置组件"。
11.1.4 runtime-dom 如何注入真实 DOM 操作
runtime-dom 的入口把这些能力落到实处(packages/runtime-dom/src/index.ts:18-19,72,83):
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'
// ...
const rendererOptions = /*@__PURE__*/ extend({ patchProp }, nodeOps)
// ...
renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions)
nodeOps 定义在 packages/runtime-dom/src/nodeOps.ts:44-138,每一项都直接落到 document 上:
const doc = (typeof document !== 'undefined' ? document : null) as Document
export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
remove: child => {
const parent = child.parentNode
if (parent) parent.removeChild(child)
},
createElement: (tag, namespace, is, props): Element => {
const el =
namespace === 'svg'
? doc.createElementNS(svgNS, tag)
: namespace === 'mathml'
? doc.createElementNS(mathmlNS, tag)
: is ? doc.createElement(tag, { is }) : doc.createElement(tag)
if (tag === 'select' && props && props.multiple != null) {
;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
}
return el
},
createText: text => doc.createTextNode(text),
createComment: text => doc.createComment(text),
setText: (node, text) => { node.nodeValue = text },
setElementText: (el, text) => { el.textContent = text },
parentNode: node => node.parentNode as Element | null,
nextSibling: node => node.nextSibling,
// ...
}
几个值得注意的细节:
doc有typeof document !== 'undefined'的保护(nodeOps.ts:40):即使在 SSR 环境误加载了 runtime-dom,也不会在模块顶层就炸,只是doc为null,等到真正调用节点操作时才出错。createElement里对<select multiple>的特殊处理(nodeOps.ts:66-68):因为<select>的multiple必须在插入选项之前通过setAttribute设置,否则value的选中行为会出错。这是"宿主自身的怪癖",被下沉到平台层处理,core 完全不用关心。insertStaticContent(nodeOps.ts:99-138)用<template>做容器:首次用innerHTML解析整段静态 HTML 字符串一次,后续走cloneNode(true)缓存路径(当start && (start === end || start.nextSibling)成立时)。这就是编译器静态提升(static hoisting)在运行时的落地——一段永不变化的 HTML 只需解析一次。它还处理了 SVG/MathML 需要外层包裹再剥壳的问题(nodeOps.ts:121-129)。
11.1.5 平台无关的边界:RendererInternals
runtime-core 内部有些"可 tree-shake 的特性"(Teleport、Suspense)在 renderer.ts 之外实现,却又需要调用渲染器的能力。直接 import 会造成循环依赖,也会破坏 tree-shaking。解法是一个"内部能力包" RendererInternals(renderer.ts:169-183),键名被刻意缩短以压缩包体积:
export interface RendererInternals<
HostNode = RendererNode,
HostElement = RendererElement,
> {
p: PatchFn // patch
um: UnmountFn // unmount
r: RemoveFn // remove
m: MoveFn // move
mt: MountComponentFn // mountComponent
mc: MountChildrenFn // mountChildren
pc: PatchChildrenFn // patchChildren
pbc: PatchBlockChildrenFn // patchBlockChildren
n: NextFn // getNextHostNode
o: RendererOptions<HostNode, HostElement> // options
}
它由闭包内的真实函数组装而成(renderer.ts:2488-2499):
const internals: RendererInternals = {
p: patch, um: unmount, m: move, r: remove,
mt: mountComponent, mc: mountChildren, pc: patchChildren,
pbc: patchBlockChildren, n: getNextHostNode, o: options,
}
然后 patch 的分派里,Teleport 和 Suspense 就把 internals 作为最后一个参数传进去(renderer.ts:476-501):
} else if (shapeFlag & ShapeFlags.TELEPORT) {
;(type as typeof TeleportImpl).process(n1 as TeleportVNode, n2 as TeleportVNode,
container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds,
optimized, internals)
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).process(/* ... */, internals)
}
边界由此闭合:runtime-core 定义"能力契约",runtime-dom 提供"能力实现",内置组件通过 internals 消费"能力",任何一方都不需要知道另一方的存在。要写自定义渲染器,你只需实现 RendererOptions,createRenderer 就会把它变成一台完整的、能跑 Teleport/Suspense 的渲染机器。
graph LR
subgraph Core["runtime-core (纯逻辑)"]
V[VNode 数据结构<br/>vnode.ts]
P[patch 机器<br/>renderer.ts]
H[h() 入口<br/>h.ts]
SF[ShapeFlags / PatchFlags<br/>编码]
end
subgraph Platform["runtime-dom (平台适配)"]
N[nodeOps<br/>真实 document 操作]
PP[patchProp]
IDX[index.ts: createRenderer]
end
IDX -->|rendererOptions| P
N --> IDX
PP --> IDX
P -->|hostInsert/hostPatchProp...| N
P -->|internals| Teleport
P -->|internals| Suspense
H --> V
P --> V
SF --> V
11.2 VNode 数据结构:一个普通对象承载的整个世界
11.2.1 全字段总览
VNode 接口定义在 packages/runtime-core/src/vnode.ts:148-245。它不是一个 class,而是一个字面量对象类型——createBaseVNode 里就是直接构造一个对象字面量(vnode.ts:453-481)。这样做的好处是对象形状(hidden class)稳定,V8 能给它分配固定的"对象模板",属性访问走内联缓存,比 class 实例或动态增删属性的对象更快。
下面是全字段对照表(按定义顺序,含释义、写入者、读取者):
| 字段 | 类型 | 含义 | 谁写 | 谁读 |
|---|---|---|---|---|
__v_isVNode | true | 类型守卫标记 | createBaseVNode 等所有创建路径 | isVNode() |
__v_skip | true | 跳过响应式代理(ReactiveFlags.SKIP) | 同上 | reactivity 系统 |
type | VNodeTypes | 节点类型(字符串标签 / 组件 / Text/Comment/Fragment/…符号) | 创建时 | patch 分派、isSameVNodeType |
props | (VNodeProps & ExtraProps) | null | 属性对象(含 key/ref 及组件 props) | 创建时(可能经 class/style 归一化) | mountElement、patchProps、patchElement |
key | PropertyKey | null | diff 复用依据 | 创建时由 normalizeKey 提取 | isSameVNodeType |
ref | VNodeNormalizedRef | null | 模板 ref / 函数 ref | 创建时由 normalizeRef 归一化 | patch 末尾的 setRef |
scopeId | string | null | SFC scoped 样式 id | 创建时取 currentScopeId | setScopeId |
slotScopeIds | string[] | null | 插槽作用域 id 数组 | processComponent / Fragment patch | setScopeId |
children | VNodeNormalizedChildren | 子节点(字符串 / VNode 数组 / 插槽对象 / null) | 创建时 + normalizeChildren | mountChildren、patchChildren |
component | ComponentInternalInstance | null | 该组件 vnode 对应的组件实例 | mountComponent(挂载时) | 更新/卸载组件 |
dirs | DirectiveBinding[] | null | 自定义指令绑定 | 编译产物(withDirectives) | invokeDirectiveHook |
transition | TransitionHooks<HostElement> | null | <Transition> 钩子 | setTransitionHooks | mountElement/remove |
el | HostNode | null | 该 vnode 对应的宿主节点 | 挂载时写入(见 11.2.6) | 几乎处处 |
anchor | HostNode | null | Fragment/Static 的结束锚点 | processFragment/mountStaticNode | remove/移动 |
placeholder | HostNode | null | 异步组件占位节点 | async wrapper | hydration |
target / targetStart / targetAnchor | HostElement | HostNode | null | Teleport 目标容器及其锚点 | Teleport.process | Teleport.remove/move |
staticCount | number | 静态 vnode 内含的节点数量(水合需要) | createStaticVNode | hydration |
suspense | SuspenseBoundary | null | 所属 Suspense 边界 | Suspense.process | Suspense.unmount 等 |
ssContent / ssFallback | VNode | null | Suspense 的 content / fallback 分支 | Suspense.normalize | Suspense |
shapeFlag | number | 类型 + 子节点形态的位编码 | 创建时 | patch/mountElement/unmount |
patchFlag | number | 更新优化提示的位编码(编译器产出) | 编译器 / cloneVNode | patchElement/patchChildren |
dynamicProps | string[] | null | PROPS 标志下"可能变化的 prop 名列表" | 编译器 | patchElement 的 PROPS 分支 |
dynamicChildren | VNode[] & { hasOnce?: boolean } | null | Block 树:本块内所有"动态子节点"(含子块) | setupBlock | patchBlockChildren/unmount |
appContext | AppContext | null | 应用级上下文(仅根 vnode) | createVNode 后由 app 赋值 | provide/inject、全局配置 |
ctx | ComponentInternalInstance | null | 词法作用域宿主实例(写入时取 currentRenderingInstance) | 创建时 | v-memo 缓存清理等 |
memo | any[]? | v-memo 缓存数组 | withMemo | cloneIfMounted/unmount |
cacheIndex | number? | v-memo 缓存在 renderCache 里的下标 | withMemo | unmount 清理 |
ce | 函数? | 自定义元素拦截钩子 | CustomElement | patch |
isCompatRoot | true? | __COMPAT__ 专用 | compat | compat |
表格横向看是"数据模型",纵向看是"生命周期":上半段字段在创建时就确定(型号、身份、props、children),中间段字段在挂载时由运行时写入(el、component、transition),下半段字段是编译器优化与内置组件专用的载荷(patchFlag、dynamicChildren、suspense 等)。 理解这条分界线,就理解了 VNode "一半是描述、一半是状态"的本质。
11.2.2 型号与身份字段:type / key / props / ref / children
这五个字段决定了"这个 vnode 是什么、和谁相等、长什么样"。
-
type:见 11.4。它是分派的唯一依据(patch用switch (n2.type)处理 Text/Comment/Static/Fragment,用shapeFlag处理其余)。字符串 → 元素;对象 → 有状态组件;函数 → 函数式组件;Text/Comment/Fragment/Static是四个符号常量(vnode.ts:64-72):export const Fragment = Symbol.for('v-fgt') as any as { __isFragment: true; new (): { $props: VNodeProps } } export const Text: unique symbol = Symbol.for('v-txt') export const Comment: unique symbol = Symbol.for('v-cmt') export const Static: unique symbol = Symbol.for('v-stc')用
Symbol.for而不是普通对象字面量,是为了保证跨模块、跨副本的唯一性——即使应用里打包了两份不同版本的 Vue(常见于微前端/组件库),Fragment依然相等,isSameVNodeType不会误判。 -
props:属性对象。它同时承载三件事:DOM 属性(id、class…)、组件 props、以及vnode 专属元数据(key/ref/onVnodeXXX钩子)。后者的类型定义在vnode.ts:123-136:export type VNodeProps = { key?: PropertyKey ref?: VNodeRef ref_for?: boolean ref_key?: string onVnodeBeforeMount?: VNodeMountHook | VNodeMountHook[] onVnodeMounted?: VNodeMountHook | VNodeMountHook[] onVnodeBeforeUpdate?: VNodeUpdateHook | VNodeUpdateHook[] onVnodeUpdated?: VNodeUpdateHook | VNodeUpdateHook[] onVnodeBeforeUnmount?: VNodeMountHook | VNodeMountHook[] onVnodeUnmounted?: VNodeMountHook | VNodeMountHook[] }正因为
key/ref藏在props里,mountElement遍历 props 时必须用isReservedProp(key)把它们排除掉(renderer.ts:718),否则key会被误当成 DOM 属性写到元素上。 -
key:由normalizeKey从 props 提取(vnode.ts:423-424):key != null ? key : null。它只在isSameVNodeType里参与比较,是"同类型节点能否复用"的开关。 -
ref:由normalizeRef归一化成"原子对象或原子对象数组"(vnode.ts:426-441)。注意它把数字 ref 转成字符串(vnode.ts:431-433,为了兼容ref="123"这种模板写法),并把非空 ref 包成{ i: currentRenderingInstance, r: ref, k: ref_key, f: !!ref_for }的规范形态,把渲染实例固化进去。这一步的意义是:ref 的归属在创建时就被绑定,而不是等到挂载时——这样在<component :is>场景里 clone 出来的 vnode 也能带着正确的实例。 -
children:形态有四种——字符串(TEXT_CHILDREN)、VNode 数组(ARRAY_CHILDREN)、插槽对象(SLOTS_CHILDREN)、null。形态信息被并行编码到shapeFlag里(见 11.3),而children本身在运行时是"裸数据"。谁把children变成合法形态,取决于创建路径:编译器产物信任输入(少量校验),createVNode走normalizeChildren全量归一化(vnode.ts:483-495与837-894两条路径的区别,详见 11.5.5)。
11.2.3 宿主与容器字段:el / anchor / target 三兄弟
这三个(组)字段都是"指向真实宿主节点"的引用,但它们服务的目标完全不同,混用会造成严重 bug。
el:vnode 对应的主宿主节点。- 元素 vnode 的
el就是它自己创建的那个 DOM 元素。 - 文本/注释 vnode 的
el就是那个 Text/Comment 节点。 - Fragment vnode 的
el是它的起始锚点文本节点(renderer.ts:1088),anchor是结束锚点。 - 组件 vnode 的
el指向其子树根节点的el(通过updateHOCHostEl一路向上同步,见componentRenderUtils.ts:477-500)。
- 元素 vnode 的
anchor:仅 Fragment 与 Static 使用的"结束"节点。Fragment 的anchor是结束锚点文本节点(renderer.ts:1089);Static 的anchor是这段静态内容的最后一个 DOM 节点(renderer.ts:556-563的[n2.el, n2.anchor])。target/targetStart/targetAnchor:仅 Teleport 使用,分别指向"渲染到哪个真实容器"以及该容器内两个用于定位/移动的锚点。
源码注释把这三者的用途标得很清楚(vnode.ts:186-191,逐字对应的中文解释:el = 宿主节点、placeholder = 异步组件占位、anchor = fragment 锚点、target = teleport 目标)。
为什么 Fragment 非要 anchor? 因为 Fragment 是一组平铺的兄弟节点,没有自己的"容器元素"。当要把整组节点移动/删除时,必须知道"这一组从哪开始、到哪结束"。起始点由 el(起锚点)承担,结束点就需要 anchor(止锚点)。removeFragment(cur, end)(renderer.ts:2355-2365)就是靠 while (cur !== end) 从起锚点一路删到止锚点。
为什么不能只用一个"节点数组"代替? 因为 DOM 没有"节点组"这个概念,父容器里就是一条兄弟链。用两个锚点文本节点把这条链"括起来",等于给这组离散节点人为造了一个可寻址的区间。这也是为什么 Fragment 挂载时会先插入两个空文本节点(renderer.ts:1112-1113)再挂载真正的子节点。
11.2.4 优化字段:shapeFlag / patchFlag / dynamicProps / dynamicChildren
这四个字段是本章的"性能核心",也是与 Vue 2 拉开差距的地方。
shapeFlag:创建时确定,描述"这是元素/组件/Teleport/Suspense 中的哪一种 + 子节点是文本/数组/插槽中的哪一种"。详见 11.3。patchFlag:由编译器产出,描述"这个节点更新时哪些部分会变"。非编译产物(手写渲染函数、JSX)通常为0,意味着"我不知道哪里会变,请全量 diff"。dynamicProps:仅当patchFlag & PROPS时非空,是"会变的 prop 名数组"。patchElement的 PROPS 分支只遍历这个数组(renderer.ts:959-968),完全跳过那些静态 prop——这是"只更新会变的那几个"的具体实现。dynamicChildren:Block 树的载荷,是这个节点(作为 Block 根)在本次渲染中收集到的所有动态子节点(含嵌套子 Block)。运行时靠它走patchBlockChildren快路径,跳过整片静态子树。
它们之间的关系可以这样记:shapeFlag 回答"我是谁、我的孩子是什么形态";patchFlag 回答"我更新时要碰哪里";dynamicProps 是 patchFlag & PROPS 的明细;dynamicChildren 是"整片子树里需要碰的节点清单"。 前者是"身份",后者是"变更集"。
注意一个反直觉点:
dynamicChildren里放的是子节点,而不是"本节点的属性"。本节点自己的属性变更由它自己的patchFlag描述;子节点的变更由父块的dynamicChildren描述。二者是两层优化。
11.2.5 生命周期与元数据字段:component / dirs / transition / ctx / appContext
component:组件 vnode 独有的"反向指针",指向ComponentInternalInstance。挂载时由mountComponent写入;更新时靠它在updateComponent里找到旧实例;卸载时靠它调用unmountComponent。这是"vnode ←→ 实例"双向绑定的 VNode 一侧。dirs:自定义指令绑定数组,由编译器的withDirectives助手写入(见renderer.ts:710-712, 744-746的invokeDirectiveHook(..., 'created'/'beforeMount'))。注意它挂在 vnode 上而非元素 DOM 上,所以组件也能带指令(再由renderComponentRoot继承到根元素,componentRenderUtils.ts:247-257)。transition:<Transition>的钩子对象(mountElement里setTransitionHooks相关路径使用)。remove时会检查transition && !transition.persisted来决定是否走离开动画(renderer.ts:2336-2349)。ctx:词法宿主实例。创建时取currentRenderingInstance(vnode.ts:480)。它的关键用途是 v-memo 缓存清理时的归属判断——unmount里cacheIndex != null && (!vnode.ctx || vnode.ctx === parentComponent)才清理缓存(renderer.ts:2207-2209),源码注释点明"插槽接收方不得使插槽作者拥有的缓存失效"。这就是"插槽内的 v-memo 缓存到底归谁管"的答案。appContext:只有应用根 vnode 非空,携带AppContext(全局组件注册表、provide/inject 的全局层、config等)。
11.2.6 编译器产出 vs 运行时写入:el 的挂载时机
这是全章最容易在面试里翻车的问题:"vnode.el 什么时候可用?" 答案是"取决于 vnode 处于哪个生命周期阶段",而且有一条精确的规则:
createBaseVNode 构造时把 el 初始化成 null(vnode.ts:469),随后只有渲染器在挂载过程中才会写入它。逐一列出写入点:
| vnode 类型 | el 在哪一步被赋值 | 源码位置 |
|---|---|---|
| 元素 | mountElement 第一句 vnode.el = hostCreateElement(...),在插入容器之前 | renderer.ts:686 |
| 文本 | processText 的 n2.el = hostCreateText(...),与插入同时 | renderer.ts:517-521 |
| 注释 | processCommentNode 的 n2.el = hostCreateComment(...) | renderer.ts:537-541 |
| Fragment | processFragment 的 n2.el = hostCreateText('')(起始锚点) | renderer.ts:1088 |
| Static | mountStaticNode 的 [n2.el, n2.anchor] = hostInsertStaticContent(...) | renderer.ts:556-563 |
| 组件 | 其子树根节点的 el,由 updateHOCHostEl 向上同步 | componentRenderUtils.ts:477-500 |
| 更新路径 | patchElement 开头 n2.el = n1.el(沿用旧节点,不重建) | renderer.ts:854 |
由此得到几条实践准则:
- "挂载前"(
onBeforeMount之前)vnode.el为null:因为挂载还没跑到赋值那一步。 mountElement里元素el在插入前就已有值:源码注释解释了为什么——"先挂载 children,因为某些 props 依赖子内容已渲染,例如<select value>"(renderer.ts:693-694)。也就是说,元素的el一旦创建就可用,但它还没进 DOM 树。所以el.parentNode在插入前是null。onMounted回调里vnode.el一定可用:它在hostInsert之后通过queuePostRenderEffect异步派发(renderer.ts:754-771)。- 组件 vnode 的
el是"借用"其根节点:不要在setup里读组件 vnode 的el,要在onMounted里通过模板 ref 拿。
哪些字段是编译器产出、哪些是运行时写入? 一张对照表:
| 阶段 | 编译器产出(codegen 生成 createVNode 调用参数) | 运行时写入 |
|---|---|---|
| 创建 | type、props、children、patchFlag、dynamicProps、key、ref(写在 props 里) | shapeFlag(由 createVNode 依 type 计算)、scopeId(currentScopeId)、ctx(currentRenderingInstance)、__v_isVNode/__v_skip |
| 挂载 | — | el、anchor、component、transition、slotScopeIds、dirs(由助手函数挂上) |
| Block 组装 | openBlock/createBlock 调用 + 编译器把带 flag 的子节点收集进 dynamicChildren | setupBlock 把 currentBlock 存回 vnode.dynamicChildren 并向上挂 |
换句话说:编译器负责"告诉运行时哪里会变"(patchFlag/dynamicProps/Block 结构),运行时负责"把描述实例化成真实节点并把状态挂回 vnode"(el/component/anchor)。 二者通过 VNode 这一个数据结构交汇——这也是为什么 VNode 既是"数据的描述"又是"状态的家"。
11.3 ShapeFlags 位枚举与 children 形态
11.3.1 定义:13 个位
packages/shared/src/shapeFlags.ts:1-13 全文如下:
export enum ShapeFlags {
ELEMENT = 1,
FUNCTIONAL_COMPONENT = 1 << 1, // 2
STATEFUL_COMPONENT = 1 << 2, // 4
TEXT_CHILDREN = 1 << 3, // 8
ARRAY_CHILDREN = 1 << 4, // 16
SLOTS_CHILDREN = 1 << 5, // 32
TELEPORT = 1 << 6, // 64
SUSPENSE = 1 << 7, // 128
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 256
COMPONENT_KEPT_ALIVE = 1 << 9, // 512
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT, // 6
}
11.3.2 位运算原理:一个整数装两个维度
shapeFlag 一个 32 位整数里塞进了两个正交维度:
- 低 3 位(bit0–bit2)是"类型位":
ELEMENT(1)、FUNCTIONAL_COMPONENT(2)、STATEFUL_COMPONENT(4)。任意时刻这三个里最多命中一个。 - bit3–bit7 是"子节点形态位 + 特殊节点位":
TEXT_CHILDREN、ARRAY_CHILDREN、SLOTS_CHILDREN、TELEPORT、SUSPENSE。- 前三个是"children 形态",三者互斥;
- 后两个是"特殊节点类型",与 children 形态正交(Teleport 也可以有 array children)。
- bit8–bit9 是 KeepAlive 标记位:
COMPONENT_SHOULD_KEEP_ALIVE、COMPONENT_KEPT_ALIVE(由<KeepAlive>在 patch 时临时打上)。 COMPONENT是"组合位":STATEFUL_COMPONENT | FUNCTIONAL_COMPONENT = 4 | 2 = 6,用来一次性判断"是不是组件(不论有状态还是函数式)"。
判定用 &,置位用 |=,清除用 &= ~,这些都是 O(1) 的机器级指令。
11.3.3 类型位与 children 位的组合关系
一个 shapeFlag 的典型取值是这样"拼"出来的:
// 元素 + 数组子节点
ELEMENT | ARRAY_CHILDREN = 1 | 16 = 17
// 元素 + 文本子节点
ELEMENT | TEXT_CHILDREN = 1 | 8 = 9
// 有状态组件 + 插槽子节点
STATEFUL_COMPONENT | SLOTS_CHILDREN = 4 | 32 = 36
// Teleport + 数组子节点
TELEPORT | ARRAY_CHILDREN = 64 | 16 = 80
// Suspense
SUSPENSE = 128
创建时如何计算类型位?看 _createVNode 的类型位计算(vnode.ts:628-639):
const shapeFlag = isString(type)
? ShapeFlags.ELEMENT
: __FEATURE_SUSPENSE__ && isSuspense(type)
? ShapeFlags.SUSPENSE
: isTeleport(type)
? ShapeFlags.TELEPORT
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT
: isFunction(type)
? ShapeFlags.FUNCTIONAL_COMPONENT
: 0
children 位则由 createBaseVNode / normalizeChildren 追加:
- 编译器产物路径(信任输入):
vnode.shapeFlag |= isString(children) ? TEXT_CHILDREN : ARRAY_CHILDREN(vnode.ts:492-494); - 归一化路径:
normalizeChildren(vnode, children)里依序分派并vnode.shapeFlag |= type(vnode.ts:893)。
11.3.4 组合判定对照表
下表列出运行时真实会出现的 shapeFlag 组合及其含义("命中位"列用二进制展示):
| shapeFlag | 十进制 | 二进制(bit7…bit0) | 含义 |
|---|---|---|---|
ELEMENT | TEXT_CHILDREN | 9 | 0000 1001 | 元素,唯一子节点是文本(<div>hello</div>) |
ELEMENT | ARRAY_CHILDREN | 17 | 0001 0001 | 元素,子节点是 vnode 数组 |
STATEFUL_COMPONENT | 4 | 0000 0100 | 有状态组件(无子节点) |
STATEFUL_COMPONENT | SLOTS_CHILDREN | 36 | 0010 0100 | 有状态组件 + 插槽对象 |
FUNCTIONAL_COMPONENT | 2 | 0000 0010 | 函数式组件 |
TELEPORT | ARRAY_CHILDREN | 80 | 0101 0000 | Teleport,子节点被强制数组化(vnode.ts:885-887) |
SUSPENSE | 128 | 1000 0000 | Suspense 边界 |
STATEFUL_COMPONENT | SLOTS_CHILDREN | COMPONENT_SHOULD_KEEP_ALIVE | 292 | 1 0010 0100 | 被 KeepAlive 缓存、当前处于失活态的组件 |
判定方式:shapeFlag & ELEMENT(非 0 即命中)、shapeFlag & COMPONENT(因为 COMPONENT = 6,能同时命中有状态与函数式)。patch 的分派就完全建立在这种判定上(renderer.ts:452-501):
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(...)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(...)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
;(type as typeof TeleportImpl).process(...)
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).process(...)
}
11.3.5 为什么用位运算而不是字符串/多字段
Vue 2 用的是 vnode.tag 字符串判断 + 若干布尔字段(isComment、isCloned 等)。Vue 3 换成位枚举,收益是实打实的:
- 判定 O(1) 且分支可预测:
shapeFlag & ELEMENT是一条AND+ 一次条件跳转,CPU 分支预测器极容易命中。字符串比较要逐字符,最坏 O(n)。 - 单字段装多信息,减少属性访问:判断"是元素还是组件"和"children 是文本还是数组"只需读一个
shapeFlag,而不是读两个布尔字段。属性越少,对象形状越稳,内联缓存越友好。 - 紧凑、可组合:
COMPONENT = STATEFUL | FUNCTIONAL让"凡是组件"这一类判断一气呵成,无需isStateful || isFunctional。 - 位清除实现"临时标记":KeepAlive 的激活/失活就是临时改位(HMR 场景
isSameVNodeType里用n1.shapeFlag &= ~COMPONENT_SHOULD_KEEP_ALIVE,vnode.ts:385-386),不引入新字段。 shapeFlag与patchFlag命名对称:一个是"结构位图",一个是"变更位图",读代码的人一眼能分清"shap"(形状)与"patch"(补丁)。
易错点:
shapeFlag是创建时算好的,不代表运行时的最终形态。例如 Teleport 的 children 在normalizeChildren里被强制转成数组(vnode.ts:885-887),所以它的shapeFlag里既有TELEPORT又有ARRAY_CHILDREN;而一个"本该是文本子节点"的组件,其 children 会被转成插槽对象(vnode.ts:875-881)。判断 children 形态之前,先确认 vnode 走的是哪条创建路径。
11.4 VNode 类型体系:8 种节点与判定顺序
11.4.1 八种 VNode 及其"身份"
VNodeTypes 联合类型定义在 vnode.ts:74-85:
export type VNodeTypes =
| string
| VNode
| Component
| typeof Text
| typeof Static
| typeof Comment
| typeof Fragment
| typeof Teleport
| typeof TeleportImpl
| typeof Suspense
| typeof SuspenseImpl
可归纳为八类(vnode.ts:64-72 的四个符号 + 字符串 + 组件等):
| 类别 | type 取值 | shapeFlag 主位 | children 形态 | 典型来源 |
|---|---|---|---|---|
| 元素 | 字符串('div'、'svg'…) | ELEMENT | 文本 / 数组 | <div> |
| 有状态组件 | 对象(含 setup/render) | STATEFUL_COMPONENT | 插槽对象后归一化 | <MyComp> |
| 函数式组件 | 函数 | FUNCTIONAL_COMPONENT | 插槽 / 数组 | h(() => h('div')) |
| 文本 | Text(Symbol.for('v-txt')) | 无(type 特判) | 字符串 | {{ msg }}、<span>hi</span> 的文本子 |
| 注释 | Comment(Symbol.for('v-cmt')) | 无 | 字符串 | v-if 空分支、用户注释 |
| 片段 | Fragment(Symbol.for('v-fgt')) | 无(type 特判) | 数组 | 多根模板、v-for、数组 children |
| 静态 | Static(Symbol.for('v-stc')) | 无 | 字符串化的 HTML | 静态提升 |
| Teleport / Suspense | Teleport/Suspense 实现对象 | TELEPORT / SUSPENSE | 数组 / 分支 | <Teleport> / <Suspense> |
注意 Text / Comment / Fragment / Static 这四类在 shapeFlag 里没有专属位(它们的 type 就是那四个 Symbol)。patch 之所以能处理它们,靠的是 switch (n2.type) 的精确匹配,而不是位判定——这是**"类型分派"与"位判定"两种手法并用**的典型设计:对"少数几个特殊类型"用 switch(分支少、可预测),对"元素 vs 组件 vs Teleport vs Suspense"这类需要组合判断的用位运算。
11.4.2 isSameVNodeType:diff 复用的唯一判据
定义在 vnode.ts:379-392:
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
if (__DEV__ && n2.shapeFlag & ShapeFlags.COMPONENT && n1.component) {
const dirtyInstances = hmrDirtyComponents.get(n2.type as ConcreteComponent)
if (dirtyInstances && dirtyInstances.has(n1.component)) {
// #7042, ensure the vnode being unmounted during HMR
n1.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
n2.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE
return false
}
}
return n1.type === n2.type && n1.key === n2.key
}
核心只有一行:n1.type === n2.type && n1.key === n2.key。含义是"同类型 + 同 key ⇒ 可以复用旧的真实节点,走 patch 更新;否则必须先卸载旧节点、再挂载新节点"。
三个要点:
type相等是前提。'div'换成'span'就不相等,必须重建;组件 A 换成组件 B 也不相等,必须卸载重挂。这是"类型不同则不可复用"的铁律。key相等是开关。有 key 时,只有 key 相同才复用;无 key 时,null === null成立,于是同类型节点按位置复用(这正是"无 key 列表就地复用"的性能与陷阱来源,第 12 章展开)。- HMR 特判(
vnode.ts:380-390):开发期组件热更新时,若该组件实例已被标记为 dirty,强制返回false以触发重挂载。这里用位清除把SHOULD_KEEP_ALIVE/KEPT_ALIVE从 shapeFlag 上抹掉,避免一个正在被热更新的组件还走 KeepAlive 的失活路径。
isSameVNodeType 的调用点遍布渲染器:patch 开头(renderer.ts:399)、patchBlockChildren 里计算容器(renderer.ts:1017)、patchKeyedChildren 的判断(第 12 章)。一处返回 true 还是 false,直接决定"复用"还是"重建"。
11.4.3 type 与 key 的比较语义
type用===全等:字符串比较按值;组件对象比较按引用;Symbol 比较按唯一性。这就是为什么四个内置符号要用Symbol.for——保证跨包副本相等。若用Symbol()(不带for)或对象字面量,两份 Vue 副本的Fragment就不全等,跨库的 vnode 会互相无法复用。key用===全等:normalizeKey保证null/undefined都归一成null(vnode.ts:423-424),于是"都没 key"时相等。key 的类型也参与比较:key={1}(number)与key={'1'}(string)不相等(===不做隐式转换)。normalizeRef会把数字 ref 转字符串(vnode.ts:431-433),但normalizeKey不会——这是有意的:key 是 diff 语义,ref 是用户便利,二者处理策略不同。
11.4.4 判定优先级表
patch 的类型分派是有序的,顺序不可调换。完整优先级如下(renderer.ts:424-505):
flowchart TD
A["patch(n1, n2)"] --> B{"n1 && !isSameVNodeType(n1,n2)?"}
B -->|是| C["anchor = getNextHostNode(n1)<br/>unmount(n1, ..., true)<br/>n1 = null"]
B -->|否| D{"n2.type?"}
C --> D
D -->|Text| E["processText"]
D -->|Comment| F["processCommentNode"]
D -->|Static| G{"n1 == null?"}
G -->|是| H["mountStaticNode"]
G -->|否| I["patchStaticNode (dev/HMR)"]
D -->|Fragment| J["processFragment"]
D -->|其他| K{"shapeFlag & ELEMENT?"}
K -->|是| L["processElement → mountElement / patchElement"]
K -->|否| M{"shapeFlag & COMPONENT?"}
M -->|是| N["processComponent → mountComponent / updateComponent"]
M -->|否| O{"shapeFlag & TELEPORT?"}
O -->|是| P["Teleport.process"]
O -->|否| Q{"__FEATURE_SUSPENSE__ && shapeFlag & SUSPENSE?"}
Q -->|是| R["Suspense.process"]
Q -->|否| S["dev: warn('Invalid VNode type')"]
E --> T["setRef / 收尾"]
F --> T
H --> T
I --> T
J --> T
L --> T
N --> T
P --> T
R --> T
优先级设计的三条动机:
- 先做"同类型判断"再做"类型分派":因为类型不同的替换要在进入任何 mount/patch 逻辑之前就完成卸载。否则新节点会被挂到一棵"还留着旧节点"的树上,造成重复。
- 四个特殊符号用
switch前置:它们的shapeFlag里没有专属位(Fragment的 shapeFlag 甚至可能只有 children 位),无法用位判定,必须精确匹配type。 - 其余用位判定,且 ELEMENT 先于 COMPONENT:虽然一个 vnode 理论上不会同时是元素和组件,但把最常见、最外层的
ELEMENT放第一位,能让绝大多数 patch 调用在第一次判定就命中,减少分支数。
易错点:
type === Text/Comment/Static/Fragment与shapeFlag & ELEMENT是互斥的。例如Fragment的shapeFlag里会有ARRAY_CHILDREN,但绝不会有ELEMENT——否则switch走完case Fragment后就不该再进default的位判定分支。源码的switch结构天然保证了这一点。
11.5 VNode 创建与规范化
11.5.1 createVNode / _createVNode:入口与两条分支
对外入口是 createVNode(vnode.ts:562-564):
export const createVNode = (
__DEV__ ? createVNodeWithArgsTransform : _createVNode
) as typeof _createVNode
这是一个"构建期条件替换":开发版用带参数变换的包装(供 @vue/test-utils 注入 stub 逻辑,vnode.ts:394-421),生产版直接用裸函数。这样生产构建里 transformVNodeArgs、vnodeArgsTransformer 相关的代码全部被 tree-shake 掉。
真正的实现 _createVNode(vnode.ts:566-663)分三条主要分支:
分支 A:!type || type === NULL_DYNAMIC_COMPONENT(vnode.ts:574-579)
if (!type || type === NULL_DYNAMIC_COMPONENT) {
if (__DEV__ && !type) warn(`Invalid vnode type when creating vnode: ${type}.`)
type = Comment
}
NULL_DYNAMIC_COMPONENT 是 <component :is="null"> 编译产物里的占位常量。它和"type 为空"一起被降级成注释节点——渲染一个空占位而不是报错崩溃。
分支 B:isVNode(type)(vnode.ts:581-598)
if (isVNode(type)) {
const cloned = cloneVNode(type, props, true /* mergeRef: true */)
if (children) normalizeChildren(cloned, children)
if (isBlockTreeEnabled > 0 && !isBlockNode && currentBlock) {
if (cloned.shapeFlag & ShapeFlags.COMPONENT) {
currentBlock[currentBlock.indexOf(type)] = cloned
} else {
currentBlock.push(cloned)
}
}
cloned.patchFlag = PatchFlags.BAIL
return cloned
}
这是 <component :is="vnode"/> 场景:传入的"type"本身就是一个 vnode。要做的是克隆它并合并额外的 props/ref(mergeRef: true 保证 <component :is="vnode" ref="x"/> 时新旧 ref 都生效,见 #2078 注释,vnode.ts:583-585)。关键一招是强制 patchFlag = PatchFlags.BAIL(vnode.ts:596):因为克隆体是用户语义而非编译器语义,运行时不能信任它的优化标记,必须让 diff 退出优化模式走全量对比。同时在 Block 树里用 indexOf 替换原位置(组件)或直接 push(普通节点),保证块结构正确。
分支 C:常规创建(vnode.ts:600-662):依次做
- 类组件归一化:
isClassComponent(type)则取type.__vccOpts(vnode.ts:601-603)——把class MyComp extends Vue {}这种 2.x 写法摊平成选项对象。 - compat 归一化(
__COMPAT__):convertLegacyComponent(vnode.ts:606-608)。 - props 归一化(
vnode.ts:611-626):先guardReactiveProps(响应式对象 clone 以免污染用户数据),再把class归一成字符串(normalizeClass)、style归一成对象(normalizeStyle,且响应式 style 需extend({}, style)复制一份)。 - 计算 shapeFlag(
vnode.ts:628-639,见 11.3.3)。 - dev 检查组件是否被
reactive()包裹(vnode.ts:641-651):若是则告警并toRaw——把组件对象变成响应式是性能陷阱,会导致每次访问都走代理。 - 落库:调用
createBaseVNode(..., needFullChildrenNormalization = true)。
guardReactiveProps(vnode.ts:665-670)还处理了 isInternalObject(props) 的情况:
export function guardReactiveProps(props) {
if (!props) return null
return isProxy(props) || isInternalObject(props) ? extend({}, props) : props
}
isInternalObject 定义在 packages/runtime-core/src/internalObject.ts:7-13:
const internalObjectProto = {}
export const createInternalObject = (): any => Object.create(internalObjectProto)
export const isInternalObject = (obj: object): boolean =>
Object.getPrototypeOf(obj) === internalObjectProto
用"空原型对象"而非"定义一个不可枚举标记属性"来标识"内部对象",是因为前者只需一次 getPrototypeOf,比读取属性更快(文件注释说这是 SSR 基准测试做的一项优化)。normalizeChildren 里也用它判断 slots 是否已被归一化过(vnode.ts:858, 862)。
11.5.2 createBaseVNode:字段的最终落点
createBaseVNode(vnode.ts:443-546)是所有创建路径的公共落点。它先构造字面量对象(vnode.ts:453-481),然后:
if (needFullChildrenNormalization) {
normalizeChildren(vnode, children)
if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).normalize(vnode)
}
} else if (children) {
// compiled element vnode - if children is passed, only possible types are string or Array.
vnode.shapeFlag |= isString(children) ? ShapeFlags.TEXT_CHILDREN : ShapeFlags.ARRAY_CHILDREN
}
两条 children 路径的分水岭就是 needFullChildrenNormalization:
| 路径 | 谁用 | 做了什么 | 代价 |
|---|---|---|---|
快速路径(else if) | 编译器产出的 createElementVNode/createBlock | 只按 isString 猜 TEXT/ARRAY | 极少,但假设输入已是合法形态 |
| 全量路径 | createVNode、h() | 走 normalizeChildren 逐个分支处理 | 稍多,但容忍任意输入 |
源码注释(vnode.ts:490-491)点明:"编译器产出的元素 vnode,若传了 children,只可能是 string 或 Array。"这就是编译器与运行时之间的一个隐式契约:编译器保证形态,运行时省下检查。createElementVNode(即 createBaseVNode 的别名,vnode.ts:548)就是给编译器产物用的快速入口。
随后 createBaseVNode 还做三件收尾:
-
dev 校验 key:
if (__DEV__ && vnode.key !== vnode.key)(vnode.ts:498-500)。NaN !== NaN恒真,所以这行专门抓key: NaN。这是用 NaN 自身的反身性做检测的经典技巧。 -
dev 校验
innerHTML/textContent与 children 冲突(vnode.ts:505-519):模板编译器会对<div v-html="x">child</div>报错,但手写渲染函数没有这层保护,所以运行时补一个告警,避免用户以为 children 会生效。 -
Block 追踪(
vnode.ts:522-538):if ( isBlockTreeEnabled > 0 && !isBlockNode && currentBlock && (vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) && vnode.patchFlag !== PatchFlags.NEED_HYDRATION ) { currentBlock.push(vnode) }这段就是 Block 树的"收集"逻辑,五个条件缺一不可:① Block 追踪未被
v-once关闭;② 自己不是 Block 根(避免自收集);③ 处在某个父 Block 的收集窗口内;④ 有 patchFlag(说明会变)或是组件(组件即使不变也必须参与 diff 以维护实例);⑤ patchFlag 不是"纯NEED_HYDRATION"(纯事件监听标记不算动态,因为事件是被缓存的)。满足才会被push进父 Block 的dynamicChildren。
11.5.3 cloneVNode:复制且"降级优化"
cloneVNode(vnode.ts:672-758)用于"需要一份近乎相同、但可安全改写"的副本。典型场景:<component :is="vnode"/>、renderComponentRoot 里把 fallthrough attrs/dirs/transition 合并进根 vnode(componentRenderUtils.ts:179, 255, 267)、dev 下复用被缓存的 hoisted vnode。
关键设计——"加了 extraProps 就不能再信任原 patchFlag"(vnode.ts:710-719):
patchFlag:
extraProps && vnode.type !== Fragment
? patchFlag === PatchFlags.CACHED // 静态提升节点
? PatchFlags.FULL_PROPS // 降级为 FULL_PROPS
: patchFlag | PatchFlags.FULL_PROPS // 或上 FULL_PROPS
: patchFlag,
原因写在注释里(vnode.ts:710-713):如果克隆时额外合并了 props,那么"哪些 prop 会变"就不再由原编译器标记决定,必须走 FULL_PROPS 全量 props diff。但 Fragment 例外——Fragment 的 patchFlag 用来为 children 选快路径(stable/keyed/unkeyed),不能被 props 语义污染。
其它值得注意的复制策略:
- 不复制
el? 不,复制:源码注释解释(vnode.ts:726-729):component/suspense/placeholder/el/anchor这些"理论上官挂载后才非空"的字段一律照抄,因为 KeepAlive 缓存的 vnode 需要它们;而"挂载时它们会被覆写"所以照抄无害。 - dev 下克隆 CACHED 节点的 children(
vnode.ts:701-704):若patchFlag === CACHED且 children 是数组,deep clone 每个子 vnode,避免缓存的静态树被多处共享后互相污染(对应#2022)。 - mergeRef(
vnode.ts:689-698):若原 vnode 已有 ref 又传了 extraProps.ref,且mergeRef=true,则把多个 ref 合并成数组。 transition.clone(vnode.ts:746-751):只有显式传cloneTransition=true时才克隆 transition 钩子,保证钩子里引用的 vnode 是新的。
11.5.4 normalizeVNode:把"任意子节点"变成 VNode
normalizeVNode(vnode.ts:807-827)是把 children 数组里的任意元素统一成 VNode 的"最后一公里":
export function normalizeVNode(child: VNodeChild): VNode {
if (child == null || typeof child === 'boolean') {
// empty placeholder
return createVNode(Comment)
} else if (isArray(child)) {
// fragment
return createVNode(Fragment, null, child.slice())
} else if (isVNode(child)) {
return cloneIfMounted(child)
} else {
// strings and numbers
return createVNode(Text, null, String(child))
}
}
四条规则,逐一解读:
输入 child | 输出 | 动机 |
|---|---|---|
null / undefined / 布尔 | 空 Comment vnode | v-if/&& 短路产生的 false 必须占一个位置,不然 diff 位置会错位 |
| 数组 | Fragment vnode(child.slice()) | 嵌套数组 → 一个片段,slice 复制避免复用 vnode 时引用污染(#3666) |
| 已是 vnode | cloneIfMounted(child) | 见下 |
| 字符串 / 数字 | Text vnode(String(child)) | 统一成文本节点 |
为什么布尔要变成空注释? 因为在渲染函数里 cond && h('div') 当 cond 为 false 时会得到 false;list.map(x => x.show && h('li')) 会得到 [h('li'), false, h('li')]。若直接把 false 留在 children 数组里,长度虽对但 diff 时无法比较,且挂载无意义。转成注释节点既能"占位"又零成本。
cloneIfMounted(vnode.ts:830-835):
export function cloneIfMounted(child: VNode): VNode {
return (child.el === null && child.patchFlag !== PatchFlags.CACHED) || child.memo
? child
: cloneVNode(child)
}
它的逻辑是:如果 vnode 还没被挂载(el === null 且不是被缓存复用)或者是 v-memo 节点,就原样返回;否则克隆一份。为什么要克隆?因为一个已挂载的 vnode 的 el 指向旧 DOM,如果直接把它塞进新树当子节点,运行时会把旧的 DOM 节点当成新节点插入,而它其实已经在别的地方被使用了。克隆一份(el 会被照抄但随后被覆写)可以切断这种"同一个 vnode 被两处引用"的隐患。
注意:
cloneIfMounted在mountChildren的 optimized 分支里被大量使用(renderer.ts:828-830),是挂载性能热路径上的关键函数。
11.5.5 normalizeChildren:children 形态的完整归一化
normalizeChildren(vnode.ts:837-894)是理解"children 到底是什么"的核心。它按输入类型分派,并把结果同时写进 vnode.children 和 vnode.shapeFlag:
第一步:children == null(vnode.ts:840-841)→ children = null,不置任何 children 位。
第二步:isArray(children)(vnode.ts:842-843)→ type = ARRAY_CHILDREN。注意此处不递归归一化数组元素;元素的分派推迟到 mountChildren/patchChildren 时用 normalizeVNode 处理(懒归一化)。
第三步:typeof children === 'object'(vnode.ts:844-874)——最复杂的一支,区分"元素拿到对象"和"组件拿到对象":
} else if (typeof children === 'object') {
if (shapeFlag & (ShapeFlags.ELEMENT | ShapeFlags.TELEPORT)) {
// Normalize slot to plain children for plain element and Teleport
const slot = (children as any).default
if (slot) {
// _c marker is added by withCtx() indicating this is a compiled slot
slot._c && (slot._d = false)
normalizeChildren(vnode, slot())
slot._c && (slot._d = true)
}
return
} else {
type = ShapeFlags.SLOTS_CHILDREN
const slotFlag = (children as RawSlots)._
if (!slotFlag && !isInternalObject(children)) {
;(children as RawSlots)._ctx = currentRenderingInstance
} else if (slotFlag === SlotFlags.FORWARDED && currentRenderingInstance) {
if ((currentRenderingInstance.slots as RawSlots)._ === SlotFlags.STABLE) {
;(children as RawSlots)._ = SlotFlags.STABLE
} else {
;(children as RawSlots)._ = SlotFlags.DYNAMIC
vnode.patchFlag |= PatchFlags.DYNAMIC_SLOTS
}
}
}
}
三条关键规则:
- 元素/Teleport 拿到"带
default的对象",会把它当成"默认插槽函数"就地调用,把返回值当普通 children 处理(vnode.ts:845-854)。这是为了支持h('div', () => ...)这种瞎写——DOM 元素没有插槽概念,只能当函数 children 处理。调用 slot 前后用slot._c && (slot._d = false/true)临时禁用/恢复依赖收集(_d是"dirty"标志,编译期插槽的追踪开关)。 - 组件拿到对象 → 就是插槽对象,置
SLOTS_CHILDREN(vnode.ts:856)。若这个 slots 对象还没有_标记(即未经编译器归一化,是手写的),则临时挂上_ctx = currentRenderingInstance,让后续渲染插槽时能拿到正确的宿主实例。 FORWARDED插槽的降级逻辑(vnode.ts:862-873):当子组件向上转发父组件的插槽时,插槽的"稳定性"由父组件决定——父组件是STABLE则子也是STABLE;否则降级为DYNAMIC并给 vnode 打上DYNAMIC_SLOTSpatchFlag(这会强制该组件每次都更新,因为插槽内容可能变)。
第四步:isFunction(children)(vnode.ts:875-881):
} else if (isFunction(children)) {
if (shapeFlag & (ShapeFlags.ELEMENT | ShapeFlags.TELEPORT)) {
normalizeChildren(vnode, { default: children })
return
}
children = { default: children, _ctx: currentRenderingInstance }
type = ShapeFlags.SLOTS_CHILDREN
}
函数 children:对元素/Teleport 包成 { default: fn } 递归(走到上面第三步);对组件直接包成 { default: fn, _ctx } 的插槽对象并置 SLOTS_CHILDREN。这就是 h(Component, () => h('div')) 能被当成"默认插槽"的原因。
第五步:其它(字符串/数字等)(vnode.ts:882-891):
} else {
children = String(children)
if (shapeFlag & ShapeFlags.TELEPORT) {
type = ShapeFlags.ARRAY_CHILDREN
children = [createTextVNode(children as string)]
} else {
type = ShapeFlags.TEXT_CHILDREN
}
}
普通值 String() 后置 TEXT_CHILDREN;但 Teleport 特例:其 children 必须被强制转成"只含一个文本 vnode 的数组",置 ARRAY_CHILDREN(vnode.ts:885-887,注释:"force teleport children to array so it can be moved around")。因为 Teleport 的内容要被移动/跨容器摆放,散落的文本节点无法被统一管理,必须先包成 vnode 数组。
最后统一落库(vnode.ts:892-893):
vnode.children = children as VNodeNormalizedChildren
vnode.shapeFlag |= type
归一化决策流程图:
flowchart TD
S["normalizeChildren(vnode, children)"] --> N{"children == null?"}
N -->|是| N1["children = null"]
N -->|否| A{"isArray?"}
A -->|是| A1["type = ARRAY_CHILDREN"]
A -->|否| O{"typeof object?"}
O -->|是| OE{"shapeFlag 含 ELEMENT/TELEPORT?"}
OE -->|是| OE1["取 .default 插槽函数<br/>调用它,递归 normalizeChildren"]
OE -->|否| OE2["type = SLOTS_CHILDREN<br/>无 _ 则挂 _ctx<br/>FORWARDED 则依父稳定性降级"]
O -->|否| F{"isFunction?"}
F -->|是| FE{"shapeFlag 含 ELEMENT/TELEPORT?"}
FE -->|是| FE1["包成 { default: fn } 递归"]
FE -->|否| FE2["{ default: fn, _ctx }<br/>type = SLOTS_CHILDREN"]
F -->|否| T{"shapeFlag & TELEPORT?"}
T -->|是| T1["children = [createTextVNode(String(c))]<br/>type = ARRAY_CHILDREN"]
T -->|否| T2["children = String(c)<br/>type = TEXT_CHILDREN"]
N1 --> W["vnode.children = children<br/>vnode.shapeFlag |= type"]
A1 --> W
OE1 --> W
OE2 --> W
FE1 --> W
FE2 --> W
T1 --> W
T2 --> W
11.5.6 创建辅助函数:createTextVNode / createCommentVNode / createStaticVNode / createBlock
-
createTextVNode(vnode.ts:775-777):export function createTextVNode(text: string = ' ', flag: number = 0): VNode { return createVNode(Text, null, text, flag) }默认值是一个空格
' '而不是空串——因为某些宿主(如旧浏览器)对空文本节点的渲染/定位有怪癖,保留一个空格更稳。第二个参数flag允许传入patchFlag(如NEED_PATCH)。 -
createCommentVNode(vnode.ts:796-805):export function createCommentVNode(text: string = '', asBlock: boolean = false): VNode { return asBlock ? (openBlock(), createBlock(Comment, null, text)) : createVNode(Comment, null, text) }asBlock=true用于v-else分支:必须把注释节点做成 Block,否则在v-if/v-else切换时,两个分支的"块身份"不一致会导致更新出错。这就是源码注释"when used as the v-else branch, the comment node must be created as a block to ensure correct updates"的含义。 -
createStaticVNode(vnode.ts:782-791):export function createStaticVNode(content: string, numberOfNodes: number): VNode { const vnode = createVNode(Static, null, content) vnode.staticCount = numberOfNodes return vnode }静态节点用一段 HTML 字符串 + 节点数量描述。
staticCount是给水合用的:服务端渲染时能通过它知道"这段静态内容对应几个 DOM 节点",从而正确跳过。 -
createBlock/createElementBlock(vnode.ts:328-373):它们与createVNode的唯一差别是最后调用了setupBlock(vnode.ts:311-323):function setupBlock(vnode: VNode) { vnode.dynamicChildren = isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null closeBlock() if (isBlockTreeEnabled > 0 && currentBlock) { currentBlock.push(vnode) } return vnode }它把"当前块收集到的动态子节点数组"存回
vnode.dynamicChildren,然后closeBlock把currentBlock还原成外层块,最后把本块本身作为子节点 push 进外层块(Block 是嵌套的树)。openBlock/closeBlock/setBlockTracking的配合详见第 7 章(编译器 codegen)。
11.6 h() 与手写渲染函数
11.6.1 为什么还需要 h()
createVNode 对编译器友好(单态、可传 patchFlag),但对人类不友好:h('div', 'x') 要写成 createVNode('div', null, 'x')。h.ts 顶部注释把两者的分工讲得很清楚(h.ts:25-29):
his a more user-friendly version ofcreateVNodethat allows omitting the props when possible. It is intended for manually written render functions. Compiler-generated code usescreateVNodebecause 1. it is monomorphic and avoids the extra call overhead 2. it allows specifying patchFlags for optimization.
即:h 是给人写的(省略 props、参数归一化),createVNode 是给编译器写的(单态、可带优化标记)。
11.6.2 重载签名:类型层面的便利
h.ts:84-196 是三十多个重载,覆盖各种调用形态。源码注释里给出了"什么时候能省略 props"的完整矩阵(h.ts:31-54):
h('div') // 仅类型
h('div', {}) // 类型 + 空 props
h('div', []) // 类型 + 省略 props + 数组 children(省略 props 时不支持具名插槽)
h('div', 'foo') // 类型 + 省略 props + 文本
h('div', h('br')) // 类型 + 省略 props + 单 vnode
h(Component, () => {}) // 类型 + 默认插槽
h('div', {}, []) // 类型 + props + 数组
h('div', {}, 'foo') // 类型 + props + 文本
h(Component, {}, {}) // 类型 + props + 具名插槽
h(Component, null, {}) // 具名插槽而无 props 时必须显式传 null 避免歧义
最后一条是 h 唯一的"歧义陷阱":h(Component, {}) 里的 {} 会被当成 props(因为对象且非数组)而不是插槽;要表达"无 props + 具名插槽",必须写成 h(Component, null, {}),用显式 null 把第二个参数位占住。
重载覆盖的类型维度:元素(HTML 标签名 + 泛型事件映射 HTMLElementEventHandler,h.ts:74-92)、自定义字符串标签、Text/Comment、Fragment、Teleport(target 必填)、Suspense、函数式组件、通用组件、defineComponent 构造器、class 组件……最后一组是"catch-all"(h.ts:191-196)。
11.6.3 参数归一化:运行时的真实逻辑
实现只有一个函数(h.ts:199-227):
export function h(type: any, propsOrChildren?: any, children?: any): VNode {
try {
// #6913 disable tracking block in h function
setBlockTracking(-1)
const l = arguments.length
if (l === 2) {
if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
// single vnode without props
if (isVNode(propsOrChildren)) {
return createVNode(type, null, [propsOrChildren])
}
// props without children
return createVNode(type, propsOrChildren)
} else {
// omit props
return createVNode(type, null, propsOrChildren)
}
} else {
if (l > 3) {
children = Array.prototype.slice.call(arguments, 2)
} else if (l === 3 && isVNode(children)) {
children = [children]
}
return createVNode(type, propsOrChildren, children)
}
} finally {
setBlockTracking(1)
}
}
逐条走读:
-
setBlockTracking(-1)...finally { setBlockTracking(1) }(h.ts:201-202, 224-225):h全程关闭 Block 追踪(对应#6913)。原因是手写渲染函数的 vnode 没有编译器的 patchFlag 语义,如果被收集进dynamicChildren,运行时可能用错误的快路径 diff 它。关掉追踪后,h产出的节点不进任何块的dynamicChildren,一律走全量对比,安全。 -
两参数(
l === 2):propsOrChildren是"要么是 props,要么是 children"。- 是对象且非数组 → 若又是 vnode,则当"唯一 children"(包成
[propsOrChildren]);否则当 props。 - 其它(数组/字符串/函数/数字)→ 当作 children,props 传
null。
- 是对象且非数组 → 若又是 vnode,则当"唯一 children"(包成
-
三参数及以上(
l >= 3):propsOrChildren明确是 props。l > 3:把第 3 个起的实参整体收集成children数组(支持h('div', {}, a, b, c))。l === 3 && isVNode(children):把单个 vnode 包成数组。- 其余情况原样传
children给createVNode(再由normalizeChildren归一化)。
-
arguments.length而非形参判断:这是关键——因为h('div', undefined)与h('div')语义不同,必须用实参个数区分。
11.6.4 h 与编译产物的关系
h 与编译器产物在数据结构上完全等价——它们都产出 VNode。差异只在"运行时对它的信任度":
| 维度 | h() 产物 | 编译器 createVNode/createBlock 产物 |
|---|---|---|
patchFlag | 通常为 0(除非用户显式传) | 编译器精确计算(TEXT/CLASS/PROPS/…) |
dynamicProps | 无 | PROPS 时提供 |
dynamicChildren | 不进块(h 关闭追踪) | 进块,形成 Block 树 |
| 更新路径 | optimized=false → 全量 patchChildren/patchProps | optimized=true → 快路径 |
| children 归一化 | 走 normalizeChildren 全量 | 信任输入,快速路径 |
因此混合场景要小心:在同一个组件里既有编译模板又有手写 h 子节点时,手写节点的 patchFlag=0 会让它自己走全量 diff,但它可能仍被父编译块收集——不,h 关了追踪所以不会。这正是 setBlockTracking(-1) 的保护意义。
h 还常用于 render() 返回数组(多根):
render() {
return [h('div', 'a'), h('div', 'b')] // 数组会被 normalizeVNode 包成 Fragment
}
这里数组最终由 renderComponentRoot 里的 normalizeVNode(componentRenderUtils.ts:102-112)转成 Fragment。
11.7 patch 主流程:三分支与类型分派
11.7.1 patch 的完整签名与默认参数
patch 是渲染器的"心脏",定义在 renderer.ts:383-513。它的签名(renderer.ts:383-393):
const patch: PatchFn = (
n1, // 旧 vnode,null 表示挂载
n2, // 新 vnode
container, // 父宿主元素
anchor = null, // 插入锚点(插到 anchor 之前)
parentComponent = null, // 父组件实例(ref/指令/scopeId 用)
parentSuspense = null, // 所属 Suspense 边界
namespace = undefined, // svg / mathml / undefined
slotScopeIds = null, // 插槽作用域 id 链
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren,
) => { ... }
optimized 的默认值藏着一个重要优化(renderer.ts:392):
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
即:"新 vnode 有没有 dynamicChildren"直接决定了本次 patch 是否走优化模式。如果新 vnode 是一个 Block(编译器产出),dynamicChildren 非空 → optimized=true;如果是手写渲染函数产物或非 Block 节点 → optimized=false。开发期 HMR 更新时强制 false(必须全量对比,否则热替换的节点可能不刷新)。
PatchFn 的类型定义在 renderer.ts:188-198,共 9 个参数。这个签名在本文件里被复用了无数次(RendererInternals.p 也用它),所以源码注释说:"这些函数在闭包里创建、无法直接导出类型,因此在这里统一声明一次,避免签名维护两份。"(renderer.ts:185-187)
11.7.2 三分支:n1 === n2 / 替换 / 更新
patch 开头有三道闸门(renderer.ts:394-421):
if (n1 === n2) {
return
}
// patching & not same type, unmount old tree
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}
闸门 1:n1 === n2 直接返回(renderer.ts:394-396)。这是"自更新短路"——v-memo、缓存复用等场景可能让新旧 vnode 是同一个引用,此时无需任何操作。这一句是零成本剪枝。
闸门 2:类型不同 → 卸载旧树再当挂载(renderer.ts:398-403)。三个动作:
anchor = getNextHostNode(n1):在卸载前先拿到旧节点的"后继节点",作为新节点的插入锚点。因为卸载后旧节点的nextSibling关系就断了,必须在之前取。unmount(n1, ..., doRemove = true):把旧树整棵移除。n1 = null:关键——把n1置空后,后续所有if (n1 == null)的判断都会走"挂载"分支。这就是"替换 = 卸载 + 挂载"的实现方式,不需要单独的 replace 逻辑。
闸门 3:patchFlag === BAIL 强制退出优化(renderer.ts:405-408)。BAIL 由 cloneVNode(<component :is="vnode"/>)或 renderSlot 遇到手写插槽时打上。设 optimized=false 且清空 dynamicChildren,让本次 diff 走全量对比。
紧随其后还有一段 v-once 保护(renderer.ts:410-421):
if (n2.dynamicChildren && n1 && n1.dynamicChildren && n1.dynamicChildren.hasOnce) {
if (n2.dynamicChildren === (EMPTY_ARR as any)) {
n2.dynamicChildren = []
}
n2.dynamicChildren.hasOnce = true
}
如果旧节点所在的块被标记了 hasOnce(块内有 v-once),那么新节点也必须继承这个标记,并且把共享的空数组 EMPTY_ARR 换成可写的 [](因为要往上面挂 hasOnce 属性,不能污染全局常量)。hasOnce 的作用在 unmount 里(见 11.11):有 v-once 的块不能走"只卸载 dynamicChildren"的快路径,否则被 v-once 包裹(因而没进 dynamicChildren)的嵌套组件永远不会被卸载。
11.7.3 类型分派:switch + 位判定
闸门之后(renderer.ts:423-505):
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)
else if (__DEV__) patchStaticNode(n1, n2, container, namespace)
break
case Fragment: processFragment(/* 9 params */); break
default:
if (shapeFlag & ShapeFlags.ELEMENT) processElement(/* ... */)
else if (shapeFlag & ShapeFlags.COMPONENT) processComponent(/* ... */)
else if (shapeFlag & ShapeFlags.TELEPORT) TeleportImpl.process(/* ..., internals */)
else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) SuspenseImpl.process(/* ..., internals */)
else if (__DEV__) warn('Invalid VNode type:', type, `(${typeof type})`)
}
所有 processXxx 函数体内再判断"挂载还是更新"(因为分派只决定了"处理哪一类",不决定"怎么处理")。这是"两阶段分派":patch 负责"按类型选函数",各 process 负责"按 n1 是否为空选挂载/更新"。
11.7.4 Text 与 Comment:最简的两条路径
processText(renderer.ts:515-528):
const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => {
if (n1 == null) {
hostInsert((n2.el = hostCreateText(n2.children as string)), container, anchor)
} else {
const el = (n2.el = n1.el!)
if (n2.children !== n1.children) {
hostSetText(el, n2.children as string)
}
}
}
- 挂载:创建文本节点并插入,创建与赋值
n2.el在同一条语句里(n2.el = hostCreateText(...))。 - 更新:沿用旧节点
n2.el = n1.el,只有文本内容变了才hostSetText。注意这里比较的是children字符串本身(!==),不做任何 diff——文本节点没有孩子。
processCommentNode(renderer.ts:530-546):
const processCommentNode = (n1, n2, container, anchor) => {
if (n1 == null) {
hostInsert((n2.el = hostCreateComment((n2.children as string) || '')), container, anchor)
} else {
// there's no support for dynamic comments
n2.el = n1.el
}
}
更新时只复制 el,完全不比较内容——源码注释"there's no support for dynamic comments"。注释节点的文本被认为是静态的(即使 v-if 的占位注释也无需更新)。唯一的例外是 HMR,但那条路径走 patchFlag 重算。
两者都属于"就地更新":不重建节点,只改内容或指针。这与元素的
patchElement(可能 patch 子节点、props)形成对比。
11.7.5 Static:开发期才 patch
Static 节点的处理(renderer.ts:431-437):
- 挂载:
mountStaticNode(renderer.ts:548-564)调用hostInsertStaticContent,返回值[el, anchor]分别写入 vnode。Static是最省的一类:一段 HTML 字符串一次性变成多个真实节点,没有 vnode 树、没有子节点 diff。 - 更新:生产环境什么都不做(只有
else if (__DEV__)才patchStaticNode)。因为静态节点内容永不变化(编译器静态提升的前提),生产环境根本不需要更新逻辑——这就是为什么整段代码被__DEV__包住,生产构建会被 tree-shake。 - dev 更新:
patchStaticNode(renderer.ts:569-591)比较children字符串,不同则先removeStaticNode(n1)再重新insertStaticContent(HMR 场景)。
11.7.6 Fragment:起点与锚点
case Fragment 调用 processFragment(renderer.ts:1077-1183),细节放 11.12。这里先记两个要点:
- Fragment 的"位置"由两个文本锚点标定:
n2.el是起始锚点,n2.anchor是结束锚点(renderer.ts:1088-1089)。 - mount 时先插锚点,再把子节点插到"结束锚点之前"(
renderer.ts:1112-1130)。这样子节点被"夹"在两个锚点之间,任何时刻都能整段定位。
11.7.7 收尾:ref 的设置
分派结束后,patch 统一处理 ref(renderer.ts:507-512):
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
} else if (ref == null && n1 && n1.ref != null) {
setRef(n1.ref, null, parentSuspense, n1, true)
}
两条规则:
- 新 vnode 有 ref 且父组件存在 →
setRef(ref, oldRef, suspense, n2, !n2)。!n2表示"是否卸载"(更新时n2非空 →false)。 - 新 vnode 没有 ref 但旧 vnode 有 → 把旧 ref 置空(
setRef(n1.ref, null, ..., true)),因为 ref 被移除了。
注意 parentComponent 为 null 的情况(如根 vnode 无父组件)不设置 ref——因为 ref 需要归属到某个组件实例的 refs 里。
11.7.8 patch 的完整流程图
flowchart TD
START["patch(n1,n2,container,anchor,...)<br/>optimized = !!n2.dynamicChildren"] --> G1{"n1 === n2?"}
G1 -->|是| R["return"]
G1 -->|否| G2{"n1 && !isSameVNodeType(n1,n2)?"}
G2 -->|是| REPLACE["anchor = getNextHostNode(n1)<br/>unmount(n1,...,true)<br/>n1 = null"]
G2 -->|否| G3{"n2.patchFlag === BAIL?"}
REPLACE --> G3
G3 -->|是| BAIL["optimized = false<br/>n2.dynamicChildren = null"]
G3 -->|否| ONCE
BAIL --> ONCE{"旧块 hasOnce?<br/>继承标记"}
ONCE --> SW{"switch n2.type"}
SW -->|Text| PT["processText"]
SW -->|Comment| PC["processCommentNode"]
SW -->|Static| PS["n1==null ? mountStaticNode : (dev) patchStaticNode"]
SW -->|Fragment| PF["processFragment"]
SW -->|default| BITS{"shapeFlag 位判定"}
BITS -->|ELEMENT| PE["processElement → mountElement / patchElement"]
BITS -->|COMPONENT| PCO["processComponent → mountComponent / updateComponent"]
BITS -->|TELEPORT| PTE["TeleportImpl.process"]
BITS -->|SUSPENSE| PSP["SuspenseImpl.process"]
PT --> REF
PC --> REF
PS --> REF
PF --> REF
PE --> REF
PCO --> REF
PTE --> REF
PSP --> REF
REF["ref 处理:setRef / 清空旧 ref"]
性能账本:一次 patch 的固定开销是 ① 两次指针比较(n1===n2、isSameVNodeType)、② 一次 switch、③ 一次位判定。这些全是 O(1) 常数操作。真正的开销在 processElement/patchElement 内部。所以"patch 主流程"本身几乎不花钱,钱都花在"节点的具体处理"上。
11.8 mountElement 逐段走读
processElement(renderer.ts:617-669)是"路由层":处理 svg/mathml 命名空间,然后 n1 == null ? mountElement : patchElement(还顺带处理自定义元素的 _beginPatch/_endPatch)。真正干活的是 mountElement(renderer.ts:672-772)。
11.8.1 第一步:创建宿主元素并写入 vnode.el
let el: RendererElement
let vnodeHook: VNodeHook | undefined | null
const { props, shapeFlag, transition, dirs } = vnode
el = vnode.el = hostCreateElement(
vnode.type as string,
namespace,
props && props.is, // is 属性(自定义内建元素)
props, // 整个 props 传给平台层(用于 <select multiple> 等特例)
)
hostCreateElement 收到 4 个参数:标签名、namespace、is、props。把 props 也传下去,是为了让平台层能处理"创建时就依赖 props 的元素",例如 <select multiple>(nodeOps.ts:66-68)。
关键:vnode.el 在此刻被赋值,且就在 hostCreateElement 之后,早于插入容器。 这意味着从这一刻起,vnode.el 已是有效的宿主元素对象,只是还不在 DOM 树里(el.parentNode === null)。
11.8.2 第二步:先挂 children,再挂 props
// mount children first, since some props may rely on child content
// being already rendered, e.g. `<select value>`
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, vnode.children as string)
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
vnode.children as VNodeArrayChildren,
el,
null, // anchor
parentComponent,
parentSuspense,
resolveChildrenNamespace(vnode, namespace),
slotScopeIds,
optimized,
)
}
子节点先于 props 挂载,源码注释给了理由:<select value> 这类元素的 value 需要在 <option> 子节点存在后才能正确生效(#2325 相关)。children 按 shapeFlag 分两条:
TEXT_CHILDREN:直接hostSetElementText(el, children)(el.textContent = text)。一个 DOM 操作搞定整段文本,比创建 Text 节点再插入更快。ARRAY_CHILDREN:mountChildren递归挂载每个子 vnode。
SLOTS_CHILDREN 不会出现在元素上——上一节说过 normalizeChildren 会把元素的插槽对象摊平成普通 children(vnode.ts:845-854)。所以 mountElement 只需处理文本与数组两种。
11.8.3 mountChildren:懒归一化与 optimized 分支
mountChildren(renderer.ts:816-843):
const mountChildren: MountChildrenFn = (children, container, anchor, parentComponent,
parentSuspense, namespace, slotScopeIds, optimized, start = 0) => {
for (let i = start; i < children.length; i++) {
const child = (children[i] = optimized
? cloneIfMounted(children[i] as VNode)
: normalizeVNode(children[i]))
patch(null, child, container, anchor, parentComponent, parentSuspense,
namespace, slotScopeIds, optimized)
}
}
两个关键点:
optimized决定归一化策略(renderer.ts:828-830):optimized=true:编译器产出的 children 必然全是 vnode,只需cloneIfMounted(防复用污染),跳过normalizeVNode的类型判断。optimized=false:手写 children 可能是字符串/数字/数组/布尔,必须走完整normalizeVNode。
children[i] = ...就地回写:归一化的结果被写回原数组,避免后续 patch 时重复归一化。start = 0参数:允许从中间开始挂载(移动/恢复场景),配合unmountChildren的start。
11.8.4 第三步:指令 created 钩子与 scopeId
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'created')
}
// scopeId
setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent)
dirs的created钩子在 props 之前调用——因为"指令可能在created里初始化内部状态,供后续逻辑使用"。setScopeId(renderer.ts:774-814)负责写 SFC 的 scoped 属性:先写自己的scopeId,再写slotScopeIds链。还有一段递归:如果这个 vnode 是"父组件子树的根",则把父 vnode 的 scopeId 也写上(renderer.ts:789-813),让组件根元素同时携带父作用域的 id(子组件根元素被父组件样式影响)。
11.8.5 第四步:挂载 props
if (props) {
for (const key in props) {
if (key !== 'value' && !isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], namespace, parentComponent)
}
}
// ... 见下
if ('value' in props) {
hostPatchProp(el, 'value', null, props.value, namespace)
}
if ((vnodeHook = props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHook, parentComponent, vnode)
}
}
三件事:
- 遍历 props 逐个 patchProp:
prevValue = null(因为是首次挂载)。跳过value和所有保留 prop(key/ref/onVnodeXXX,由isReservedProp识别)。 value单独且延后设置(renderer.ts:722-733):源码注释列了两条理由——①value有顺序敏感(必须在min/max之后设置,#2325、#4024);② 它需要强制设置(#1471)。所以它被从循环里拿出来,在其它 prop 之后再设。onVnodeBeforeMount钩子:由 props 携带的 vnode 生命周期钩子(不是组件钩子),在此触发。
11.8.6 第五步:devtools 标记、beforeMount 指令、transition、插入、mounted
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
def(el, '__vnode', vnode, true)
def(el, '__vueParentComponent', parentComponent, true)
}
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
}
const needCallTransitionHooks = needTransition(parentSuspense, transition)
if (needCallTransitionHooks) {
transition!.beforeEnter(el)
}
hostInsert(el, container, anchor)
if ((vnodeHook = props && props.onVnodeMounted) || needCallTransitionHooks || dirs) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
needCallTransitionHooks && transition!.enter(el)
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
}, parentSuspense)
}
顺序要点:
- devtools 挂
__vnode/__vueParentComponent:把 vnode 与父组件实例直接挂在 DOM 元素上,供 devtools 检查。用def(..., true)(不可枚举),避免污染for...in与序列化。 beforeMount指令钩子:在插入前调用。transition.beforeEnter:在插入前调用(此时元素已存在但尚未进 DOM,适合设置起始样式)。hostInsert(el, container, anchor):真正的插入。onVnodeMounted/transition.enter/mounted指令钩子:全部放进queuePostRenderEffect异步派发(在本轮渲染 flush 的"后置"阶段执行),保证在整棵树都插入完成后才调用。这就是为什么onMounted、ref在回调里一定可用。
needTransition(parentSuspense, transition) 是个易忽略的细节:它保证"在 Suspense 未 resolve 时进入的元素,其 enter 钩子要等到 Suspense resolve 后再触发"(源码 #1583/#1689 注释,renderer.ts:747-748)。
11.8.7 mountElement 的完整时序图
sequenceDiagram
participant P as patch
participant M as mountElement
participant H as host (nodeOps)
participant Q as postRenderEffect 队列
P->>M: mountElement(vnode, container, anchor, ...)
M->>H: hostCreateElement(type, ns, is, props)
H-->>M: el (vnode.el = el)
M->>M: shapeFlag 分派 children
alt TEXT_CHILDREN
M->>H: hostSetElementText(el, children)
else ARRAY_CHILDREN
M->>M: mountChildren(递归 patch)
end
M->>M: invokeDirectiveHook('created')
M->>H: setScopeId(el, scopeId, slotScopeIds)
M->>H: for key in props: hostPatchProp(el, key, null, val)
M->>H: hostPatchProp(el, 'value', null, value)
M->>M: invokeVNodeHook(onVnodeBeforeMount)
M->>M: invokeDirectiveHook('beforeMount')
M->>H: transition.beforeEnter(el)(可选)
M->>H: hostInsert(el, container, anchor)
M->>Q: queuePostRenderEffect(...)
Note over Q: flush 后置阶段执行<br/>onVnodeMounted / transition.enter / 'mounted'
性能账本:mountElement 的主要成本是 ① 一次 hostCreateElement、② children 的递归挂载、③ 每个 props 一次 hostPatchProp、④ 一次 hostInsert。其中 hostPatchProp 的条数 = props 键数(去掉 key/ref/value),无优化的全量遍历——挂载时没有 diff 的必要,因为旧值恒为 null。
11.9 patchElement 逐段走读:patchFlag 驱动的优化
patchElement(renderer.ts:845-990)是"元素更新"的全部逻辑,也是"优化模式"最集中的地方。逐段走读。
11.9.1 开头:沿用旧 el、修正 patchFlag
const el = (n2.el = n1.el!)
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
el.__vnode = n2
}
let { patchFlag, dynamicChildren, dirs } = n2
// #1426 take the old vnode's patch flag into account since user may clone a
// compiler-generated vnode, which de-opts to FULL_PROPS
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
三段:
n2.el = n1.el:更新不重建元素,沿用旧 DOM。这句是"就地更新"的根本。- devtools 更新
el.__vnode:让 devtools 指向最新 vnode。 patchFlag |= n1.patchFlag & FULL_PROPS(renderer.ts:858-861):只要旧 vnode 有FULL_PROPS,新 vnode 也必须全量 props diff。源码注释解释:用户可能克隆了一个编译器产物(cloneVNode会把 patchFlag 降级或上FULL_PROPS,见 11.5.3),克隆体的 FULL_PROPS 不能丢。这里用位或"传染"给新 vnode。
11.9.2 更新前的钩子与"暂停递归"
parentComponent && toggleRecurse(parentComponent, false)
if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
}
if (dirs) {
invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
}
parentComponent && toggleRecurse(parentComponent, true)
toggleRecurse(parentComponent, false) 在调用钩子前临时停止父组件的递归更新(updateJob 的递归标志),避免 beforeUpdate 里改状态导致无限递归;调用后再恢复。这是"钩子里改状态"的安全阀。
11.9.3 何时强制退出优化模式
if (
(__DEV__ && isHmrUpdating) ||
(dynamicChildren &&
(!n1.dynamicChildren ||
n1.dynamicChildren.length !== dynamicChildren.length))
) {
patchFlag = 0
optimized = false
dynamicChildren = null
}
两个条件触发"降级为全量 diff":
- HMR 更新(dev)。
- Block 元数据不稳定(
#6385):新 vnode 有dynamicChildren,但旧 vnode 没有、或两者长度不一致。源码注释说"旧 vnode 可能是用户包裹过的非同构 Block"。一旦长度对不上,快路径的"按下标一一对应"假设就失效,必须回退全量 diff。
降级动作三连:patchFlag = 0、optimized = false、dynamicChildren = null——三个变量全部归零,后续所有 if (patchFlag > 0) / if (dynamicChildren) 分支都会失效,自然走全量。
11.9.4 innerHTML / textContent 被清空的特殊处理
if (
(oldProps.innerHTML && newProps.innerHTML == null) ||
(oldProps.textContent && newProps.textContent == null)
) {
hostSetElementText(el, '')
}
#9135:如果旧节点用了 innerHTML/textContent 而现在要移除这个属性,必须先清空元素内容,否则可能在挂载新 children 时出现"旧内容残留"的错误。注意这里是"旧有、新无",即属性被移除的场景。
11.9.5 children 的两条路径:Block 快路径 vs 全量
if (dynamicChildren) {
patchBlockChildren(
n1.dynamicChildren!,
dynamicChildren,
el,
parentComponent,
parentSuspense,
resolveChildrenNamespace(n2, namespace),
slotScopeIds,
)
if (__DEV__) {
// necessary for HMR
traverseStaticChildren(n1, n2)
}
} else if (!optimized) {
// full diff
patchChildren(n1, n2, el, null, parentComponent, parentSuspense,
resolveChildrenNamespace(n2, namespace), slotScopeIds, false)
}
这一段是 Block 优化的核心入口:
- 有
dynamicChildren→ 走patchBlockChildren(renderer.ts:993-1037),只 patch 那些被编译器标记为"会变"的子节点,静态子节点整片跳过。这是 Block 树优化的收益来源。 - 没有
dynamicChildren且!optimized→ 走patchChildren全量 diff(第 12 章主题)。 - 没有
dynamicChildren但optimized=true→ 什么都不做!因为"优化模式下没有 dynamicChildren"意味着"这棵子树没有任何动态节点",纯静态,无需更新。
为什么 patchBlockChildren 能只 patch 动态节点? 因为它依赖一个强假设:新旧 vnode 的 dynamicChildren 长度相同、下标一一对应(同位置的动态节点类型稳定)。这个假设的前提正是"结构不变"——而 Block 的定义就是"结构稳定的子树"(v-once/v-if/v-for 才会破坏结构,所以它们各自开新的 Block)。若长度不符(如上面的元数据不稳定检查),就回退全量。
patchBlockChildren 的另一个细节是容器计算(renderer.ts:1005-1024):
const oldEl = oldVNode.el || resolveAsyncComponentPlaceholder(oldVNode)
const container =
oldEl &&
(oldVNode.type === Fragment || !isSameVNodeType(oldVNode, newVNode) ||
oldVNode.shapeFlag & (COMPONENT | TELEPORT | SUSPENSE))
? hostParentNode(oldEl)!
: fallbackContainer // 大多数情况直接用作父元素
只有三种情况才真的去查 parentNode:① 目标是 Fragment(要拿它的真实父容器);② 新旧类型不同(即将替换);③ 是组件/Teleport/Suspense(内部可能任意嵌套)。其余情况直接复用 fallbackContainer(当前元素),省掉一次 parentNode 调用。这个微优化在 diff 热路径上很值。
11.9.6 props 的 patchFlag 分支:FULL_PROPS / CLASS / STYLE / PROPS / TEXT
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
patchProps(el, oldProps, newProps, parentComponent, 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) {
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) {
// unoptimized, full diff
patchProps(el, oldProps, newProps, parentComponent, namespace)
}
这是一张"按 patchFlag 位选路径"的决策表:
| patchFlag 位 | 分支动作 | 关键优化 |
|---|---|---|
FULL_PROPS | patchProps 全量 props diff | 动态 key 的唯一正确路径 |
CLASS | 比较 oldProps.class !== newProps.class,不等才 patch | 先比较再 patch,避免重复写 DOM |
STYLE | 直接 hostPatchProp(el, 'style', old, new) | 交给 patchProp 内部做 style diff |
PROPS | 只遍历 n2.dynamicProps | 只碰会变的键,跳过静态 prop |
TEXT | 比较 children 字串,不等才 hostSetElementText | 文本子节点的快速更新 |
无 flag 且 !optimized | patchProps 全量 | 手写渲染函数的兜底 |
几个精妙点:
CLASS分支先比较再 patch(renderer.ts:939-943):class变化常伴随其它更新,先比较能避免无谓的 DOM 写入。PROPS分支的next !== prev || key === 'value'(renderer.ts:965):value是"无论是否变化都强制 patch"(#1471,表单元素value需要强制同步)。其它 prop 只在值变时 patch。PROPS与FULL_PROPS的互斥:PROPS假设"知道哪些键会变",FULL_PROPS是"键可能增删"——后者必须遍历 keys 找新增/删除,前者不必。所以二者互斥(patchFlags.ts:52-57注释明确写了)。TEXT标志可以和非 props 标志共存:一个元素可能同时有动态文本和动态 class,此时两个位都置上,两个分支都跑。
11.9.7 为什么"没有 patchFlag 时要走全量 diff"(optimized 标志)
末尾那句(renderer.ts:979-982):
} else if (!optimized && dynamicChildren == null) {
// unoptimized, full diff
patchProps(el, oldProps, newProps, parentComponent, namespace)
}
为什么需要 !optimized? 想象一个优化模式下的节点:optimized=true、patchFlag=0、dynamicChildren=null。这表示"这是纯静态节点,编译器明确告诉运行时它不会变"。此时绝不能跑 patchProps——那会白白比较所有 props。所以必须加 !optimized 守卫。
反过来,手写渲染函数产物 optimized=false、patchFlag=0、dynamicChildren=null:编译器没给任何提示,运行时只能老老实实全量 patchProps。这就是"没有 patchFlag 就全量 diff"的实质:是对"信息缺失"的兜底。
optimized 变量的完整语义可以总结成一句:"本次 diff 是否可以信任编译器给的优化标记"。 它为 true 时,运行时按 patchFlag/dynamicChildren 精确打击;为 false 时,一切回退到全量。
11.9.8 更新后的钩子
if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
}, parentSuspense)
}
同样通过 queuePostRenderEffect 异步派发。至此 patchElement 结束。
11.9.9 patchElement 决策流程图
flowchart TD
PS["patchElement(n1, n2, ...)"] --> E["el = n2.el = n1.el<br/>patchFlag |= n1.patchFlag & FULL_PROPS"]
E --> HOOK["onVnodeBeforeUpdate / 指令 beforeUpdate"]
HOOK --> DEG{"HMR 或<br/>dynamicChildren 不稳定?"}
DEG -->|是| ZERO["patchFlag=0; optimized=false; dynamicChildren=null"]
DEG -->|否| HTML
ZERO --> HTML{"innerHTML/textContent 被移除?"}
HTML -->|是| CLR["hostSetElementText(el, '')"]
HTML -->|否| KIDS
CLR --> KIDS{"有 dynamicChildren?"}
KIDS -->|是| PBC["patchBlockChildren<br/>(只 patch 动态子节点)"]
KIDS -->|否| OPT{"!optimized?"}
OPT -->|是| PCH["patchChildren 全量 diff"]
OPT -->|否| SKIP["跳过(纯静态)"]
PBC --> PF{"patchFlag > 0?"}
PCH --> PF
SKIP --> PF
PF -->|是| FB{"FULL_PROPS?"}
FB -->|是| FULLP["patchProps 全量"]
FB -->|否| CS["CLASS / STYLE / PROPS 按位<br/>+ TEXT"]
PF -->|否| LAST{"!optimized && dynamicChildren==null?"}
LAST -->|是| FULLP2["patchProps 全量"]
LAST -->|否| DONE
FULLP --> TXT
CS --> TXT["TEXT 分支 / 收尾"]
FULLP2 --> TXT
TXT --> UPD["onVnodeUpdated / 指令 updated (post)"]
11.10 props 的更新机制
11.10.1 mountProps:挂载时"全量置入"
挂载阶段没有独立的 mountProps 函数——它内联在 mountElement 里(renderer.ts:716-737,见 11.8.5)。逻辑是:
for (const key in props) {
if (key !== 'value' && !isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], namespace, parentComponent)
}
}
if ('value' in props) {
hostPatchProp(el, 'value', null, props.value, namespace)
}
"全量置入" 的依据很直白:旧值恒为 null,patchProp 拿到 prevValue=null 就知道是"新增属性",无需 diff 决策。唯一特殊处理是 value 延后 + 保留 prop 过滤。
11.10.2 patchProps:更新时的"双向 diff"
patchProps(renderer.ts:1039-1075)是元素的"props 全量 diff":
const patchProps = (el, oldProps, newProps, parentComponent, namespace) => {
if (oldProps !== newProps) {
if (oldProps !== EMPTY_OBJ) {
for (const key in oldProps) {
if (!isReservedProp(key) && !(key in newProps)) {
hostPatchProp(el, key, oldProps[key], null, namespace, parentComponent)
}
}
}
for (const key in newProps) {
if (isReservedProp(key)) continue
const next = newProps[key]
const prev = oldProps[key]
if (next !== prev && key !== 'value') {
hostPatchProp(el, key, prev, next, namespace, parentComponent)
}
}
if ('value' in newProps) {
hostPatchProp(el, 'value', oldProps.value, newProps.value, namespace)
}
}
}
三个循环构成双向 diff:
oldProps !== newProps短路:如果新旧 props 是同一个对象引用(常见于未改动的静态 props),直接跳过。这是第一层剪枝。- 删除循环(遍历
oldProps):!isReservedProp(key) && !(key in newProps)→ 这个键在新 props 里没了,用nextValue=null通知patchProp移除属性。这是"只遍历 newProps"做不到的事——必须遍历旧对象才能发现"被删掉的键"。 - 更新循环(遍历
newProps):只 patchnext !== prev的键(value延后)。 value单独处理:从旧 props 取prev,从新 props 取next,强制 patch。
EMPTY_OBJ 的特殊判断(renderer.ts:1047):EMPTY_OBJ 是一个全局共享的空对象常量(renderer.ts:862-863 里 n1.props || EMPTY_OBJ)。若旧值就是 EMPTY_OBJ,说明根本没有旧 props,跳过删除循环。这避免了无谓遍历一个空对象。
11.10.3 patchDOMProps 与 key 的 diff 策略
这里的"key"不是 vnode 的 key,而是 props 的键。patchElement 的 PROPS 分支里(renderer.ts:957-969):
if (patchFlag & PatchFlags.PROPS) {
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)
}
}
}
这就是 dynamicProps 的"索引优化":不改遍历 newProps(可能有几十个静态键),而是遍历编译器给出的"会变的键"数组。源码注释(renderer.ts:951-956)点出:
"This flag is matched when the element has dynamic prop/attr bindings other than class and style. The keys of dynamic prop/attrs are saved for faster iteration. Note dynamic keys like
:[foo]="bar"will cause this optimization to bail out and go through a full diff because we need to unset the old key."
即:普通动态绑定走 PROPS + dynamicProps;动态键名 :[foo] 走 FULL_PROPS(因为键名本身会变,必须能处理"旧键需要删除")。
多次 hostPatchProp 之间的 value 比较:patchProps 与 PROPS 分支都用 next !== prev 作为"是否真的 patch"的门槛,唯一例外是 value。这是"值级别"的比较——浅比较(!==),不做深比较。这意味着对 style="{}" 这类每次渲染新建的对象,!== 恒真,会每次都 patch;这也是为什么编译器会把静态 style 提升成常量(patchFlags.ts:31-40 注释里解释 STYLE 的静态提升策略)。
关于
patchProp内部对不同属性类型(attr/domProp/class/style/event)的处理,是runtime-dom/src/patchProp.ts的主题,属第 17 章。
11.10.4 props 更新路径总表
| 场景 | 走哪条路径 | 何时触发 |
|---|---|---|
| 挂载 | mountElement 内联循环,prevValue=null | 首次创建元素 |
| 更新:无 patchFlag(手写渲染函数) | patchProps 双向 diff | !optimized && dynamicChildren==null |
更新:FULL_PROPS | patchProps 双向 diff | 动态键名或克隆降级 |
更新:CLASS | 比较 class 字符串,不等才 patch | 有 :class 动态绑定 |
更新:STYLE | 直接 patch style | 有 :style 动态绑定 |
更新:PROPS | 只遍历 dynamicProps | 有普通动态绑定 |
更新:TEXT | 比较 children 字串 | 只有动态文本 |
性能账本:在"优化模式 + 精确 patchFlag"下,一次元素更新的 props 处理与静态 prop 数量无关——只与 dynamicProps.length 成正比。这正是 Vue 3 相对 Vue 2"每次全量 diff props"的最大结构性收益。
11.11 unmount 与节点移除
11.11.1 unmount 的完整签名与前置处理
unmount(renderer.ts:2170-2301):
const unmount: UnmountFn = (
vnode,
parentComponent,
parentSuspense,
doRemove = false, // 是否真的从 DOM 摘掉(false = 只清理逻辑,不摘节点)
optimized = false,
) => {
const { type, props, ref, children, dynamicChildren, shapeFlag,
patchFlag, dirs, cacheIndex, memo } = vnode
if (patchFlag === PatchFlags.BAIL || (dynamicChildren && dynamicChildren.hasOnce)) {
optimized = false
}
...
doRemove 参数是理解 unmount 的关键:它区分两种"卸载"——
doRemove = true:逻辑清理 + 从 DOM 移除节点(真正的卸载)。doRemove = false:只做逻辑清理(解绑 ref、调用钩子、停止组件 scope),不摘 DOM。这在"移动节点"(move会把 DOM 搬到新位置)或"KeepAlive 换出"等场景使用。
开头的两处 optimized 降级(renderer.ts:2191-2196)与 patch 里一致:BAIL 或有 v-once 的块都不能走"只卸载 dynamicChildren"的快路径。
11.11.2 前置:解绑 ref、清理 memo 缓存、KeepAlive 提前返回
if (ref != null) {
pauseTracking()
setRef(ref, null, parentSuspense, vnode, true)
resetTracking()
}
if (cacheIndex != null && (!vnode.ctx || vnode.ctx === parentComponent)) {
parentComponent!.renderCache[cacheIndex] = undefined
}
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
return
}
三段:
- 解绑 ref(
renderer.ts:2199-2203):pauseTracking()临时停止依赖收集(避免解绑过程误触发副作用),setRef(ref, null, ..., true)把 ref 设为null,再resetTracking()。 - 清理 v-memo 缓存(
renderer.ts:2207-2209):#6593,若 vnode 是用renderCache缓存的(cacheIndex != null)则清空对应槽位。归属判断!vnode.ctx || vnode.ctx === parentComponent是关键:只有缓存的"作者"是自己时才清(见 11.2.5 的ctx说明)。 - KeepAlive 提前返回(
renderer.ts:2211-2214):若这个组件带着COMPONENT_SHOULD_KEEP_ALIVE(应被缓存而非真卸载),调用deactivate并直接 return,不执行后续的 real unmount。这就是<KeepAlive>的"失活"路径。对应地,patch时若带COMPONENT_KEPT_ALIVE则走activate(renderer.ts:1198-1205)。这两个位就是 KeepAlive 的"状态开关"。
11.11.3 钩子与组件分支
const shouldInvokeDirs = shapeFlag & ShapeFlags.ELEMENT && dirs
const shouldInvokeVnodeHook = !isAsyncWrapper(vnode)
let vnodeHook: VNodeHook | undefined | null
if (shouldInvokeVnodeHook && (vnodeHook = props && props.onVnodeBeforeUnmount)) {
invokeVNodeHook(vnodeHook, parentComponent, vnode)
}
if (shapeFlag & ShapeFlags.COMPONENT) {
unmountComponent(vnode.component!, parentSuspense, doRemove)
} else {
...
}
shouldInvokeDirs:只有元素上的指令才走beforeUnmount/unmounted钩子(组件的指令在renderComponentRoot里已继承到根元素,卸载时随根元素触发)。shouldInvokeVnodeHook = !isAsyncWrapper(vnode):异步组件包装器不重复触发 vnode 钩子(其"真实"子树会触发)。- 组件分支:
shapeFlag & COMPONENT→unmountComponent(vnode.component!, ...)。组件卸载的细节(bum/um钩子、scope.stop()、job.flags |= DISPOSED)在 11.11.6。
11.11.4 非组件分支:Suspense / Teleport / children 递归
if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
vnode.suspense!.unmount(parentSuspense, doRemove)
return
}
if (shouldInvokeDirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
}
if (shapeFlag & ShapeFlags.TELEPORT) {
;(vnode.type as typeof TeleportImpl).remove(vnode, parentComponent, parentSuspense, internals, doRemove)
} else if (
dynamicChildren &&
!dynamicChildren.hasOnce &&
(type !== Fragment || (patchFlag > 0 && patchFlag & PatchFlags.STABLE_FRAGMENT))
) {
// fast path for block nodes: only need to unmount dynamic children.
unmountChildren(dynamicChildren, parentComponent, parentSuspense, false, true)
} else if (
(type === Fragment && patchFlag & (PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) ||
(!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN)
) {
unmountChildren(children as VNode[], parentComponent, parentSuspense)
}
if (doRemove) {
remove(vnode)
}
三种 children 卸载路径,按优先级:
- Suspense:
vnode.suspense.unmount(...)后 return(Suspense 有自己的卸载逻辑,处理 content/fallback 分支)。 - Teleport:
TeleportImpl.remove(...)(要处理"内容在别的容器里"的移除)。 - Block 快路径(
dynamicChildren存在):只卸载dynamicChildren里的动态子节点(unmountChildren(dynamicChildren, ..., optimized=true))。这与patchBlockChildren对称——Block 子树里静态部分无需处理。三个条件是:① 有dynamicChildren;② 没有hasOnce(有 v-once 的块不能走快路径,否则嵌套组件漏卸);③ 不是"非 stable 的 Fragment"(#1153:v-for 产生的 keyed/unkeyed Fragment 必须走全量,因为其结构会变)。 - Fragment 且 keyed/unkeyed:
unmountChildren(children)全量卸载(v-for 场景)。 - 普通且未优化但有数组子节点:
unmountChildren(children)。
最后 if (doRemove) remove(vnode):真正从 DOM 摘掉这个 vnode(或其代表的子树)。
11.11.5 remove:节点 / Fragment / Static 的差异化移除
remove(renderer.ts:2303-2353)处理"如何从一个 vnode 摘掉其 DOM":
const remove: RemoveFn = vnode => {
const { type, el, anchor, transition } = vnode
const isFragment = type === Fragment
if (isFragment && !transition) {
removeFragment(el!, anchor!)
return
}
if (type === Static) {
removeStaticNode(vnode)
if (transition && !transition.persisted && transition.afterLeave) transition.afterLeave()
return
}
const leavingNode = isFragment ? getTransitionRoot(vnode) : vnode
const performRemove = () => {
if (isFragment) {
if (hostParentNode(el!)) removeFragment(el!, anchor!)
} else {
hostRemove(el!)
}
if (transition && !transition.persisted && transition.afterLeave) transition.afterLeave()
}
if (leavingNode && leavingNode.shapeFlag & ShapeFlags.ELEMENT && transition && !transition.persisted) {
const { leave, delayLeave } = transition
const leavingEl = leavingNode.el!
const performLeave = () => leave(leavingEl, performRemove)
if (delayLeave) delayLeave(leavingEl, performRemove, performLeave)
else performLeave()
} else {
performRemove()
}
}
四条差异化路径:
- Fragment 且无 transition →
removeFragment(el, anchor):从起始锚点一路删到结束锚点(renderer.ts:2355-2365的while (cur !== end))。 - Static →
removeStaticNode(vnode):while (el && el !== anchor)逐个删(renderer.ts:607-615),然后删 anchor。若有 transition 且未持久化,调用afterLeave释放等待(源码注释解释:不透明的水合占位不能动画,但必须释放 out-in 过渡)。 - 有 transition 且是元素 → 走离开动画:
leave(leavingEl, performRemove),DOM 的移除推迟到动画结束的performRemove回调。delayLeave若存在则先调用它决定时机。 - 其它 → 直接
performRemove()(hostRemove(el))。
getTransitionRoot(vnode):Fragment 想做过渡时,需要找到它内部的"单个根元素"(Fragment 本身没有 el,只有锚点)。
11.11.6 unmountComponent:组件卸载的三段
unmountComponent(renderer.ts:2367-2428):
const unmountComponent = (instance, parentSuspense, doRemove?) => {
if (__DEV__ && instance.type.__hmrId) unregisterHMR(instance)
const { bum, scope, job, subTree, um, m, a } = instance
invalidateMount(m)
invalidateMount(a)
if (bum) invokeArrayFns(bum) // beforeUnmount 钩子
if (__COMPAT__ && ...) instance.emit('hook:beforeDestroy')
scope.stop() // 停止该组件的 effect 作用域
if (job) {
job.flags! |= SchedulerJobFlags.DISPOSED // 标记调度任务已废弃
unmount(subTree, instance, parentSuspense, doRemove) // 递归卸载子树
} else if (instance.vnode.el && subTree) {
subTree.transition = instance.vnode.transition
unmount(subTree, instance, parentSuspense, doRemove)
}
if (um) queuePostRenderEffect(um, parentSuspense) // unmounted 钩子(异步)
if (__COMPAT__ && ...) queuePostRenderEffect(() => instance.emit('hook:destroyed'), parentSuspense)
queuePostRenderEffect(() => { instance.isUnmounted = true }, parentSuspense)
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) devtoolsComponentRemoved(instance)
}
要点:
scope.stop():停止组件的effectScope,把所有computed/watch/effect一起停掉。这是"组件卸载时响应式副作用不泄漏"的根本保证。job.flags |= DISPOSED:把该组件的更新任务标记为"已废弃",防止调度器在卸载后还调用它(#源码注释:"so that scheduler will no longer invoke it")。job可能为 null:源码注释说明——组件在 asyncsetup()完成前就被卸载时job为 null。此时走else if分支,用instance.vnode.el判断是否处于 hydration 中(vnode.el在 hydration 早期就有值),并把根 transition 传给 subTree 再卸载占位。um(unmounted)与isUnmounted都走queuePostRenderEffect:异步派发,保证在 DOM 真正移除后触发。
11.11.7 unmountChildren 与 getNextHostNode
unmountChildren(renderer.ts:2430-2441)是一个简单循环:
const unmountChildren: UnmountChildrenFn = (children, parentComponent, parentSuspense,
doRemove = false, optimized = false, start = 0) => {
for (let i = start; i < children.length; i++) {
unmount(children[i], parentComponent, parentSuspense, doRemove, optimized)
}
}
start 参数允许从中间开始(配合 patch 时的部分卸载)。
getNextHostNode(renderer.ts:2443-2456)用于patch 替换时取"锚点":
const getNextHostNode: NextFn = vnode => {
if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
return getNextHostNode(vnode.component!.subTree)
}
if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
return vnode.suspense!.next()
}
const el = hostNextSibling((vnode.anchor || vnode.el)!)
// #9071, #9313 teleported content 会干扰 patch 期间的 nextSibling 搜索,需跳过
const teleportEnd = el && el[TeleportEndKey]
return teleportEnd ? hostNextSibling(teleportEnd) : el
}
- 组件:递归到
subTree(组件 vnode 的el是借用根节点的,nextSibling要从根节点算)。 - Suspense:
suspense.next()。 - 普通节点:从
anchor || el取nextSibling。Teleport 特判(#9071/#9313):Teleport 内容被搬到别处,其"下一个兄弟"在父容器里可能指向错误的节点,所以要跳过TeleportEndKey标记的结束节点。
11.11.8 unmount 决策流程图
flowchart TD
UM["unmount(vnode, ..., doRemove, optimized)"] --> B["BAIL / hasOnce → optimized=false"]
B --> REF["ref 解绑"]
REF --> MEMO["清 v-memo 缓存"]
MEMO --> KA{"COMPONENT_SHOULD_KEEP_ALIVE?"}
KA -->|是| DEACT["KeepAlive.deactivate → return"]
KA -->|否| HOOK["onVnodeBeforeUnmount"]
HOOK --> COMP{"shapeFlag & COMPONENT?"}
COMP -->|是| UMC["unmountComponent → 递归 unmount(subTree)"]
COMP -->|否| SUS{"SUSPENSE?"}
SUS -->|是| SUSU["suspense.unmount → return"]
SUS -->|否| DIRS["指令 beforeUnmount"]
DIRS --> TP{"TELEPORT?"}
TP -->|是| TPR["TeleportImpl.remove"]
TP -->|否| BLK{"dynamicChildren && !hasOnce && 非非stableFragment?"}
BLK -->|是| UDC["unmountChildren(dynamicChildren, optimized=true)"]
BLK -->|否| FRAG{"Fragment(keyed/unkeyed) 或 未优化且 ARRAY_CHILDREN?"}
FRAG -->|是| UDC2["unmountChildren(children)"]
FRAG -->|否| RM
UDC --> RM{"doRemove?"}
UDC2 --> RM
TPR --> RM
RM -->|是| REMOVE["remove(vnode)"]
RM -->|否| DONE["跳过节点的 DOM 移除"]
11.12 Fragment 的 mount / patch:anchor 机制
11.12.1 为什么 Fragment 需要两个锚点
一个 Fragment 代表"一组平铺的兄弟节点"。问题是:DOM 只有父子层级,没有"节点组"。当你要:
- 把整组节点移到别处;
- 把整组节点整体删除;
- 在组内某位置插入新节点;
- 判断这组节点在哪里结束。
都需要"这一组的边界"。Vue 的解法是在组的两端各插入一个空的文本节点作为锚点:
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!
(renderer.ts:1088-1089)
- 起始锚点 =
n2.el(复用了el字段!这是 Fragment 的特殊约定)。 - 结束锚点 =
n2.anchor。
为什么起始锚点复用 el 字段? 因为运行时的很多通用逻辑都假设"vnode.el 是这个 vnode 的代表节点"(如 getNextHostNode 用 vnode.anchor || vnode.el 算 nextSibling)。让 Fragment 的 el 指向起始锚点,就能让这些通用逻辑"免费"支持 Fragment,而不必到处加 if (isFragment)。
为什么用文本节点而不是注释节点? 文本节点在某些场景下更轻量(注释节点在某些渲染器里可能被特殊处理),且空文本节点不出现在视觉上。实际上 Vue 历史上两种都用过,3.5 用的是空文本(hostCreateText(''))。
11.12.2 mount 阶段:先插锚点,子节点插到"结束锚点之前"
if (n1 == null) {
hostInsert(fragmentStartAnchor, container, anchor)
hostInsert(fragmentEndAnchor, container, anchor)
mountChildren(
(n2.children || []) as VNodeArrayChildren,
container,
fragmentEndAnchor, // ← 锚点 = 结束锚点
parentComponent, parentSuspense, namespace, slotScopeIds, optimized,
)
}
(renderer.ts:1111-1130)
时序:
- 把起始锚点插到
container的anchor之前。 - 把结束锚点插到
container的anchor之前(此时结束锚点在起始锚点之后,因为后插的在同一位置前插,会落在起始锚点之后)。 mountChildren的 anchor 参数是"结束锚点"——于是每个子节点都被插在"结束锚点之前",即被夹在两个锚点之间。
注意 (n2.children || [])(renderer.ts:1118-1122):源码注释 #10007 解释——像 <></> 这种虚空 Fragment 会被编译成"没有 children 的 Fragment",此时用空数组兜底,避免 undefined.length 报错。这是 3.5 新增的边界处理。
11.12.3 patch 阶段:三条路径
} else {
if (patchFlag > 0 && patchFlag & PatchFlags.STABLE_FRAGMENT &&
dynamicChildren && n1.dynamicChildren &&
n1.dynamicChildren.length === dynamicChildren.length) {
// stable fragment:不需要 patch children 顺序,但可能有 dynamicChildren
patchBlockChildren(n1.dynamicChildren, dynamicChildren, container, ...)
...
} else {
// keyed / unkeyed / 手写 fragment
patchChildren(n1, n2, container, fragmentEndAnchor, ...)
}
}
(renderer.ts:1131-1182)
两条主路径:
STABLE_FRAGMENT+ dynamicChildren 且长度匹配(renderer.ts:1132-1164):- 含义:这是一个"子节点顺序不变"的 Fragment(模板根或
<template v-for>),可以直接patchBlockChildren——按下标对应 patch 动态子节点,完全不考虑顺序。 - 之后有一段
traverseStaticChildren(dev,为了 HMR)或(生产)traverseStaticChildren(n1, n2, true)的浅层调用(#2080/#2134:若片段有 key 或它是组件根,需要把el引到新的子 vnode 上)。
- 含义:这是一个"子节点顺序不变"的 Fragment(模板根或
- 其它(keyed / unkeyed / 手写 Fragment) →
patchChildren(n1, n2, container, fragmentEndAnchor, ...)全量 diff(第 12 章)。
为什么 stable 才能走快路径? 因为 patchBlockChildren 是"旧第 i 个 ↔ 新第 i 个"的对应。若子节点顺序会变(v-for 重排),这个对应关系就错了,必须走能处理"移动"的 patchChildren。"stable"这个词就是"顺序不变"的意思。
11.12.4 Fragment 的 key 与 slotScopeIds
-
Fragment 的 key:在
isSameVNodeType里,Fragment 也参与key比较。v-for生成的 Fragment 通常不带 key(key 在内部的每个子节点上),但<template v-for :key>会。Fragment 的 key 决定"是不是同一个 Fragment 实例",进而决定走 patch 还是重建。 -
Fragment 的
slotScopeIds(renderer.ts:1104-1109):if (fragmentSlotScopeIds) { slotScopeIds = slotScopeIds ? slotScopeIds.concat(fragmentSlotScopeIds) : fragmentSlotScopeIds }当 Fragment 由插槽渲染产生且带
:slotted样式时,其slotScopeIds会拼接到外层的 slotScopeIds 链上,让 Fragment 里的所有元素都带上这些作用域 id。
11.12.5 Fragment 与"多根模板"
一个组件模板如果有多于一个根元素:
<template>
<header>...</header>
<main>...</main>
</template>
编译器会产出一个 Fragment vnode 包裹这两个元素。这就是 renderComponentRoot 里 normalizeVNode 处理数组的情形(componentRenderUtils.ts:102-112)。此时 inheritAttrs 无法自动落到某个单根,运行时会在 dev 给出"Extraneous non-props attributes... could not be automatically inherited because component renders fragment"的告警(componentRenderUtils.ts:180-215)。这也解释了为什么"多根组件的属性透传"是个经典坑。
11.12.6 Fragment 锚点的移动与删除
- 移动:
move对 Fragment 会把起始锚点、所有子节点、结束锚点依次搬到新位置(配合moveStaticNode类似的循环逻辑)。 - 删除:
removeFragment(el, anchor)(renderer.ts:2355-2365)从起始锚点逐个hostRemove直到遇到结束锚点,最后删掉结束锚点。注意它删的是"锚点之间的所有节点",包括锚点本身。
为什么删除要逐个删而不是清空容器? 因为 Fragment 可能只是某个大容器里的一段,不能用 innerHTML = '' 这种"清空整个容器"的方式。
11.13 实战:打印 vnode 与验证 el 时机
11.13.1 手写渲染函数产物:shapeFlag 与 patchFlag 解读
下面用运行时 API 构造一个 vnode 并逐字段解读(可在浏览器控制台或 Node 里跑):
import { h, Fragment, Text, Comment, createVNode, ShapeFlags } from 'vue'
// 1) 元素 + 数组 children,手写渲染函数
const v1 = h('div', { id: 'app', class: 'box' }, [h('span', 'hi')])
// 2) 元素 + 文本 children
const v2 = h('p', 'text')
// 3) 片段(多根)
const v3 = h(Fragment, [h('a', 'x'), h('b', 'y')])
// 4) 文本 / 注释
const v4 = createVNode(Text, null, 'hello')
const v5 = createVNode(Comment)
const dump = (v, name) => console.log(name, {
type: typeof v.type === 'symbol' ? v.type.description : v.type,
key: v.key,
shapeFlag: v.shapeFlag,
isElement: !!(v.shapeFlag & ShapeFlags.ELEMENT),
isComponent: !!(v.shapeFlag & ShapeFlags.COMPONENT),
isTextChildren: !!(v.shapeFlag & ShapeFlags.TEXT_CHILDREN),
isArrayChildren: !!(v.shapeFlag & ShapeFlags.ARRAY_CHILDREN),
isSlotsChildren: !!(v.shapeFlag & ShapeFlags.SLOTS_CHILDREN),
patchFlag: v.patchFlag,
dynamicChildren: v.dynamicChildren,
el: v.el,
})
dump(v1, 'v1'); dump(v2, 'v2'); dump(v3, 'v3'); dump(v4, 'v4'); dump(v5, 'v5')
预期的关键结果:
| 变量 | type | shapeFlag | 命中位 | 解读 |
|---|---|---|---|---|
v1 | 'div' | 17 | ELEMENT | ARRAY_CHILDREN | 元素,children 是 vnode 数组 |
v2 | 'p' | 9 | ELEMENT | TEXT_CHILDREN | 元素,children 是字符串 |
v3 | 'v-fgt' | 16 | ARRAY_CHILDREN | 片段,没有 ELEMENT 位(靠 type 分派) |
v4 | 'v-txt' | 8 | TEXT_CHILDREN | 文本节点 |
v5 | 'v-cmt' | 0 | 无 | 注释节点,无 children 位 |
el 在这五个对象上全是 null——因为它们是"创建后、挂载前"的 vnode。这直接呼应了 11.2.6:el 由运行时在挂载时才写入。
patchFlag 全为 0、dynamicChildren 全为 null——因为它们由 h 创建(关 Block 追踪)。这正是 11.6.4 表格里"h 产物走全量 diff"的实证。若改用编译器产出的渲染函数,patchFlag 会出现 1(TEXT)/8(PROPS)/16(FULL_PROPS) 等值,dynamicChildren 会是非空数组。
type 的打印:四个内置符号用 Symbol.for 创建,description 分别是 'v-fgt'、'v-txt'、'v-cmt'、'v-stc'(vnode.ts:64-72)。在调试器里看到这些字符串,就能立刻识别节点类型。
11.13.2 验证"vnode.el 何时可用"
用生命周期与渲染函数的执行顺序验证 11.2.6 的结论:
import { createApp, h, onBeforeMount, onMounted, ref, nextTick } from 'vue'
const app = createApp({
setup() {
const mounted = ref(false)
onBeforeMount(() => console.log('beforeMount: el =', /* 此处 ref 尚未绑定 */ 'n/a'))
onMounted(() => console.log('mounted: el 可用'))
return () => {
const vnode = h('div', { ref: 'rootEl' })
// 渲染函数执行时,vnode 刚被创建,el 必然为 null
console.log('render: vnode.el =', vnode.el) // → null
return vnode
}
},
})
app.mount('#app')
输出顺序与结论:
render:时的vnode.el === null:渲染函数只负责"生成描述",还没挂载。mountElement执行期间:vnode.el = hostCreateElement(...)被赋值——此刻元素已创建,但尚未hostInsert,所以el.parentNode === null。onBeforeMount:在组件挂载开始前触发,此时模板 ref 尚未绑定。onMounted:在queuePostRenderEffect的"后置阶段"触发,此刻el.parentNode已是容器,ref 也已绑定。
验证要点:如果你想在渲染函数里访问 el,会拿到 null;想在"元素已进 DOM"之后拿,必须等 onMounted 或 await nextTick()。
11.13.3 可复现的调试技巧
- 看 shapeFlag 的十进制:
17 = 1(元素) + 16(数组子);9 = 1 + 8(文本子)。记住COMPONENT = 6、SLOTS_CHILDREN = 32、TELEPORT = 64、SUSPENSE = 128即可口算。 - 看 patchFlag 的位:
1=TEXT、2=CLASS、4=STYLE、8=PROPS、16=FULL_PROPS、32=NEED_HYDRATION、64=STABLE_FRAGMENT、128=KEYED_FRAGMENT、256=UNKEYED_FRAGMENT、512=NEED_PATCH、1024=DYNAMIC_SLOTS;负数-1=CACHED、-2=BAIL。 - 判断 vnode 是不是 Block:
vnode.dynamicChildren !== null即是一个 Block 根(空块时是[];被v-once关闭追踪时可能是EMPTY_ARR或null)。 - 判断节点是否会走快路径:
!!n2.dynamicChildren→optimized=true(除非 BAIL/HMR)。
11.13.4 与 Vue 2 VNode 的对比
| 维度 | Vue 2 VNode | Vue 3 VNode |
|---|---|---|
| 类型判断 | tag 字符串 + isComment/isCloned 等布尔字段 | type(含 Symbol)+ shapeFlag 位图 |
| 更新优化 | 每次更新整棵组件树全量 diff | patchFlag + Block 树,只 diff 动态节点 |
| children 优化 | 每次全量比较 children 数组 | dynamicChildren 按下标对应 patch |
| props 优化 | 每次遍历全部 props | PROPS + dynamicProps 只碰会变的键 |
| 平台无关 | 深度依赖 DOM 接口 | RendererOptions 依赖注入,可自建渲染器 |
| 静态内容 | 编译期生成 staticRenderFns | Static vnode + insertStaticContent 一次性解析 |
| 深度标记 | 组件 $vnode + activeInstance | component 反向指针 + subTree |
一句话总结:Vue 2 的优化在"编译期生成 static/render 缓存函数"层面;Vue 3 的优化在"运行期的 VNode 位图 + Block 树"层面。 后者粒度更细、更可组合,也因此更依赖编译器与运行时的隐蔽契约(normalizeChildren 的两条路径、optimized 标志的信任模型)。
11.14 本章要点回顾
- 可插拔架构:
runtime-core零 DOM 依赖,靠RendererOptions(renderer.ts:117-154)定义 13 个宿主能力;runtime-dom的nodeOps(nodeOps.ts:44-138)+patchProp经createRenderer(renderer.ts:322-327)注入;Teleport/Suspense 通过RendererInternals(renderer.ts:169-183)消费能力,边界闭合且支持 tree-shaking。 - VNode 是普通对象(
vnode.ts:148-245):字段分三层——创建时确定(type/key/props/children/shapeFlag)、挂载时写入(el/anchor/component/transition)、编译器优化载荷(patchFlag/dynamicProps/dynamicChildren)。el在不同节点类型的不同挂载步骤被赋值(11.2.6 表格)。 - ShapeFlags:13 个位,低 3 位是类型位、bit3–7 是 children/特殊节点位、bit8–9 是 KeepAlive 位。位运算让"元素/组件/Teleport/Suspense"与"文本/数组/插槽 children"在一个整数里 O(1) 判定。
- 类型体系:8 类节点;
isSameVNodeType仅比较type === && key ===(vnode.ts:379-392),决定"复用 or 重建";四个内置符号用Symbol.for保证跨包相等。 - 创建与规范化:
createVNode(含isVNode(type)克隆分支 +BAIL)→createBaseVNode(两条 children 路径)→normalizeChildren(对象/函数/slot/Teleport 特例);normalizeVNode把 null/布尔/数组/字符串统一成 Comment/Fragment/Text/VNode。 - h():关 Block 追踪(
setBlockTracking(-1)),参数按arguments.length归一化,产物patchFlag=0走全量 diff,与编译器产物数据同构、信任度不同。 - patch 主流程:三闸门(
n1===n2/ 类型不同→卸载重挂 /BAIL降级)+switch type+ 位判定;optimized默认取!!n2.dynamicChildren。 - mountElement:
vnode.el在hostCreateElement后立即赋值;children 先于 props(<select value>);value延后强制设;mounted 类钩子走queuePostRenderEffect。 - patchElement:
patchFlag |= n1.patchFlag & FULL_PROPS;Block 元数据不稳定则降级;有dynamicChildren走patchBlockChildren,否则!optimized走全量;props 按 FULL_PROPS/CLASS/STYLE/PROPS 位分派。 - props 更新:
patchProps用"删除循环 + 更新循环 + value 特例"做双向 diff;PROPS分支只遍历dynamicProps;值比较是浅比较!==。 - unmount:
doRemove区分"逻辑卸载"与"真摘 DOM";KeepAlive 用COMPONENT_SHOULD_KEEP_ALIVE提前deactivate返回;Block 子树只卸dynamicChildren;remove对 Fragment/Static/有 transition 三类差异化处理;unmountComponent里scope.stop()+job.flags |= DISPOSED。 - Fragment:两端空文本锚点(起始 =
el,结束 =anchor);mount 时子节点插到"结束锚点之前";patch 时 stable 走patchBlockChildren,否则全量;<></>空片段用(children || [])兜底。
面试深度问答
Q1:为什么 runtime-core 里没有 document? 因为它面向多平台(DOM/SSR/Canvas/Native),所有宿主操作经 RendererOptions 注入。RendererNode 只是索引签名占位类型,源码注释明确"节点从不被直接操作,永远只传给 nodeOps 函数"。
Q2:shapeFlag 和 patchFlag 有何不同? shapeFlag 是结构位图(创建时算定,描述"是什么节点 + children 形态");patchFlag 是变更位图(编译器产出,描述"更新时碰哪里")。前者恒有值,后者手写渲染函数时为 0。
Q3:为什么 <component :is="vnode"/> 会强制 patchFlag = BAIL? 因为克隆体是用户语义,编译器没为它标注"哪里会变",运行时不能信任任何优化标记,必须退出优化模式走全量 diff。
Q4:vnode.el 何时可用? 元素在 mountElement 的 hostCreateElement 后即写入(但尚未插入 DOM);onMounted 回调里一定可用(post-render 派发);渲染函数执行时恒为 null。
Q5:为什么 Fragment 要用两个锚点、且起始锚点复用 el 字段? DOM 没有"节点组",必须用两个锚点界定区间以便整体移动/删除;复用 el 让 getNextHostNode 等通用逻辑免于对 Fragment 特判。
Q6:optimized 到底代表什么? "本次 diff 是否可以信任编译器给的优化标记"。为 true 时按 patchFlag/dynamicChildren 精确打击;为 false 时全量。默认值 !!n2.dynamicChildren 就是"新节点是不是 Block"。
Q7:为什么 props 更新要遍历旧对象? 因为要发现"被删除的键"(!(key in newProps) → patchProp(el, key, old, null))。只遍历新对象无法感知删除。
Q8:KeepAlive 的卸载为何不真正卸载? unmount 开头判断 shapeFlag & COMPONENT_SHOULD_KEEP_ALIVE,走 deactivate 后 return,故 DOM 与实例都保留在缓存里;再次激活时 processComponent 检测 COMPONENT_KEPT_ALIVE 走 activate。
下一章(12)将深入
patchChildren/patchKeyedChildren,讲清无 key 就地复用的陷阱、有 key 的双端比较、以及最长递增子序列(LIS)如何把 DOM 移动降到最少。