Vue 3 核心源码深度剖析系列(v3.5.44)-运行时内幕(一):VNode 设计与渲染器主流程

2 阅读30分钟

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 = () => {} 形式,以防被压缩器内联")。这个设计有三个动机:

  1. 共享 options 而不污染全局:hostInsert、hostPatchProp 等从 options 解构出来后,被闭包内所有函数引用(renderer.ts:366-379),无需层层传参。
  2. 天然支持多实例:你可以在同一个页面创建多个"渲染器实例"(例如一个渲染 DOM、一个渲染 Canvas),它们各自持有自己的一套 hostInsert,互不干扰。
  3. 闭包函数不可被外部篡改:引用稳定,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[&#34;runtime-core (纯逻辑)&#34;]
    V[VNode 数据结构<br/>vnode.ts]
    P[patch 机器<br/>renderer.ts]
    H[h() 入口<br/>h.ts]
    SF[ShapeFlags / PatchFlags<br/>编码]
  end
  subgraph Platform[&#34;runtime-dom (平台适配)&#34;]
    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_isVNodetrue类型守卫标记createBaseVNode 等所有创建路径isVNode()
__v_skiptrue跳过响应式代理(ReactiveFlags.SKIP)同上reactivity 系统
typeVNodeTypes节点类型(字符串标签 / 组件 / Text/Comment/Fragment/…符号)创建时patch 分派、isSameVNodeType
props(VNodeProps & ExtraProps) | null属性对象(含 key/ref 及组件 props)创建时(可能经 class/style 归一化)mountElement、patchProps、patchElement
keyPropertyKey | nulldiff 复用依据创建时由 normalizeKey 提取isSameVNodeType
refVNodeNormalizedRef | null模板 ref / 函数 ref创建时由 normalizeRef 归一化patch 末尾的 setRef
scopeIdstring | nullSFC scoped 样式 id创建时取 currentScopeIdsetScopeId
slotScopeIdsstring[] | null插槽作用域 id 数组processComponent / Fragment patchsetScopeId
childrenVNodeNormalizedChildren子节点(字符串 / VNode 数组 / 插槽对象 / null)创建时 + normalizeChildrenmountChildren、patchChildren
componentComponentInternalInstance | null该组件 vnode 对应的组件实例mountComponent(挂载时)更新/卸载组件
dirsDirectiveBinding[] | null自定义指令绑定编译产物(withDirectives)invokeDirectiveHook
transitionTransitionHooks<HostElement> | null<Transition> 钩子setTransitionHooksmountElement/remove
elHostNode | null该 vnode 对应的宿主节点挂载时写入(见 11.2.6)几乎处处
anchorHostNode | nullFragment/Static 的结束锚点processFragment/mountStaticNoderemove/移动
placeholderHostNode | null异步组件占位节点async wrapperhydration
target / targetStart / targetAnchorHostElement | HostNode | nullTeleport 目标容器及其锚点Teleport.processTeleport.remove/move
staticCountnumber静态 vnode 内含的节点数量(水合需要)createStaticVNodehydration
suspenseSuspenseBoundary | null所属 Suspense 边界Suspense.processSuspense.unmount 等
ssContent / ssFallbackVNode | nullSuspense 的 content / fallback 分支Suspense.normalizeSuspense
shapeFlagnumber类型 + 子节点形态的位编码创建时patch/mountElement/unmount
patchFlagnumber更新优化提示的位编码(编译器产出)编译器 / cloneVNodepatchElement/patchChildren
dynamicPropsstring[] | nullPROPS 标志下"可能变化的 prop 名列表"编译器patchElement 的 PROPS 分支
dynamicChildrenVNode[] & { hasOnce?: boolean } | nullBlock 树:本块内所有"动态子节点"(含子块)setupBlockpatchBlockChildren/unmount
appContextAppContext | null应用级上下文(仅根 vnode)createVNode 后由 app 赋值provide/inject、全局配置
ctxComponentInternalInstance | null词法作用域宿主实例(写入时取 currentRenderingInstance)创建时v-memo 缓存清理等
memoany[]?v-memo 缓存数组withMemocloneIfMounted/unmount
cacheIndexnumber?v-memo 缓存在 renderCache 里的下标withMemounmount 清理
ce函数?自定义元素拦截钩子CustomElementpatch
isCompatRoottrue?__COMPAT__ 专用compatcompat

表格横向看是"数据模型",纵向看是"生命周期":上半段字段在创建时就确定(型号、身份、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)。
  • 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
FragmentprocessFragment 的 n2.el = hostCreateText('')(起始锚点)renderer.ts:1088
StaticmountStaticNode 的 [n2.el, n2.anchor] = hostInsertStaticContent(...)renderer.ts:556-563
组件其子树根节点的 el,由 updateHOCHostEl 向上同步componentRenderUtils.ts:477-500
更新路径patchElement 开头 n2.el = n1.el(沿用旧节点,不重建)renderer.ts:854

由此得到几条实践准则:

  1. "挂载前"(onBeforeMount 之前)vnode.el 为 null:因为挂载还没跑到赋值那一步。
  2. mountElement 里元素 el 在插入前就已有值:源码注释解释了为什么——"先挂载 children,因为某些 props 依赖子内容已渲染,例如 <select value>"(renderer.ts:693-694)。也就是说,元素的 el 一旦创建就可用,但它还没进 DOM 树。所以 el.parentNode 在插入前是 null。
  3. onMounted 回调里 vnode.el 一定可用:它在 hostInsert 之后通过 queuePostRenderEffect 异步派发(renderer.ts:754-771)。
  4. 组件 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 的子节点收集进 dynamicChildrensetupBlock 把 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_CHILDREN90000 1001元素,唯一子节点是文本(<div>hello</div>)
ELEMENT | ARRAY_CHILDREN170001 0001元素,子节点是 vnode 数组
STATEFUL_COMPONENT40000 0100有状态组件(无子节点)
STATEFUL_COMPONENT | SLOTS_CHILDREN360010 0100有状态组件 + 插槽对象
FUNCTIONAL_COMPONENT20000 0010函数式组件
TELEPORT | ARRAY_CHILDREN800101 0000Teleport,子节点被强制数组化(vnode.ts:885-887)
SUSPENSE1281000 0000Suspense 边界
STATEFUL_COMPONENT | SLOTS_CHILDREN | COMPONENT_SHOULD_KEEP_ALIVE2921 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 换成位枚举,收益是实打实的:

  1. 判定 O(1) 且分支可预测:shapeFlag & ELEMENT 是一条 AND + 一次条件跳转,CPU 分支预测器极容易命中。字符串比较要逐字符,最坏 O(n)。
  2. 单字段装多信息,减少属性访问:判断"是元素还是组件"和"children 是文本还是数组"只需读一个 shapeFlag,而不是读两个布尔字段。属性越少,对象形状越稳,内联缓存越友好。
  3. 紧凑、可组合:COMPONENT = STATEFUL | FUNCTIONAL 让"凡是组件"这一类判断一气呵成,无需 isStateful || isFunctional。
  4. 位清除实现"临时标记":KeepAlive 的激活/失活就是临时改位(HMR 场景 isSameVNodeType 里用 n1.shapeFlag &= ~COMPONENT_SHOULD_KEEP_ALIVE,vnode.ts:385-386),不引入新字段。
  5. 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 / SuspenseTeleport/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 更新;否则必须先卸载旧节点、再挂载新节点"。

三个要点:

  1. type 相等是前提。'div' 换成 'span' 就不相等,必须重建;组件 A 换成组件 B 也不相等,必须卸载重挂。这是"类型不同则不可复用"的铁律。
  2. key 相等是开关。有 key 时,只有 key 相同才复用;无 key 时,null === null 成立,于是同类型节点按位置复用(这正是"无 key 列表就地复用"的性能与陷阱来源,第 12 章展开)。
  3. 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[&#34;patch(n1, n2)&#34;] --> B{&#34;n1 && !isSameVNodeType(n1,n2)?&#34;}
  B -->|是| C[&#34;anchor = getNextHostNode(n1)<br/>unmount(n1, ..., true)<br/>n1 = null&#34;]
  B -->|否| D{&#34;n2.type?&#34;}
  C --> D
  D -->|Text| E[&#34;processText&#34;]
  D -->|Comment| F[&#34;processCommentNode&#34;]
  D -->|Static| G{&#34;n1 == null?&#34;}
  G -->|是| H[&#34;mountStaticNode&#34;]
  G -->|否| I[&#34;patchStaticNode (dev/HMR)&#34;]
  D -->|Fragment| J[&#34;processFragment&#34;]
  D -->|其他| K{&#34;shapeFlag & ELEMENT?&#34;}
  K -->|是| L[&#34;processElement → mountElement / patchElement&#34;]
  K -->|否| M{&#34;shapeFlag & COMPONENT?&#34;}
  M -->|是| N[&#34;processComponent → mountComponent / updateComponent&#34;]
  M -->|否| O{&#34;shapeFlag & TELEPORT?&#34;}
  O -->|是| P[&#34;Teleport.process&#34;]
  O -->|否| Q{&#34;__FEATURE_SUSPENSE__ && shapeFlag & SUSPENSE?&#34;}
  Q -->|是| R[&#34;Suspense.process&#34;]
  Q -->|否| S[&#34;dev: warn('Invalid VNode type')&#34;]
  E --> T[&#34;setRef / 收尾&#34;]
  F --> T
  H --> T
  I --> T
  J --> T
  L --> T
  N --> T
  P --> T
  R --> T

优先级设计的三条动机:

  1. 先做"同类型判断"再做"类型分派":因为类型不同的替换要在进入任何 mount/patch 逻辑之前就完成卸载。否则新节点会被挂到一棵"还留着旧节点"的树上,造成重复。
  2. 四个特殊符号用 switch 前置:它们的 shapeFlag 里没有专属位(Fragment 的 shapeFlag 甚至可能只有 children 位),无法用位判定,必须精确匹配 type。
  3. 其余用位判定,且 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):依次做

  1. 类组件归一化:isClassComponent(type) 则取 type.__vccOpts(vnode.ts:601-603)——把 class MyComp extends Vue {} 这种 2.x 写法摊平成选项对象。
  2. compat 归一化(__COMPAT__):convertLegacyComponent(vnode.ts:606-608)。
  3. props 归一化(vnode.ts:611-626):先 guardReactiveProps(响应式对象 clone 以免污染用户数据),再把 class 归一成字符串(normalizeClass)、style 归一成对象(normalizeStyle,且响应式 style 需 extend({}, style) 复制一份)。
  4. 计算 shapeFlag(vnode.ts:628-639,见 11.3.3)。
  5. dev 检查组件是否被 reactive() 包裹(vnode.ts:641-651):若是则告警并 toRaw——把组件对象变成响应式是性能陷阱,会导致每次访问都走代理。
  6. 落库:调用 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 vnodev-if/&& 短路产生的 false 必须占一个位置,不然 diff 位置会错位
数组Fragment vnode(child.slice())嵌套数组 → 一个片段,slice 复制避免复用 vnode 时引用污染(#3666)
已是 vnodecloneIfMounted(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
      }
    }
  }
}

三条关键规则:

  1. 元素/Teleport 拿到"带 default 的对象",会把它当成"默认插槽函数"就地调用,把返回值当普通 children 处理(vnode.ts:845-854)。这是为了支持 h('div', () => ...) 这种瞎写——DOM 元素没有插槽概念,只能当函数 children 处理。调用 slot 前后用 slot._c && (slot._d = false/true) 临时禁用/恢复依赖收集(_d 是"dirty"标志,编译期插槽的追踪开关)。
  2. 组件拿到对象 → 就是插槽对象,置 SLOTS_CHILDREN(vnode.ts:856)。若这个 slots 对象还没有 _ 标记(即未经编译器归一化,是手写的),则临时挂上 _ctx = currentRenderingInstance,让后续渲染插槽时能拿到正确的宿主实例。
  3. FORWARDED 插槽的降级逻辑(vnode.ts:862-873):当子组件向上转发父组件的插槽时,插槽的"稳定性"由父组件决定——父组件是 STABLE 则子也是 STABLE;否则降级为 DYNAMIC 并给 vnode 打上 DYNAMIC_SLOTS patchFlag(这会强制该组件每次都更新,因为插槽内容可能变)。

第四步: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[&#34;normalizeChildren(vnode, children)&#34;] --> N{&#34;children == null?&#34;}
  N -->|是| N1[&#34;children = null&#34;]
  N -->|否| A{&#34;isArray?&#34;}
  A -->|是| A1[&#34;type = ARRAY_CHILDREN&#34;]
  A -->|否| O{&#34;typeof object?&#34;}
  O -->|是| OE{&#34;shapeFlag 含 ELEMENT/TELEPORT?&#34;}
  OE -->|是| OE1[&#34;取 .default 插槽函数<br/>调用它,递归 normalizeChildren&#34;]
  OE -->|否| OE2[&#34;type = SLOTS_CHILDREN<br/>无 _ 则挂 _ctx<br/>FORWARDED 则依父稳定性降级&#34;]
  O -->|否| F{&#34;isFunction?&#34;}
  F -->|是| FE{&#34;shapeFlag 含 ELEMENT/TELEPORT?&#34;}
  FE -->|是| FE1[&#34;包成 { default: fn } 递归&#34;]
  FE -->|否| FE2[&#34;{ default: fn, _ctx }<br/>type = SLOTS_CHILDREN&#34;]
  F -->|否| T{&#34;shapeFlag & TELEPORT?&#34;}
  T -->|是| T1[&#34;children = [createTextVNode(String(c))]<br/>type = ARRAY_CHILDREN&#34;]
  T -->|否| T2[&#34;children = String(c)<br/>type = TEXT_CHILDREN&#34;]
  N1 --> W[&#34;vnode.children = children<br/>vnode.shapeFlag |= type&#34;]
  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):

h is a more user-friendly version of createVNode that allows omitting the props when possible. It is intended for manually written render functions. Compiler-generated code uses createVNode because 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)
  }
}

逐条走读:

  1. setBlockTracking(-1) ... finally { setBlockTracking(1) }(h.ts:201-202, 224-225):h 全程关闭 Block 追踪(对应 #6913)。原因是手写渲染函数的 vnode 没有编译器的 patchFlag 语义,如果被收集进 dynamicChildren,运行时可能用错误的快路径 diff 它。关掉追踪后,h 产出的节点不进任何块的 dynamicChildren,一律走全量对比,安全。

  2. 两参数(l === 2):propsOrChildren 是"要么是 props,要么是 children"。

    • 是对象且非数组 → 若又是 vnode,则当"唯一 children"(包成 [propsOrChildren]);否则当 props。
    • 其它(数组/字符串/函数/数字)→ 当作 children,props 传 null。
  3. 三参数及以上(l >= 3):propsOrChildren 明确是 props。

    • l > 3:把第 3 个起的实参整体收集成 children 数组(支持 h('div', {}, a, b, c))。
    • l === 3 && isVNode(children):把单个 vnode 包成数组。
    • 其余情况原样传 children 给 createVNode(再由 normalizeChildren 归一化)。
  4. 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/patchPropsoptimized=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。这里先记两个要点:

  1. Fragment 的"位置"由两个文本锚点标定:n2.el 是起始锚点,n2.anchor 是结束锚点(renderer.ts:1088-1089)。
  2. 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[&#34;patch(n1,n2,container,anchor,...)<br/>optimized = !!n2.dynamicChildren&#34;] --> G1{&#34;n1 === n2?&#34;}
  G1 -->|是| R[&#34;return&#34;]
  G1 -->|否| G2{&#34;n1 && !isSameVNodeType(n1,n2)?&#34;}
  G2 -->|是| REPLACE[&#34;anchor = getNextHostNode(n1)<br/>unmount(n1,...,true)<br/>n1 = null&#34;]
  G2 -->|否| G3{&#34;n2.patchFlag === BAIL?&#34;}
  REPLACE --> G3
  G3 -->|是| BAIL[&#34;optimized = false<br/>n2.dynamicChildren = null&#34;]
  G3 -->|否| ONCE
  BAIL --> ONCE{&#34;旧块 hasOnce?<br/>继承标记&#34;}
  ONCE --> SW{&#34;switch n2.type&#34;}
  SW -->|Text| PT[&#34;processText&#34;]
  SW -->|Comment| PC[&#34;processCommentNode&#34;]
  SW -->|Static| PS[&#34;n1==null ? mountStaticNode : (dev) patchStaticNode&#34;]
  SW -->|Fragment| PF[&#34;processFragment&#34;]
  SW -->|default| BITS{&#34;shapeFlag 位判定&#34;}
  BITS -->|ELEMENT| PE[&#34;processElement → mountElement / patchElement&#34;]
  BITS -->|COMPONENT| PCO[&#34;processComponent → mountComponent / updateComponent&#34;]
  BITS -->|TELEPORT| PTE[&#34;TeleportImpl.process&#34;]
  BITS -->|SUSPENSE| PSP[&#34;SuspenseImpl.process&#34;]
  PT --> REF
  PC --> REF
  PS --> REF
  PF --> REF
  PE --> REF
  PCO --> REF
  PTE --> REF
  PSP --> REF
  REF[&#34;ref 处理:setRef / 清空旧 ref&#34;]

性能账本:一次 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)
  }
}

两个关键点:

  1. optimized 决定归一化策略(renderer.ts:828-830):
    • optimized=true:编译器产出的 children 必然全是 vnode,只需 cloneIfMounted(防复用污染),跳过 normalizeVNode 的类型判断。
    • optimized=false:手写 children 可能是字符串/数字/数组/布尔,必须走完整 normalizeVNode。
  2. children[i] = ... 就地回写:归一化的结果被写回原数组,避免后续 patch 时重复归一化。
  3. 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)
  }
}

三件事:

  1. 遍历 props 逐个 patchProp:prevValue = null(因为是首次挂载)。跳过 value 和所有保留 prop(key/ref/onVnodeXXX,由 isReservedProp 识别)。
  2. value 单独且延后设置(renderer.ts:722-733):源码注释列了两条理由——① value 有顺序敏感(必须在 min/max 之后设置,#2325、#4024);② 它需要强制设置(#1471)。所以它被从循环里拿出来,在其它 prop 之后再设。
  3. 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)
}

顺序要点:

  1. devtools 挂 __vnode / __vueParentComponent:把 vnode 与父组件实例直接挂在 DOM 元素上,供 devtools 检查。用 def(..., true)(不可枚举),避免污染 for...in 与序列化。
  2. beforeMount 指令钩子:在插入前调用。
  3. transition.beforeEnter:在插入前调用(此时元素已存在但尚未进 DOM,适合设置起始样式)。
  4. hostInsert(el, container, anchor):真正的插入。
  5. 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

三段:

  1. n2.el = n1.el:更新不重建元素,沿用旧 DOM。这句是"就地更新"的根本。
  2. devtools 更新 el.__vnode:让 devtools 指向最新 vnode。
  3. 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":

  1. HMR 更新(dev)。
  2. 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_PROPSpatchProps 全量 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 且 !optimizedpatchProps 全量手写渲染函数的兜底

几个精妙点:

  1. CLASS 分支先比较再 patch(renderer.ts:939-943):class 变化常伴随其它更新,先比较能避免无谓的 DOM 写入。
  2. PROPS 分支的 next !== prev || key === 'value'(renderer.ts:965):value 是"无论是否变化都强制 patch"(#1471,表单元素 value 需要强制同步)。其它 prop 只在值变时 patch。
  3. PROPS 与 FULL_PROPS 的互斥:PROPS 假设"知道哪些键会变",FULL_PROPS 是"键可能增删"——后者必须遍历 keys 找新增/删除,前者不必。所以二者互斥(patchFlags.ts:52-57 注释明确写了)。
  4. 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[&#34;patchElement(n1, n2, ...)&#34;] --> E[&#34;el = n2.el = n1.el<br/>patchFlag |= n1.patchFlag & FULL_PROPS&#34;]
  E --> HOOK[&#34;onVnodeBeforeUpdate / 指令 beforeUpdate&#34;]
  HOOK --> DEG{&#34;HMR 或<br/>dynamicChildren 不稳定?&#34;}
  DEG -->|是| ZERO[&#34;patchFlag=0; optimized=false; dynamicChildren=null&#34;]
  DEG -->|否| HTML
  ZERO --> HTML{&#34;innerHTML/textContent 被移除?&#34;}
  HTML -->|是| CLR[&#34;hostSetElementText(el, '')&#34;]
  HTML -->|否| KIDS
  CLR --> KIDS{&#34;有 dynamicChildren?&#34;}
  KIDS -->|是| PBC[&#34;patchBlockChildren<br/>(只 patch 动态子节点)&#34;]
  KIDS -->|否| OPT{&#34;!optimized?&#34;}
  OPT -->|是| PCH[&#34;patchChildren 全量 diff&#34;]
  OPT -->|否| SKIP[&#34;跳过(纯静态)&#34;]
  PBC --> PF{&#34;patchFlag > 0?&#34;}
  PCH --> PF
  SKIP --> PF
  PF -->|是| FB{&#34;FULL_PROPS?&#34;}
  FB -->|是| FULLP[&#34;patchProps 全量&#34;]
  FB -->|否| CS[&#34;CLASS / STYLE / PROPS 按位<br/>+ TEXT&#34;]
  PF -->|否| LAST{&#34;!optimized && dynamicChildren==null?&#34;}
  LAST -->|是| FULLP2[&#34;patchProps 全量&#34;]
  LAST -->|否| DONE
  FULLP --> TXT
  CS --> TXT[&#34;TEXT 分支 / 收尾&#34;]
  FULLP2 --> TXT
  TXT --> UPD[&#34;onVnodeUpdated / 指令 updated (post)&#34;]

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:

  1. oldProps !== newProps 短路:如果新旧 props 是同一个对象引用(常见于未改动的静态 props),直接跳过。这是第一层剪枝。
  2. 删除循环(遍历 oldProps):!isReservedProp(key) && !(key in newProps) → 这个键在新 props 里没了,用 nextValue=null 通知 patchProp 移除属性。这是"只遍历 newProps"做不到的事——必须遍历旧对象才能发现"被删掉的键"。
  3. 更新循环(遍历 newProps):只 patch next !== prev 的键(value 延后)。
  4. 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_PROPSpatchProps 双向 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
}

三段:

  1. 解绑 ref(renderer.ts:2199-2203):pauseTracking() 临时停止依赖收集(避免解绑过程误触发副作用),setRef(ref, null, ..., true) 把 ref 设为 null,再 resetTracking()。
  2. 清理 v-memo 缓存(renderer.ts:2207-2209):#6593,若 vnode 是用 renderCache 缓存的(cacheIndex != null)则清空对应槽位。归属判断 !vnode.ctx || vnode.ctx === parentComponent 是关键:只有缓存的"作者"是自己时才清(见 11.2.5 的 ctx 说明)。
  3. 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 卸载路径,按优先级:

  1. Suspense:vnode.suspense.unmount(...) 后 return(Suspense 有自己的卸载逻辑,处理 content/fallback 分支)。
  2. Teleport:TeleportImpl.remove(...)(要处理"内容在别的容器里"的移除)。
  3. Block 快路径(dynamicChildren 存在):只卸载 dynamicChildren 里的动态子节点(unmountChildren(dynamicChildren, ..., optimized=true))。这与 patchBlockChildren 对称——Block 子树里静态部分无需处理。三个条件是:① 有 dynamicChildren;② 没有 hasOnce(有 v-once 的块不能走快路径,否则嵌套组件漏卸);③ 不是"非 stable 的 Fragment"(#1153:v-for 产生的 keyed/unkeyed Fragment 必须走全量,因为其结构会变)。
  4. Fragment 且 keyed/unkeyed:unmountChildren(children) 全量卸载(v-for 场景)。
  5. 普通且未优化但有数组子节点: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()
  }
}

四条差异化路径:

  1. Fragment 且无 transition → removeFragment(el, anchor):从起始锚点一路删到结束锚点(renderer.ts:2355-2365 的 while (cur !== end))。
  2. Static → removeStaticNode(vnode):while (el && el !== anchor) 逐个删(renderer.ts:607-615),然后删 anchor。若有 transition 且未持久化,调用 afterLeave 释放等待(源码注释解释:不透明的水合占位不能动画,但必须释放 out-in 过渡)。
  3. 有 transition 且是元素 → 走离开动画:leave(leavingEl, performRemove),DOM 的移除推迟到动画结束的 performRemove 回调。delayLeave 若存在则先调用它决定时机。
  4. 其它 → 直接 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:源码注释说明——组件在 async setup() 完成前就被卸载时 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[&#34;unmount(vnode, ..., doRemove, optimized)&#34;] --> B[&#34;BAIL / hasOnce → optimized=false&#34;]
  B --> REF[&#34;ref 解绑&#34;]
  REF --> MEMO[&#34;清 v-memo 缓存&#34;]
  MEMO --> KA{&#34;COMPONENT_SHOULD_KEEP_ALIVE?&#34;}
  KA -->|是| DEACT[&#34;KeepAlive.deactivate → return&#34;]
  KA -->|否| HOOK[&#34;onVnodeBeforeUnmount&#34;]
  HOOK --> COMP{&#34;shapeFlag & COMPONENT?&#34;}
  COMP -->|是| UMC[&#34;unmountComponent → 递归 unmount(subTree)&#34;]
  COMP -->|否| SUS{&#34;SUSPENSE?&#34;}
  SUS -->|是| SUSU[&#34;suspense.unmount → return&#34;]
  SUS -->|否| DIRS[&#34;指令 beforeUnmount&#34;]
  DIRS --> TP{&#34;TELEPORT?&#34;}
  TP -->|是| TPR[&#34;TeleportImpl.remove&#34;]
  TP -->|否| BLK{&#34;dynamicChildren && !hasOnce && 非非stableFragment?&#34;}
  BLK -->|是| UDC[&#34;unmountChildren(dynamicChildren, optimized=true)&#34;]
  BLK -->|否| FRAG{&#34;Fragment(keyed/unkeyed) 或 未优化且 ARRAY_CHILDREN?&#34;}
  FRAG -->|是| UDC2[&#34;unmountChildren(children)&#34;]
  FRAG -->|否| RM
  UDC --> RM{&#34;doRemove?&#34;}
  UDC2 --> RM
  TPR --> RM
  RM -->|是| REMOVE[&#34;remove(vnode)&#34;]
  RM -->|否| DONE[&#34;跳过节点的 DOM 移除&#34;]

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)

时序:

  1. 把起始锚点插到 container 的 anchor 之前。
  2. 把结束锚点插到 container 的 anchor 之前(此时结束锚点在起始锚点之后,因为后插的在同一位置前插,会落在起始锚点之后)。
  3. 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)

两条主路径:

  1. 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 上)。
  2. 其它(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')

预期的关键结果:

变量typeshapeFlag命中位解读
v1'div'17ELEMENT | ARRAY_CHILDREN元素,children 是 vnode 数组
v2'p'9ELEMENT | TEXT_CHILDREN元素,children 是字符串
v3'v-fgt'16ARRAY_CHILDREN片段,没有 ELEMENT 位(靠 type 分派)
v4'v-txt'8TEXT_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')

输出顺序与结论:

  1. render: 时的 vnode.el === null:渲染函数只负责"生成描述",还没挂载。
  2. mountElement 执行期间:vnode.el = hostCreateElement(...) 被赋值——此刻元素已创建,但尚未 hostInsert,所以 el.parentNode === null。
  3. onBeforeMount:在组件挂载开始前触发,此时模板 ref 尚未绑定。
  4. 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 VNodeVue 3 VNode
类型判断tag 字符串 + isComment/isCloned 等布尔字段type(含 Symbol)+ shapeFlag 位图
更新优化每次更新整棵组件树全量 diffpatchFlag + Block 树,只 diff 动态节点
children 优化每次全量比较 children 数组dynamicChildren 按下标对应 patch
props 优化每次遍历全部 propsPROPS + dynamicProps 只碰会变的键
平台无关深度依赖 DOM 接口RendererOptions 依赖注入,可自建渲染器
静态内容编译期生成 staticRenderFnsStatic vnode + insertStaticContent 一次性解析
深度标记组件 $vnode + activeInstancecomponent 反向指针 + subTree

一句话总结:Vue 2 的优化在"编译期生成 static/render 缓存函数"层面;Vue 3 的优化在"运行期的 VNode 位图 + Block 树"层面。 后者粒度更细、更可组合,也因此更依赖编译器与运行时的隐蔽契约(normalizeChildren 的两条路径、optimized 标志的信任模型)。


11.14 本章要点回顾

  1. 可插拔架构: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。
  2. VNode 是普通对象(vnode.ts:148-245):字段分三层——创建时确定(type/key/props/children/shapeFlag)、挂载时写入(el/anchor/component/transition)、编译器优化载荷(patchFlag/dynamicProps/dynamicChildren)。el 在不同节点类型的不同挂载步骤被赋值(11.2.6 表格)。
  3. ShapeFlags:13 个位,低 3 位是类型位、bit3–7 是 children/特殊节点位、bit8–9 是 KeepAlive 位。位运算让"元素/组件/Teleport/Suspense"与"文本/数组/插槽 children"在一个整数里 O(1) 判定。
  4. 类型体系:8 类节点;isSameVNodeType 仅比较 type === && key ===(vnode.ts:379-392),决定"复用 or 重建";四个内置符号用 Symbol.for 保证跨包相等。
  5. 创建与规范化:createVNode(含 isVNode(type) 克隆分支 + BAIL)→ createBaseVNode(两条 children 路径)→ normalizeChildren(对象/函数/slot/Teleport 特例);normalizeVNode 把 null/布尔/数组/字符串统一成 Comment/Fragment/Text/VNode。
  6. h():关 Block 追踪(setBlockTracking(-1)),参数按 arguments.length 归一化,产物 patchFlag=0 走全量 diff,与编译器产物数据同构、信任度不同。
  7. patch 主流程:三闸门(n1===n2 / 类型不同→卸载重挂 / BAIL 降级)+ switch type + 位判定;optimized 默认取 !!n2.dynamicChildren。
  8. mountElement:vnode.el 在 hostCreateElement 后立即赋值;children 先于 props(<select value>);value 延后强制设;mounted 类钩子走 queuePostRenderEffect。
  9. patchElement:patchFlag |= n1.patchFlag & FULL_PROPS;Block 元数据不稳定则降级;有 dynamicChildren 走 patchBlockChildren,否则 !optimized 走全量;props 按 FULL_PROPS/CLASS/STYLE/PROPS 位分派。
  10. props 更新:patchProps 用"删除循环 + 更新循环 + value 特例"做双向 diff;PROPS 分支只遍历 dynamicProps;值比较是浅比较 !==。
  11. unmount:doRemove 区分"逻辑卸载"与"真摘 DOM";KeepAlive 用 COMPONENT_SHOULD_KEEP_ALIVE 提前 deactivate 返回;Block 子树只卸 dynamicChildren;remove 对 Fragment/Static/有 transition 三类差异化处理;unmountComponent 里 scope.stop() + job.flags |= DISPOSED。
  12. 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 移动降到最少。