VNode 内部结构与 ShapeFlags 位运算
第14章:VNode 内部结构与 ShapeFlags 位运算
Vue 3 的 VNode 对象平均只有 12 个字段,其中
shapeFlag一个整数就承担了原本需要 10 个 boolean 字段才能表达的类型信息——这不是炫技,而是 V8 引擎对小对象有专属的快速内联缓存路径。
本章核心问题:VNode 到底是什么结构?渲染器如何在微秒级别判断一个节点是组件还是原生元素还是文本?
读完本章你将理解:
- VNode 的完整字段结构及每个字段在运行时的精确含义
- ShapeFlag 位掩码的设计动机与实际运算方式
createVNode()、createElementVNode()、h()三者的调用时机差异- Fragment、Text、Comment 等特殊 Symbol 类型的挂载逻辑
- 为什么用 index 做 key 会引发隐性性能灾难
Level 1 · 你需要知道的(1-3年经验)
VNode 是什么:一个描述 UI 意图的普通 JS 对象
虚拟节点(VNode)不是 DOM,也不是组件实例。它是一张"施工图纸",描述了"我想要一个什么样的 DOM 节点或组件"。渲染器读取这张图纸,决定如何操作真实 DOM。
最重要的事实:一个 VNode 对象在内存中只活一帧。每次渲染,Vue 都会生成全新的 VNode 树,然后与上一帧的旧树对比(diff),只把差异应用到真实 DOM。
VNode 的 11 个核心字段
interface VNode {
// 类型标识(最重要的字段)
type: string | object | symbol | Function
// diff 复用的唯一标识
key: string | number | symbol | null
// 模板 ref 引用
ref: VNodeNormalizedRef | null
// 传给元素或组件的属性对象
props: Record<string, any> | null
// 子节点:字符串、数组、插槽对象
children: VNodeNormalizedChildren
// 挂载后:指向组件实例(仅组件 VNode)
component: ComponentInternalInstance | null
// 挂载后:指向对应的真实 DOM 节点(仅元素 VNode)
el: HostElement | null
// 类型位掩码:用一个整数编码节点的所有类型特征
shapeFlag: number
// 编译器生成的更新提示(0 = 全量对比,>0 = 精确更新)
patchFlag: number
// 需要动态对比的 prop 名列表(仅在 patchFlag 包含 FULL_PROPS 时有效)
dynamicProps: string[] | null
// Block 树收集的动态子节点(跳过静态节点的关键)
dynamicChildren: VNode[] | null
}
type 字段的四种形态
type 是渲染器判断如何处理这个 VNode 的第一依据:
| type 的值 | 代表的节点类型 | 渲染器行为 |
|---|---|---|
'div'、'span'、'input' 等字符串 |
原生 HTML 元素 | 创建/更新 DOM 元素 |
{ setup, render, ... } 对象 |
有状态组件(选项式或 setup 式) | 创建组件实例,调用 setup |
(props, ctx) => VNode 函数 |
函数式组件 | 直接调用函数获取 VNode |
Symbol(Fragment)、Symbol(Text) 等 |
特殊容器节点 | 各自的特殊处理逻辑 |
// 字符串类型:原生元素
const divVNode = h('div', { class: 'box' }, 'Hello')
// divVNode.type === 'div'
// 对象类型:有状态组件
const MyComp = defineComponent({ setup() { return () => h('span', 'Hi') } })
const compVNode = h(MyComp, { title: 'test' })
// compVNode.type === MyComp
// Symbol 类型:Fragment(多根节点)
const fragVNode = h(Fragment, null, [h('li', 'a'), h('li', 'b')])
// fragVNode.type === Fragment(一个 Symbol)
三种特殊 Symbol 类型
// packages/runtime-core/src/vnode.ts
export const Fragment = Symbol('Fragment')
export const Text = Symbol('Text')
export const Comment = Symbol('Comment')
export const Static = Symbol('Static')
- Fragment:多根节点容器,
children是子节点数组,自身不对应任何 DOM 节点。模板<template v-for>会产生 Fragment。 - Text:纯文本节点,
children是字符串,对应document.createTextNode()。 - Comment:注释节点,
v-if为 false 时的占位符。 - Static:静态提升后的节点,内容永远不变,只创建一次。
shapeFlag 字段的直觉理解
假设你要存一个"这个节点是不是有状态组件"的信息,最直观的方式是用一个 boolean:
vnode.isStatefulComponent = true
vnode.hasArrayChildren = false
vnode.hasSlotsChildren = true
// ... 再加 8 个 boolean
但 Vue 3 用一个数字表达同样的信息:
vnode.shapeFlag = 36 // 二进制 100100 = 有状态组件(4) | 插槽子节点(32)
这样做的好处:内存从 10 个字段减少到 1 个,类型检查从属性访问变成位运算(更快)。
日常开发中遇到 VNode 的场景
<script setup>
import { h, cloneVNode, useSlots } from 'vue'
const slots = useSlots()
// 访问插槽的 VNode
const defaultSlotVNodes = slots.default?.()
// 克隆并修改 VNode(用于高阶组件)
if (defaultSlotVNodes?.length) {
const cloned = cloneVNode(defaultSlotVNodes[0], { class: 'extra-class' })
}
</script>
Level 2 · 它是怎么运行的(3-5年经验)
ShapeFlags 枚举完整定义
// packages/shared/src/shapeFlags.ts
export const enum ShapeFlags {
ELEMENT = 1, // 0000_0000_0001
FUNCTIONAL_COMPONENT = 1 << 1, // 0000_0000_0010 = 2
STATEFUL_COMPONENT = 1 << 2, // 0000_0000_0100 = 4
TEXT_CHILDREN = 1 << 3, // 0000_0000_1000 = 8
ARRAY_CHILDREN = 1 << 4, // 0000_0001_0000 = 16
SLOTS_CHILDREN = 1 << 5, // 0000_0010_0000 = 32
TELEPORT = 1 << 6, // 0000_0100_0000 = 64
SUSPENSE = 1 << 7, // 0000_1000_0000 = 128
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 0001_0000_0000 = 256
COMPONENT_KEPT_ALIVE = 1 << 9, // 0010_0000_0000 = 512
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT, // = 6
}
注意 COMPONENT = 6,它是 STATEFUL_COMPONENT(4) | FUNCTIONAL_COMPONENT(2) 的按位或结果。这意味着:
// 一次位运算,同时检测两种组件类型
if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
// 无论有状态还是函数式,都走这里
}
位运算检测的内部实现
位运算原理图:
shapeFlag 值为 36 的 VNode(有状态组件 + 插槽子节点):
shapeFlag 的二进制表示:
Bit 9 8 7 6 5 4 3 2 1 0
0 0 0 0 1 0 0 1 0 0 = 36
检测 STATEFUL_COMPONENT (4 = 0000_0100):
shapeFlag: 0000_0010_0100
mask: 0000_0000_0100 (ShapeFlags.STATEFUL_COMPONENT)
AND 结果: 0000_0000_0100 = 4(非零 → true,是有状态组件)
检测 ELEMENT (1 = 0000_0001):
shapeFlag: 0000_0010_0100
mask: 0000_0000_0001 (ShapeFlags.ELEMENT)
AND 结果: 0000_0000_0000 = 0(零 → false,不是原生元素)
检测 SLOTS_CHILDREN (32 = 0010_0000):
shapeFlag: 0000_0010_0100
mask: 0000_0010_0000 (ShapeFlags.SLOTS_CHILDREN)
AND 结果: 0000_0010_0000 = 32(非零 → true,有插槽子节点)
实际的渲染器 patch 分发代码:
// packages/runtime-core/src/renderer.ts (简化版)
function patch(n1, n2, container) {
const { type, shapeFlag } = n2
// 先按 type 分发特殊 Symbol 类型
switch (type) {
case Text:
processText(n1, n2, container)
break
case Comment:
processCommentNode(n1, n2, container)
break
case Fragment:
processFragment(n1, n2, container)
break
default:
// 再按 shapeFlag 分发普通类型
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
type.process(n1, n2, container, ...)
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
type.process(n1, n2, container, ...)
}
}
}
createVNode 的完整执行流程
createVNode() 执行链路:
createVNode(type, props, children)
│
├── 1. 规范化 type(处理 defineComponent 返回值)
│
├── 2. 计算 shapeFlag
│ isString(type) → ELEMENT(1)
│ isArray(type) → ELEMENT(1)(会被规范化)
│ isSuspense(type) → SUSPENSE(128)
│ isTeleport(type) → TELEPORT(64)
│ isFunction(type) → FUNCTIONAL_COMPONENT(2)
│ isObject(type) → STATEFUL_COMPONENT(4)
│
├── 3. 规范化 props(处理 class/style 的特殊形式)
│ class: ['a', {b: true}] → 'a b'(提前计算字符串)
│ style: [{color:'red'}] → {color: 'red'}(合并对象)
│
├── 4. 规范化 children,更新 shapeFlag
│ children 是字符串 → shapeFlag |= TEXT_CHILDREN(8)
│ children 是数组 → shapeFlag |= ARRAY_CHILDREN(16)
│ children 是对象(插槽) → shapeFlag |= SLOTS_CHILDREN(32)
│
├── 5. 收集到当前 Block 的 dynamicChildren(若有 patchFlag)
│
└── 6. 返回 VNode 对象
createVNode vs createElementVNode vs h() 的差异
这三个函数是不同层级的抽象:
// h() 是用户层 API,做了最多的参数规范化
// 可以接受各种形式的参数
h('div', 'text') // children 是字符串
h('div', ['a', 'b']) // children 是数组
h('div', { class: 'box' }, 'text') // props + children
// createVNode() 是内部层,比 h() 少一层参数规范化
// 编译器不会生成 h(),而是生成 createVNode() 或更快的变体
// createElementVNode() 是 createVNode() 的特化版本
// 只处理原生元素(已知 type 是字符串),跳过 isObject/isFunction 检查
// 编译器对模板中的原生元素生成 createElementVNode(),比 createVNode() 快约 20%
// 实际编译结果:
// <div class="box">Hello</div>
// 编译为:
createElementVNode("div", { class: "box" }, "Hello")
// 而不是:
createVNode("div", { class: "box" }, "Hello")
cloneVNode 的使用场景与实现
cloneVNode 不是简单的对象浅拷贝,它需要处理 VNode 的多个字段的合并:
// packages/runtime-core/src/vnode.ts(简化)
export function cloneVNode(vnode, extraProps?, children?) {
const { props, ref, patchFlag, children: existingChildren } = vnode
const mergedProps = extraProps
? mergeProps(props || {}, extraProps)
: props
return {
...vnode, // 复制所有字段
props: mergedProps,
// ref 需要特殊处理:合并多个 ref
ref: ...,
// children 优先使用新传入的
children: children ?? existingChildren,
// patchFlag 重置为 -2(表示"已克隆,需要全量 diff")
patchFlag: patchFlag === PatchFlags.HOISTED
? PatchFlags.HOISTED
: patchFlag | PatchFlags.BAIL,
// 克隆后的 VNode 不再属于原来的 Block
dynamicChildren: null,
}
}
常见使用场景:
// 场景1:高阶组件为插槽内容添加额外属性
const HOC = defineComponent({
setup(_, { slots }) {
return () => {
const children = slots.default?.()
if (!children) return null
// 为所有插槽内容添加 class
return children.map(vnode =>
cloneVNode(vnode, { class: 'hoc-wrapped' })
)
}
}
})
// 场景2:TransitionGroup 为子节点注入 key
// Vue 内部源码中大量使用 cloneVNode
Fragment VNode 的挂载与卸载
Fragment 的特殊性在于它没有对应的 DOM 节点,但需要知道自己的边界(用于后续的插入/移除操作):
Fragment 的 DOM 布局:
虚拟层:
Fragment VNode
├── Text VNode "item A"
├── Text VNode "item B"
└── Text VNode "item C"
真实 DOM 层:
<!-- 起始锚点:存储在 vnode.el -->
"item A"
"item B"
"item C"
<!-- 结束锚点:存储在 vnode.anchor -->
卸载时:从 el 到 anchor 之间的所有 DOM 节点全部移除
// processFragment 的核心逻辑(简化)
function processFragment(n1, n2, container) {
// 创建两个空文本节点作为锚点
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))
if (n1 == null) {
// 首次挂载:插入起点锚、挂载子节点、插入终点锚
hostInsert(fragmentStartAnchor, container)
mountChildren(n2.children, container, fragmentEndAnchor)
hostInsert(fragmentEndAnchor, container)
} else {
// 更新:patch 子节点
patchChildren(n1, n2, container, fragmentEndAnchor)
}
}
组件 VNode vs 元素 VNode 的挂载差异
元素 VNode 挂载(mountElement):
1. createElement(type) → 创建真实 DOM 节点
2. mountChildren(children) → 递归处理子节点
3. patchProps(el, props) → 设置 class/style/事件等
4. insert(el, container) → 插入 DOM 树
组件 VNode 挂载(mountComponent):
1. createComponentInstance() → 创建组件实例对象
2. setupComponent(instance) → 执行 setup(),处理 props/slots
3. setupRenderEffect(instance) → 创建响应式副作用
├── 首次执行:调用 render() 得到 subTree VNode
├── patch(null, subTree) → 递归挂载 subTree
└── 后续:数据变化时重新执行
Level 3 · 设计文档与源码(资深开发者)
VNode 为什么不用 class 而用普通对象
这是一个关键的设计决策。Vue 2 的 VNode 是用 class 定义的:
// Vue 2 的方式
class VNode {
constructor(tag, data, children, text, elm, ...) {
this.tag = tag
this.data = data
// ... 20+ 个字段
}
}
Vue 3 改为工厂函数返回普通对象:
// packages/runtime-core/src/vnode.ts
const vnode: VNode = {
__v_isVNode: true,
__v_skip: true, // 告诉响应式系统不要追踪这个对象
type,
key,
ref,
// ...
}
原因有三:
- V8 的隐藏类(Hidden Class)优化:工厂函数每次返回具有相同字段顺序的对象,V8 可以将它们归入同一个隐藏类,触发内联缓存(Inline Cache)。class 实例在理论上也可以,但工厂函数更容易保证字段顺序稳定。
__v_skip: true标记:响应式系统(reactive())看到这个标记会直接跳过,不会把 VNode 变成响应式对象。这防止了大量不必要的追踪。- Tree-shaking 友好:工厂函数可以被静态分析,class 方法较难被 tree-shaking。
patchFlag 的精确语义
// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
TEXT = 1, // 动态文本内容
CLASS = 1 << 1, // 动态 class 绑定
STYLE = 1 << 2, // 动态 style 绑定
PROPS = 1 << 3, // 动态 props(不含 class/style)
FULL_PROPS = 1 << 4, // 包含动态 key 的 props,需要全量 diff
HYDRATE_EVENTS = 1 << 5, // SSR 水合时需要处理的事件监听器
STABLE_FRAGMENT = 1 << 6, // 子节点顺序不变的 Fragment
KEYED_FRAGMENT = 1 << 7, // 有 key 的 Fragment(v-for)
UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 Fragment(v-for without key)
NEED_PATCH = 1 << 9, // 需要对比(如 ref、指令)
DYNAMIC_SLOTS = 1 << 10, // 动态插槽
HOISTED = -1, // 静态提升的 VNode,永不更新
BAIL = -2, // 退出优化模式,全量对比(cloneVNode 后使用)
}
编译器对 <div :class="cls">{{ text }}</div> 生成:
createElementVNode(
"div",
{ class: _ctx.cls }, // 动态 class
toDisplayString(_ctx.text), // 动态文本
3 // patchFlag = CLASS(2) | TEXT(1) = 3
)
更新时,patchElement 看到 patchFlag = 3:
// packages/runtime-core/src/renderer.ts(简化)
function patchElement(n1, n2) {
const { patchFlag, dynamicProps } = n2
if (patchFlag > 0) {
if (patchFlag & PatchFlags.CLASS) {
// 只更新 class,不碰 style/其他 props
hostPatchProp(el, 'class', null, n2.props!.class)
}
if (patchFlag & PatchFlags.TEXT) {
// 只更新文本,直接 textContent 赋值
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
// 跳过所有其他检查
} else if (patchFlag === PatchFlags.BAIL || patchFlag === 0) {
// 全量 diff:对比所有 props
patchProps(el, n2, oldProps, newProps)
}
}
VNode 对象的内存布局分析
一个典型的元素 VNode 对象占用内存估算:
VNode 内存布局(V8 64位环境):
字段 类型 大小(估算)
─────────────────────────────────────────────
__v_isVNode boolean 1 byte(对象头中标志位)
__v_skip boolean 1 byte
type pointer 8 bytes(指向字符串或对象)
key pointer 8 bytes
ref pointer 8 bytes
props pointer 8 bytes(指向 props 对象)
children pointer 8 bytes
component null 8 bytes
el null 8 bytes
shapeFlag int32 4 bytes ← 位掩码的价值所在
patchFlag int32 4 bytes
dynamicProps null 8 bytes
dynamicChildren null 8 bytes
─────────────────────────────────────────────
对象头 + 字段: 约 120-160 bytes
若改用 10 个独立 boolean 字段:
isElement/isComponent/.../ 每个 boolean 在对象中占 8 bytes(指针槽)
额外增加: 10 × 8 = 80 bytes
源码中 shapeFlag 的设置链路
// packages/runtime-core/src/vnode.ts(关键代码路径)
export function createBaseVNode(type, props, children, ...) {
const vnode: VNode = {
// ...
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 规范化后更新 shapeFlag
normalizeChildren(vnode, children)
// normalizeChildren 内部:
// vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN (若 children 是字符串)
// vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN (若 children 是数组)
// vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN (若 children 是对象)
return vnode
}
为什么 COMPONENT = STATEFUL | FUNCTIONAL = 6
这个合并值使渲染器可以用一次位运算同时匹配两种组件,而不需要两次检查:
// 不好的写法(两次检查):
if (
(shapeFlag & ShapeFlags.STATEFUL_COMPONENT) ||
(shapeFlag & ShapeFlags.FUNCTIONAL_COMPONENT)
) { ... }
// Vue 3 的实际写法(一次检查):
if (shapeFlag & ShapeFlags.COMPONENT) {
// ShapeFlags.COMPONENT = 6 = 0110
// STATEFUL(4) = 0100 → 0100 & 0110 = 0100 ≠ 0 → true
// FUNCTIONAL(2) = 0010 → 0010 & 0110 = 0010 ≠ 0 → true
// ELEMENT(1) = 0001 → 0001 & 0110 = 0000 = 0 → false
}
这是位掩码设计的精髓:精心选择的位分配可以让"分组检查"用一次操作完成。
Level 4 · 边界与陷阱(全体适用)
陷阱1:直接修改 VNode 对象的属性
// 危险:直接修改 VNode 会导致不可预测的行为
const vnode = h('div', { class: 'box' })
vnode.props.class = 'other' // 永远不要这样做!
// 原因:
// 1. VNode 可能是静态提升的(HOISTED),所有渲染共享同一个对象
// 2. 直接修改会绕过响应式系统,下次更新时对比结果错乱
// 3. patchFlag 已经告诉渲染器哪些字段是静态的,直接修改打破这个约定
// 正确做法:使用 cloneVNode
const modified = cloneVNode(vnode, { class: 'other' })
陷阱2:在异步函数中使用 h() 构建 VNode 树
// 问题:异步环境中,currentBlock 可能指向错误的 Block
async function fetchAndRender() {
const data = await fetch('/api/data')
// ⚠️ 此处 await 之后,openBlock/createBlock 的上下文已丢失
return h('div', data.json())
}
// 更安全的写法:在 setup() 同步阶段使用响应式数据
const data = ref(null)
onMounted(async () => {
data.value = await fetch('/api/data').then(r => r.json())
})
// 模板中用 v-if="data" 处理异步状态
陷阱3:误解 key 的作用域
<template>
<!-- 错误:两个不同的 v-for 使用相同的 key 值不会互相影响 -->
<!-- key 只在同一个父节点的兄弟节点之间起作用 -->
<div v-for="item in list1" :key="item.id">{{ item.name }}</div>
<div v-for="item in list2" :key="item.id">{{ item.name }}</div>
<!-- 上面两个 v-for 的 key 互不影响,即使值相同也没问题 -->
<!-- 错误:以为给父元素加 key 能重置子组件 -->
<div key="parent-key">
<MyComponent /> <!-- key 在 div 上,MyComponent 不会重置 -->
</div>
<!-- 正确:要重置子组件,key 必须直接加在组件上 -->
<MyComponent :key="someValue" />
</template>
陷阱4:静态提升后的 VNode 被 cloneVNode 破坏
// 编译器对静态节点做了提升(hoisting)
// _hoisted_1 在模块顶层只创建一次
const _hoisted_1 = createElementVNode("div", { class: "static-box" }, "永远不变")
// _hoisted_1.patchFlag === PatchFlags.HOISTED === -1
// 如果你用 cloneVNode 修改它:
const cloned = cloneVNode(_hoisted_1, { 'data-extra': 'value' })
// cloned.patchFlag 会变成 PatchFlags.BAIL(-2)
// 这意味着这个克隆版本每次更新都会做全量 diff
// 性能上不如直接在模板中声明动态属性
陷阱5:函数式组件的 shapeFlag 误判
// 陷阱:有时组件定义的写法决定了 shapeFlag
const Comp1 = { render() { return h('div') } } // shapeFlag: STATEFUL_COMPONENT(4)
const Comp2 = (props) => h('div') // shapeFlag: FUNCTIONAL_COMPONENT(2)
const Comp3 = defineComponent({ render() { return h('div') } }) // shapeFlag: STATEFUL_COMPONENT(4)
// defineComponent 始终返回传入的对象(isObject → STATEFUL)
// 即使你传入了一个函数:
const Comp4 = defineComponent((props) => () => h('div'))
// setup 函数返回渲染函数,但 Comp4 本身是 defineComponent 包装后的对象
// shapeFlag 仍然是 STATEFUL_COMPONENT(4),不是 FUNCTIONAL_COMPONENT(2)
陷阱6:dynamicChildren 为 null 时的误判
// cloneVNode 后 dynamicChildren 被清空
const original = createElementVNode('div', null, 'text', PatchFlags.TEXT)
// original.dynamicChildren = [...] (由当前 Block 收集)
const cloned = cloneVNode(original)
// cloned.dynamicChildren = null ← 清空了!
// cloned.patchFlag = original.patchFlag | PatchFlags.BAIL = -2
// 问题:如果你把 cloned 放入 openBlock/createBlock 的上下文中
// 它的 dynamicChildren 是 null,Block 优化不起作用
// 渲染器会退回到全量 diff 模式
本章小结
-
VNode 是一帧的施工图:平均 11 个字段,
type决定渲染路径,shapeFlag用位掩码编码类型信息,patchFlag告诉渲染器哪些部分是动态的。 -
ShapeFlag 位掩码的价值:一个 int32 字段替代了 10 个 boolean,节省了约 80 字节的对象体积,并让类型检查从属性访问变成了单次位运算(比
===快 2-5 倍在高频路径中)。 -
三种创建函数的定位:
h()是用户层(做最多规范化),createVNode()是内部层,createElementVNode()是编译器专用快速路径(跳过类型检查),理解这个层级有助于优化手写 render 函数的性能。 -
Fragment 的 DOM 锚点机制:Fragment VNode 通过
el(起始锚点)和anchor(结束锚点)两个空文本节点标记自己在 DOM 中的边界,这使得 Fragment 可以在不知道父节点的情况下被移除和移动。 -
永远不要直接修改 VNode:VNode 是不可变的施工图,静态提升的 VNode 在所有渲染周期共享,直接修改会破坏
patchFlag约定并导致渲染器行为异常;需要修改时使用cloneVNode()。