前言
Vue 3 是一个结合了编译时和运行时的框架。在编译时,我们编写的模板代码会被转换为一个 render 函数,该函数的返回值是一个虚拟节点。而在运行时,核心工作就是将虚拟节点转换为真实节点,然后根据情况对 DOM 树进行挂载或更新。前面的文章已经分析了虚拟节点转换为真实节点的核心流程,但是有些细节并没有讲解。这是因为这些内容和本文的主题 Block Tree
和 PatchFlags
相关,没有这些背景知识很难理解那些内容。
本文将从一段模板代码开始,并将模板代码和对应的编译结果进行比较。接着,引出了虚拟节点的 PatchFlags
属性值,并在 patchFlag
机制的基础上,讲解了 dynamicChildren
属性存在的意义。然后,分析为虚拟节点添加 dynamicChildren 属性值的过程,也就是 Block
机制。有了 Block
机制,我们又继续探讨 Block 机制的缺陷,进而分析 Block Tree
。
1. 模版编译
Diff 算法 无法避免新旧虚拟 DOM 中无用的比较 操作,通过 patchFlags
来标记动态内容 ,可以实现快速 diff 算法
html
<!-- 代码示例1 -->
<div>
<h1>Hello Zhang</h1>
<span>{{name}}</span>
</div>
此
template
经过模板编译会变成以下代码:
js
// 代码示例2
import {
createElementVNode as _createElementVNode,
toDisplayString as _toDisplayString,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock("div", null, [
_createElementVNode("h1", null, "Hello Zhang"),
_createElementVNode(
"span",
null,
_toDisplayString(_ctx.name),
1 /* TEXT */
),
])
);
}
// Check the console for the AST
Vue3 官方提供了模版编译成的 render 渲染函数:Vue 3 Template Explorer,大家也可以访问试试
我们使用runtime-dom
包来看下render
函数执行后返回的虚拟节点是怎么样的
这里直接使用打包后的runtime-dom.esm-browser.js。
大家可以直接下载下来,由于 type="module"
的限制,需要在本地启动一个服务器,然后在浏览器中访问该 HTML 页面。在控制台中,可以查看打印的调用该 render 函数生成的虚拟节点的结果。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script type="module">
import {
toDisplayString as _toDisplayString,
createElementVNode as _createElementVNode,
Fragment as _Fragment,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
renderList as _renderList,
createCommentVNode as _createCommentVNode,
createTextVNode as _createTextVNode,
} from "./runtime-dom.esm-browser.js";
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock("div", null, [
_createElementVNode("h1", null, "Hello Zhang"),
_createElementVNode(
"span",
null,
_toDisplayString(_ctx.name),
1 /* TEXT */
),
])
);
}
let vNode = render({ a: "a", b: "b" });
console.log(vNode);
</script>
</body>
</html>
关于上面代码中使用到的 api,初次看到的人可能会感到疑惑,脑海中会出现一些问题,例如:_createElementVNode
是用来做什么的?_createElementBlock
又是用来做什么的?openBlock
又是用来做什么的?以及 1 /* TEXT */
代表什么意思?
如果你对这些问题感到困惑,先不要着急,接下来的内容将会为你揭示这些地方背后的工作原理,让你豁然开朗。
2. render 函数
关于模板代码是如何被转换成渲染函数的,我们会在后续的文章中进行详细分析。现在,我们先来看看这个编译后的渲染函数究竟做了什么,或者说应该做什么。事实上,正如我们之前在文章中提到的那样,Vue 3 最核心的工作流程就是将模板代码 转换为可以返回虚拟节点的渲染函数 ,以及将虚拟节点 转换为真实节点 。而渲染函数中自然就是返回一个虚拟节点对象。
我们可以看下渲染函数中调用的函数 _createElementVNode
,发现这个函数其实就是用来创建虚拟节点 的。但是你可能会感到困惑,因为创建虚拟节点 的函数其实就是返回一个对象,这很好理解,这个对象可以描述一个 DOM 节点
,而且也不难理解 DOM 节点
有子节点,这里的虚拟节点 也有子虚拟节点 ,因此函数 _createElementVNode
的第三个参数是一个数组,这个数组中的每一个元素都是调用函数 _createElementVNode
来创建的子虚拟节点。
这些内容都很容易理解。但是有的同学会问了:在模板代码中,有一个根节点,但在渲染函数中,我们似乎只创建了子节点,那么根节点是由谁来创建的呢?
我们可以再看看上面的渲染函数,发现函数 _createElementBlock
的参数和函数 _createElementVNode
的参数几乎是一模一样的。没错,我们可以认为 _createElementBlock
的功能也是创建虚拟节点。
这样,我们知道了渲染函数的核心任务就是返回虚拟节点 ,并且也知道了所谓的虚拟节点其实就是一个描述 DOM 节点的对象 ,而函数 _createElementVNode
和 _createElementBlock
都具备创建该对象的能力。但是,由于这两个创建虚拟节点的函数名称都有差异,背后肯定也存在着深刻的原因。这正是我们接下来需要讨论的主题------PatchFlags
和 Block Tree
的深刻联系。
3. PatchFlags
优化
js
let vNode = render({ a: "a", b: "b" });
console.log(vNode);
我们看看上面渲染函数生成的虚拟 dom 结构是怎样的
生成的虚拟 DOM 是:
json
{
"__v_isVNode": true,
"__v_skip": true,
"type": "div",
"props": null,
"key": null,
"ref": null,
"scopeId": null,
"slotScopeIds": null,
"children": [{...}, {...}],
"dynamicChildren": [{...}],
"component": null,
"suspense": null,
"ssContent": null,
"ssFallback": null,
"dirs": null,
"transition": null,
"el": null,
"anchor": null,
"target": null,
"targetAnchor": null,
"staticCount": 0,
"shapeFlag": 17,
"patchFlag": 0,
"dynamicProps": null,
"appContext": null
}
我们可以发现虚拟 Node 有一个属性叫patchFlag
。其实在代码中有个PatchFlags
枚举如下:
1.1 动态标识
js
export const enum PatchFlags {
TEXT = 1, // 动态文本节点
CLASS = 1 << 1, // 动态class
STYLE = 1 << 2, // 动态style
PROPS = 1 << 3, // 除了class\style动态 props 的节点
FULL_PROPS = 1 << 4, // 有key,需要完整diff
HYDRATE_EVENTS = 1 << 5, // 挂载过事件的
STABLE_FRAGMENT = 1 << 6, // 稳定序列,子节点顺序不会发生变化
KEYED_FRAGMENT = 1 << 7, // 子节点有key的fragment
UNKEYED_FRAGMENT = 1 << 8, // 子节点没有key的fragment
NEED_PATCH = 1 << 9, // 进行非props比较, ref比较
DYNAMIC_SLOTS = 1 << 10, // 动态插槽
DEV_ROOT_FRAGMENT = 1 << 11,
HOISTED = -1, // 表示静态节点,内容变化,不比较儿子
BAIL = -2 // 表示diff算法应该结束
}
需要了解的是,除了 HOISTED
和 BAIL
,其他所有的值都代表着虚拟节点所代表的节点是动态的。所谓动态的,就是可能发生变化的。比如 <div>abc</div>
这样的节点就不是动态的,里面没有响应式元素,正常情况下是不会发生变化的。在 patch
过程中对其进行比较是没有意义的。因此,Vue 3
对虚拟节点打上标记,如果节点的标记大于 0,则说明在 patch
的时候需要比较新旧虚拟节点的差异进行更新。
这时候有的同学会问了,如果只是区分节点是否是动态的,直接打上标记大于 0 或者小于 0 不就行了吗?为什么要使用十几个枚举值来表示呀?
这个问题问得很好。在回答这个问题之前,我们先问大家另外一个问题:假设让我们来比较两个节点有什么差异,你会怎么比较呢?
面对这个问题,按照正常的思维,既然要比较两个事物是否有差异,就得看两个事物的各组成部分 是否有差异。我们知道虚拟节点有标签名、类型名、事件名等各种属性名,同时还有子节点,子节点又可能有子节点。那么要比较两个虚拟节点的差异,就得逐个属性逐级进行比较。而这样必然导致全部属性遍历,性能不可避免的会很低效。
Vue 3 的作者不仅标记某个虚拟节点是否动态,而且精准地标记具体是哪个属性是动态的。这样在进行更新的时候,只需要定向查找相应属性的状态。比如,patchFlag
的值如果包含的状态是 CLASS 对应的值 1<<1
,则直接比对新旧虚拟节点的 class
属性的值的变化。注意,由于 patchFlag
是采用位运算的方式进行赋值,结合枚举类型 PatchFlags
,patchFlag
可以同时表示多种状态。也就是说,可以表示 class
属性是动态的,也可以表示 style
属性是动态的。
我们发现,虽然已经精准地标记了虚拟节点的动态属性,甚至标识到了具体哪个属性的维度。但是仍然无法避免递归整个虚拟节点树。作为追求极致的工程师们,又创造性地想到了利用 Block
的机制来规避全量对虚拟节点树进行递归。
4. Block
在讲解 Block
机制之前,我们可以先尝试想一下,如果让我们自己来想办法规避全量比较虚拟节点,我们会怎么做呢?也许有的同学会想到,能不能把那些动态的节点单独放到一个地方进行维护呢?这样,新旧虚拟节点的动态节点就可以在同一个地方进行比较,我们再看看下面这个虚拟节点数据:
json
{
"__v_isVNode": true,
"__v_skip": true,
"type": "div",
"props": null,
"key": null,
"ref": null,
"scopeId": null,
"slotScopeIds": null,
"children": [{...}, {...}],
"dynamicChildren": [{...}],
"component": null,
"suspense": null,
"ssContent": null,
"ssFallback": null,
"dirs": null,
"transition": null,
"el": null,
"anchor": null,
"target": null,
"targetAnchor": null,
"staticCount": 0,
"shapeFlag": 17,
"patchFlag": 0,
"dynamicProps": null,
"appContext": null
}
可以看到有一个 dynamicChildren
属性。一般的虚拟节点是没有这个属性的,因为我们之前说过,虚拟节点是用来描述 DOM 节点的对象,而 DOM 节点是没有一个叫做 dynamicChildren
的属性的。那么这个属性有什么用呢?还记得我们在分析 patchElement
函数的时候,有这样一段代码:
js
// 代码示例5
if (dynamicChildren) {
patchBlockChildren(
n1.dynamicChildren!,
dynamicChildren,
el,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds
)
if (__DEV__) {
// necessary for HMR
traverseStaticChildren(n1, n2)
}
} else if (!optimized) {
// full diff
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
)
}
我们来看看函数patchBlockChildren
的具体实现:
js
// 代码示例6
// 用于处理动态节点的更新
const patchBlockChildren: PatchBlockChildrenFn = (
oldChildren, // 旧的虚拟节点数组
newChildren, // 新的虚拟节点数组
fallbackContainer, // 回退容器
parentComponent, // 父组件
parentSuspense, // 父 suspense
isSVG, // 是否为 SVG 元素
slotScopeIds // 插槽作用域 ID
) => {
// 遍历新的虚拟节点数组
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i] // 获取旧的虚拟节点
const newVNode = newChildren[i] // 获取新的虚拟节点
// 确定要进行更新的容器(父元素)
const container =
// oldVNode 可能是一个错误的异步 setup() 组件,位于 suspense 内部,
// 它将不会有一个已挂载的元素
oldVNode.el &&
// - 对于 Fragment,我们需要提供 Fragment 本身的实际父级
// 以便它可以移动其子级。
(oldVNode.type === Fragment ||
// - 对于不同的节点,将会有一个替换,
// 这也需要正确的父容器
!isSameVNodeType(oldVNode, newVNode) ||
// - 对于组件,它可以包含任何内容。
oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
? hostParentNode(oldVNode.el)!
: // 在其他情况下,父容器实际上并没有被使用,因此我们
// 只需在此处传递块元素以避免 DOM parentNode 调用。
fallbackContainer
// 调用 patch 函数进行更新操作
patch(
oldVNode,
newVNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
true
)
}
}
patchElement 函数的逻辑还是挺简单的,先对新旧虚拟节点的 dynamicChildren
属性所代表的虚拟节点数组进行遍历,然后调用 patch
函数进行更新操作。
由此可以看出性能被大幅度提升,从 tree 级别的比对,变成了线性结构比对。
从【代码示例 5】 中可以发现,如果属性 dynamicChildren
有值,则不会执行 patchChildren
函数进行比较新旧虚拟节点的差异并进行更新。有的同学问了,为什么可以直接比较虚拟节点的 dynamicChildren
属性对应的数组元素,就可以完成更新呢?
我们知道,dynamicChildren
中存放的是所有的代表动态节点的虚拟节点,而且从【代码示例 1】中可以看出,dynamicChildren
记录的动态节点不仅包括自己所属层级的动态节点,也包括子级的动态节点,也就是说根节点内部所有的动态节点都会收集在 dynamicChildren
中。由于新旧虚拟节点的根节点下都有 dynamicChildren
属性,都保存了所有的动态元素对应的值,也就是说动态节点的顺序是一一对应的。因此,在【代码示例 6】中,不再需要深度递归去寻找节点间的差异,而是简单的线性遍历并执行 patch
函数就可以完成节点的更新。
那么,这个 dynamicChildren
属性是如何赋值的呢?
还记得【代码示例 2】中,让我们倍感疑惑的两个函数_openBlock 和_createElementBlock 吗。我们来探索这两个函数的内部实现:
js
export function openBlock(disableTracking = false) {
blockStack.push((currentBlock = disableTracking ? null : []));
}
我们可以看到 openBlock 函数的逻辑非常简单,它只是给数组 blockStack
添加一个元素,该元素可能为 null
或者空数组 []
。
再来看看 createElementBlock
是做什么的
js
// 定义 setupBlock 函数,用于设置 Block 相关的信息
function setupBlock(vnode: VNode) {
// 在 Block 虚拟节点上保存当前 Block 的子节点
vnode.dynamicChildren =
isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
// 关闭当前 Block
closeBlock()
// 由于 Block 一定会被更新,因此将其作为其父 Block 的子节点进行跟踪
if (isBlockTreeEnabled > 0 && currentBlock) {
currentBlock.push(vnode)
}
return vnode
}
/**
* @private
*/
// 定义 createElementBlock 函数,用于创建 Block 类型的虚拟节点
export function createElementBlock(
type: string | typeof Fragment, // 节点类型
props?: Record<string, any> | null, // 节点属性
children?: any, // 子节点
patchFlag?: number, // 补丁标志
dynamicProps?: string[], // 动态属性
shapeFlag?: number // 节点类型标志
) {
return setupBlock(
createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
true /* isBlock */ // 标记为 Block 类型
)
)
}
调用了一个函数 createBaseVNode,该函数用于创建虚拟节点对,那这里的函数 setupBlock 发挥了什么作用呢?而 可以概括为以下三个作用:
-
在虚拟节点创建完成后,给该虚拟节点的 dynamicChildren 属性赋值,赋的值为 currentBlock。我们知道,currentBlock 是在调用 openBlock 函数的时候初始化的一个数组。
-
调用 closeBlock 函数的作用是将调用 openBlock 时初始化的数组对象 currentBlock 移除,并将 currentBlock 赋值为 blockStack 的最后一个元素。
js
// 代码示例9
export function closeBlock() {
blockStack.pop();
// 关闭block
currentBlock = blockStack[blockStack.length - 1] || null;
}
- 执行语句 currentBlock.push(vnode),将当前创建的节点自身添加到上一级(因为 closeBlock 的时候已经 pop 出刚刚创建完成的虚拟节点所在的 currentBlock)currentBlock 中。
描述了上面 3 点,可能大家觉得有些疑惑,上面的描述和代码虽然很一致,但是究竟发挥了什么作用呢?我们先将源码实现进行精简,在下文讨论Block Tree
的时候再回过头看代码示例 7 到代码示例 9 的代码:
js
// 代码示例10
export function createElementBlock(
type: string | typeof Fragment,
props?: Record<string, any> | null,
children?: any,
patchFlag?: number,
dynamicProps?: string[],
shapeFlag?: number
) {
return setupBlock(
createBaseVNode(/*此处省略若干参数*/)
)
}
function createBaseVNode(/* ...*/) {
const vnode = { /* ...*/} as VNode
if (/*如果是动态元素*/) {
currentBlock.push(vnode)
}
return vnode
}
function setupBlock(vnode: VNode) {
vnode.dynamicChildren = currentBlock
return vnode
}
4.1 Block 存在的问题
上面已经了解了,dynamicChildren 的赋值过程能够提高我们更新 DOM 元素的效率。但是,这里面还是存在一些棘手的问题。关键问题在于,当 DOM 结构不稳定时,我们无法像【代码示例 6】那样更新元素。
因为如果我们想通过遍历数组的方式调用 patch 函数来更新元素,那么新旧虚拟 Node 的 dynamicChildren 元素必须是一一对应的。这是因为只有当新旧虚拟 Node 是同一个元素时,依次调用 patch 进行更新才有意义。但如果新旧虚拟 Node 的dynamicChildren
元素不能一一对应,那我们就无法用这种方式来更新。
然而,我们平常写的的模版代码中,有很多可能会改变 DOM 树结构的指令,比如v-if、v-else、v-else-if、v-for
等(具体的指令下面介绍)。例如,下面的模板:
html
<!--代码示例11-->
<div>
<div v-if="flag">
<div>{{name}}</div>
<div>{{age}}</div>
</div>
<div v-else>
<div>{{city}}</div>
</div>
<div v-for="item in arr">{{item}}</div>
</div>
当 flag 的值改变时,收集的动态节点数量会有所不同,同时,不同的虚拟 Node 对应的真实 DOM 也会有所不同。因此,如果我们试图像在代码片段 6 中那样直接遍历并更新,这种方法就无法生效。
让我们来举个例子。当 flag 为 true 时,动态节点中包含{{name}}
和{{age}}
所在的 div。但是,当条件发生变化后,新的虚拟 Node 收集的动态节点变成了{{city}}
所在的 div。在进行遍历比较时,会用{{city}}
所在 div 的虚拟 Node 去和{{name}}
所在 div 的虚拟 Node 进行比较和更新。但问题在于,{{name}}
所在 div 的虚拟 Node 的 el 属性是节点<div>{{name}}</div>
,而这个节点因为条件的变化已经消失了。所以,即使我们对这个节点进行了更新,浏览器页面也不会有任何变化。
4.2 Block Tree
为什么我们还要提出 blockTree
的概念? 只有 block
不就挺好的么? 问题出在 block
在收集动态节点时是忽略虚拟 DOM 树层级的。
为了解决仅使用 Block 来提升更新性能时出现的问题,就有了Block Tree
。所谓的Block Tree
,其实就是将那些可能发生 DOM 结构变化的地方也作为一个动态节点进行收集。实际上,【代码示例 6】到【代码示例 9】之间维护的全局栈结构,就是为了配合Block Tree
这种机制的正常运作。
我们来看一个具体的例子:
html
<!--代码示例12-->
<div>
<div>{{name}}</div>
<div v-for="(item,index) in arr" :key="index">{{item}}</div>
</div>
我们来看一下这个 render 函数的返回值。为了方便阅读,我做了大量精简,关键信息如下
js
{
"type": "div",
"children": [
{
"type": "div",
"key": null,
"children": "james",
"staticCount": 0,
"shapeFlag": 9,
"patchFlag": 1,
"dynamicProps": null,
"dynamicChildren": null,
},
{
"key": null,
"slotScopeIds": null,
"children": [
{
"type": "div",
"key": 0,
"children": "aaa",
"props": {
"key": 0
},
"patchFlag": 1,
},
{
"type": "div",
"key": 0,
"children": "bbb",
"props": {
"key": 0
},
"patchFlag": 1,
},
{
"type": "div",
"key": 2,
"children": "ccc",
"props": {
"key": 2
},
"patchFlag": 1,
}
],
}
],
"patchFlag": 0,
"dynamicChildren": [
{
"type": "div",
"children": "james",
"shapeFlag": 9,
"patchFlag": 1,
"dynamicProps": null,
"dynamicChildren": null,
},
{
"props": null,
"key": null,
"children": [
{
"type": "div",
"key": 0,
"children": "aaa",
"props": {
"key": 0
},
"patchFlag": 1,
},
{
"type": "div",
"key": 1,
"children": "bbb",
"props": {
"key": 1
},
"patchFlag": 1,
},
{
"type": "div",
"key": 2,
"children": "ccc",
"props": {
"key": 2
},
"patchFlag": 1,
}
],
}
],
"appContext": null
}
我们可以看到,根节点下有一个dynamicChildren
属性值,这个属性对应的数组有两个元素。一个元素对应{{name}}
所在的 div,另一个元素对应 for 循环的外层节点。这个外层节点的dynamicChildren
是一个空数组,原因是我们无法保证里面的元素数量的一致性,也就无法通过循环遍历,让新旧虚拟节点一一对应进行更新。因此,我们只能正常比较 children 下的元素。
我们再来看看这种场景的模版代码:
html
<div>
<p v-if="flag">
<span>{{a}}</span>
</p>
<div v-else>
<span>{{a}}</span>
</div>
</div>
这里我们知道默认根节点是一个
block
节点,如果要是按照之前的套路来搞,这时候切换flag
的状态将无法从p
标签切换到div
标签。 解决方案:就是将不稳定的结构也作为 block 来进行处理
4.3 不稳定结构
所谓的不稳定结构 就是 DOM 树的结构可能会发生变化 。不稳定结构有哪些呢? (v-if/v-for/Fragment
)
4.3.1 v-if
html
<div>
<div v-if="flag">
<span>{{a}}</span>
</div>
<div v-else>
<p><span>{{a}}</span></p>
</div>
</div>
使用template-explore查看编译后的结果:
js
import {
toDisplayString as _toDisplayString,
createElementVNode as _createElementVNode,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
createCommentVNode as _createCommentVNode,
} from "vue";
const _hoisted_1 = { key: 0 };
const _hoisted_2 = { key: 1 };
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock("div", null, [
_ctx.flag
? (_openBlock(),
_createElementBlock("div", _hoisted_1, [
_createElementVNode(
"span",
null,
_toDisplayString(_ctx.a),
1 /* TEXT */
),
]))
: (_openBlock(),
_createElementBlock("div", _hoisted_2, [
_createElementVNode("p", null, [
_createElementVNode(
"span",
null,
_toDisplayString(_ctx.a),
1 /* TEXT */
),
]),
])),
])
);
}
// Check the console for the AST
text
Block(div)
Blcok(div,{key:0})
Block(div,{key:1})
父节点除了会收集动态节点 之外,也会收集子 block 。 更新时因 key
值不同会进行删除重新创建
4.3.2 v-for
随着v-for
变量的变化也会导致虚拟 DOM 树变得不稳定
看看这个简单的循环生成 dom 节点:
html
<div>
<div v-for="item in fruits"></div>
</div>
模板编译成[渲染函数]:
js
import {
renderList as _renderList,
Fragment as _Fragment,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock("div", null, [
(_openBlock(true),
_createElementBlock(
_Fragment,
null,
_renderList(_ctx.fruits, (item) => {
return _openBlock(), _createElementBlock("div");
}),
256 /* UNKEYED_FRAGMENT */
)),
])
);
}
// Check the console for the AST
可以试想一下,如果不增加这个 block(_openBlock)
,前后元素不一致是无法做到靶向更新的。因为 dynamicChildren
中还有可能有其他层级的元素。同时这里还生成了一个 Fragment
,因为前后元素个数不一致,所以称之为不稳定序列。