虚拟dom——virtual dom,提供一种简单js对象去代替复杂的 dom 对象,从而优化 dom 操作。virtual dom 是“解决过多的操作 dom 影响性能”的一种解决方案。virtual dom 很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达到平衡。
diff 算法是一种优化手段,将前后两个模块进行差异化比较,修补(更新)差异的过程叫做 patch,也叫打补丁。只有当新旧子节点的类型都是多个子节点时,核心
Diff
算法才派得上用场。diff的目的是时间换空间:尽可能通过移动旧节点,复用旧节点DOM元素,减少新增DOM操作。通过首首、尾尾、首尾、尾首以及在旧节点列表遍历等方式逐个试探去找可复用的旧节点。vue3引入了最长递增子序列优化diff:去掉相同的前缀和后缀,也就是首首、尾尾都比较完后剩余的旧节点列表和新节点列表进行diff。在新节点列表中用一个数组,统计新节点出现在旧节点相同元素的index;对这个数组求最长递增子序列。递增子序列的节点不需要移动(即使不连续),因为它们在新旧节点序列中的相对位置是一样的。
diff算法目的
在页面数据发生变化,进行组件更新的时候,产生了新节点虚拟dom。这个时候需要将新节点的dom渲染为真实的dom。核心diff算法就是在已知旧节点的DOM结构、旧节点vnode和新子节点的vnode情况下,以较低的成本完成子节点的更新为目的,求解生成子节点dom的系列操作。
如果我将新的虚拟dom转为真实dom,这个过程消耗性能。如果能复用老节点的dom节点,是不是可以减少dom操作啊。
那对于新旧两个dom树而言,如何比较呢?
vue采用的是深度递归+同层比较。深度递归能保证每个节点都能遍历到,同层比较能够缩小比较范围。
diff算法比较流程
比较是否是相同节点;相同节点比较属性,并复用老节点;
如果是相同节点,考虑老节点和新节点的儿子节点情况:
- 老的没有儿子,新的有儿子,将新的儿子节点挂载给老节点;
- 老的有儿子,新的没儿子。删除页面节点;
- 老的有儿子,新的有儿子,但都是文本节点,直接更新文本节点;
- 老的儿子是一个列表,新的儿子也是一个列表。核心diff,双端比较。
vue双端比较
所谓
双端比较
就是新列表和旧列表两个列表的头与尾互相对比,在对比的过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。
思考:为什么需要头头、尾尾、头尾、尾头四种比较?
在看diff算法是时候一定要有一个目标——我要尽可能在旧节点列表中找到一个可以复用的节点!这样就可以复用老节点的数据,而避免了新增dom。为什么使用这四种方式?考虑了下面新旧节点列表可能排列的情况:元素的追加、删除、向左翻转、向右翻转、逆序排列等方式
diff的整个优化采用了双指针的方式,在新老节点列表的头尾插了两个指针。在比较的过程中如果新老节点有一方头尾指针重合了,意味着节点遍历结束。这个时候需要终止diff算法。在比较过程中,如果头指针新老节点相等,头指针向后移动,头指针++;如果尾指针相等,尾指针向前移动,尾指针--。
头头比较
看下下面这种场景,尾部插入数据。仅使用头和头比较,就能找到复用的节点,且不需要移动旧节点。如果新节点有多余元素,将新节点中多余部分插入到dom里。如果老节点有多余元素,删除老节点多余节点。
尾尾比较
头部插入数据,只需要尾尾进行比较。当某一方头尾指针重合了结束。此时如果新节点元素多,将多余的元素一起新增dom操作。其余复用老节点。如果老节点多,同理,将多余的老节点删除
尾头比较
如果头和头,尾和尾都比较不成功,还是尝试从两端找相同节点。此时是不是可以交叉比较。这里举一个尾头比较的例子:
旧节点尾指针指向的D和新节点头指针指向的D相等。此时D是不是要找相对位置,要与新节点保持一致,那么就移动旧节点D到旧节点的头部。保持列表顺序一致。
li-d
节点所对应的真实 DOM 原本是最后一个子节点,并且更新之后它应该变成第一个子节点。所以我们需要把li-d
所对应的真实 DOM 移动到最前方即可 。
由于 li-d
节点所对应的真实 DOM 元素已经更新完成且被移动,所以现在真实 DOM 的顺序是:li-d
、li-a
、li-b
、li-c
,如下图所示:
头尾比较
如果前面三种都找不到相同节点,看头尾能否找到。在下面的例子中旧节点的头和新节点的尾相等。此时移动旧节点的A到旧节点尾部,与新节点的相对位置保持一致。之后头指针向后++,尾指针向前--。
双端非理想乱序——key映射表查找老节点
在之前的讲解中,我们所采用的是较理想的例子,换句话说,在每一轮的比对过程中,总会满足四个步骤中的一步,但实际上大多数情况下并不会这么理想,如下图所示:
可以根据节点的key建立一个旧节点key的映射表。然后用新节点头指针newStartVnode遍历新节点,根据新节点从映射表里找旧节点,如果找到了就复用,找不到就创建。复用老节点,同时移动老节点,这个时候节点在列表中间,只需要 将找到的元素,比如上图将b移动到a前面,同时原有的b要设为undefined,因为要保留占位。处理完,新节点头指针++
考虑过程中出现节点会变成为undefined,所以找旧节点时要加判断,如果是undefined就跳过
新老节点列表长度不相等
如果新老节点有一方长度不相等;循环结束后,肯定有一方多余。这个时候要判断是新节点多了还是老节点多了。多余的节点在startIndex和endIndex中间,所以判断哪个的startIndex>oldIndex。如果老节点多了就删除,如果新节点多了就插入。
diff结束条件
newStartIdx
>newEndIdx
保证新节点遍历结束
长度及序列相等
旧节点有多余
diff伪代码
function vue2diff(prevChildren, nextChildren, parent) {
let oldStartIndex = 0,
newStartIndex = 0,
oldStartIndex = prevChildren.length - 1,
newStartIndex = nextChildren.length - 1,
oldStartNode = prevChildren[oldStartIndex],
oldEndNode = prevChildren[oldStartIndex],
newStartNode = nextChildren[newStartIndex],
newEndNode = nextChildren[newStartIndex];
while (oldStartIndex <= oldStartIndex && newStartIndex <= newStartIndex) {
if (oldStartNode === undefined) {
oldStartNode = prevChildren[++oldStartIndex]
} else if (oldEndNode === undefined) {
oldEndNode = prevChildren[--oldStartIndex]
} else if (oldStartNode.key === newStartNode.key) {
patch(oldStartNode, newStartNode, parent)
oldStartIndex++
newStartIndex++
oldStartNode = prevChildren[oldStartIndex]
newStartNode = nextChildren[newStartIndex]
} else if (oldEndNode.key === newEndNode.key) {
patch(oldEndNode, newEndNode, parent)
oldStartIndex--
newStartIndex--
oldEndNode = prevChildren[oldStartIndex]
newEndNode = nextChildren[newStartIndex]
} else if (oldStartNode.key === newEndNode.key) {
patch(oldStartNode, newEndNode, parent)
parent.insertBefore(oldStartNode.el, oldEndNode.el.nextSibling)
oldStartIndex++
newStartIndex--
oldStartNode = prevChildren[oldStartIndex]
newEndNode = nextChildren[newStartIndex]
} else if (oldEndNode.key === newStartNode.key) {
patch(oldEndNode, newStartNode, parent)
parent.insertBefore(oldEndNode.el, oldStartNode.el)
oldStartIndex--
newStartIndex++
oldEndNode = prevChildren[oldStartIndex]
newStartNode = nextChildren[newStartIndex]
} else {
let newKey = newStartNode.key,
oldIndex = prevChildren.findIndex(child => child && (child.key === newKey));
if (oldIndex === -1) {
mount(newStartNode, parent, oldStartNode.el)
} else {
let prevNode = prevChildren[oldIndex]
patch(prevNode, newStartNode, parent)
parent.insertBefore(prevNode.el, oldStartNode.el)
prevChildren[oldIndex] = undefined
}
newStartIndex++
newStartNode = nextChildren[newStartIndex]
}
}
if (newStartIndex > newStartIndex) {
while (oldStartIndex <= oldStartIndex) {
if (!prevChildren[oldStartIndex]) {
oldStartIndex++
continue
}
parent.removeChild(prevChildren[oldStartIndex++].el)
}
} else if (oldStartIndex > oldStartIndex) {
while (newStartIndex <= newStartIndex) {
mount(nextChildren[newStartIndex++], parent, oldStartNode.el)
}
}
}
vue2源码分析
源码地址vue-main\src\core\vdom\patch.ts
当数据发生改变时,订阅者watcher
就会调用patch
给真实的DOM
打补丁
通过isSameVnode
进行判断,相同则调用patchVnode
方法
patchVnode
做了以下操作:
- 找到对应的真实
dom
,称为el
- 如果都有都有文本节点且不相等,将
el
文本节点设置为Vnode
的文本节点 - 如果
oldVnode
有子节点而VNode
没有,则删除el
子节点 - 如果
oldVnode
没有子节点而VNode
有,则将VNode
的子节点真实化后添加到el
- 如果两者都有子节点,则执行
updateChildren
函数比较子节点
updateChildren
主要做了以下操作:
- 设置新旧
VNode
的头尾指针 - 新旧头尾指针进行比较,循环向中间靠拢,根据情况调用
patchVnode
进行patch
重复流程、调用createElem
创建一个新节点,从哈希表寻找key
一致的VNode
节点再分情况操作
patch方法
patch
函数前两个参数位为oldVnode
和Vnode
,分别代表新的节点和之前的旧节点,主要做了四个判断:
- 没有新节点,直接触发旧节点的
destory
钩子- 没有旧节点,说明是页面刚开始初始化的时候,此时,根本不需要比较了,直接全是新建,所以只调用
createElm
- 旧节点和新节点自身一样,通过
sameVnode
判断节点是否一样,一样时,直接调用patchVnode
去处理这两个节点- 旧节点和新节点自身不一样,当两个节点不一样的时候,直接创建新节点,删除旧节点
核心当然还是新旧节点相同时的patchVnode阶段
patchVnode方法
patchVnode方法,比较两个节点,并且比较两个节点的孩子节点
patchVnode
主要做了几个判断:
- 新节点是否是文本节点,如果是,则直接更新
dom
的文本内容为新节点的文本内容 - 新节点和旧节点如果都有子节点,则处理比较更新子节点
- 只有新节点有子节点,旧节点没有,那么不用比较了,所有节点都是全新的,所以直接全部新建就好了,新建是指创建出所有新
DOM
,并且添加进父节点 - 只有旧节点有子节点而新节点没有,说明更新后的页面,旧节点全部都不见了,那么要做的,就是把所有的旧节点删除,也就是直接把
DOM
删除
子节点不完全一致,则调用updateChildren
updateChildren方法
新节点和旧节点是相同节点。比较新旧的子节点是否相等。如果新节点的chidren是个列表,同时旧节点的children也是个列表。此时调用updateChidren新旧的children的比较移动逻辑。这部分是核心diff
while
循环主要处理了以下五种情景:
- 当新老
VNode
节点的start
相同时,直接patchVnode
,同时新老VNode
节点的开始索引都加 1 - 当新老
VNode
节点的end
相同时,同样直接patchVnode
,同时新老VNode
节点的结束索引都减 1 - 当老
VNode
节点的start
和新VNode
节点的end
相同时,这时候在patchVnode
后,还需要将当前真实dom
节点移动到oldEndVnode
的后面,同时老VNode
节点开始索引加 1,新VNode
节点的结束索引减 1 - 当老
VNode
节点的end
和新VNode
节点的start
相同时,这时候在patchVnode
后,还需要将当前真实dom
节点移动到oldStartVnode
的前面,同时老VNode
节点结束索引减 1,新VNode
节点的开始索引加 1 - 如果都不满足以上四种情形,那说明没有相同的节点可以复用,则会分为以下两种情况:
- 从旧的
VNode
为key
值,对应index
序列为value
值的哈希表中找到与newStartVnode
一致key
的旧的VNode
节点,再进行patchVnode
,同时将这个真实dom
移动到oldStartVnode
对应的真实dom
的前面 - 调用
createElm
创建一个新的dom
节点放到当前newStartIdx
的位置
- 从旧的
vue3 diff算法做了哪些优化?
-
静态树提升(Static Tree Hoisting):Vue3使用静态树提升技术,将静态内容从动态内容中分离出来,并在渲染时只更新动态内容,从而减少不必要的更新操作,提高性能。
-
PatchFlag:引入了
PatchFlag
标志,用于标识虚拟节点的类型和需要进行的操作,从而在更新过程中更快地识别和处理特定类型的更新。 -
Fragments优化:针对不同类型的片段(如稳定片段、带键片段、无键片段等),Vue3采取不同的优化策略,以减少不必要的比较和更新操作。
-
动态插槽优化:针对具有动态插槽的组件,Vue3会进行特殊处理,并始终强制更新这些组件,以确保插槽内容正确渲染。
-
优化的算法逻辑:Vue3对diff算法的逻辑进行了优化,进行前缀后缀处理,并引入最长递增子序列,减少元素移动次数。使得在更新过程中能够更快地定位变化并进行更新,减少不必要的操作。
什么是PatchFlag?
vue3会根据元素是否有动态文本,动态样式,动态类等给不同的元素打上patchFlag标识。在diff算法比较的时候,会针对有patchFlag标识的节点进行比对。在Vue3中,
patchFlag
的值是一个整数,通过位运算来表示不同的更新操作类型。通过检查patchFlag
的值,Vue3可以快速了解虚拟节点需要进行的具体操作,从而优化更新过程。
<div>
<span>hello vue</span>
<span>{{msg}}</span>
<span :class="name">poetry</span>
<span :id="name">poetry</span>
<span :id="name">{{msg}}</span>
<span :id="name" :msg="msg">poetry</span>
</div>
可以看到vue3的模板编译,在动态的节点、样式、属性等节点都加上patchFlag编译后的值。对非动态的节点不加patchFlag。
vue2的模板编译就比较简单了
patchFlag的类型
通过组合不同的补丁标志,可以在差异比较过程中针对特定类型的更新进行优化处理。有下面几种类型:
特殊标志:
TEXT
:1,表示具有动态textContent
(子节点快速路径)的元素。比如{{msg}}CLASS
:1<<1,表示具有动态类绑定的元素。比如“:class='colorStyle'”-
STYLE
:1<<2,表示具有动态样式的元素。编译器会将静态字符串样式预编译为静态对象,并检测并提升内联静态对象。例如,style="color: red"
和:style="{ color: 'red' }"
都会被提升为静态对象{ color: 'red' }
,以便在渲染函数中使用。 -
PROPS
:1<<3,表示具有非类/样式动态属性的元素,或者是具有任何动态属性(包括类/样式)的组件。当存在这个标志时,虚拟节点还会有一个dynamicProps
数组,其中包含可能发生变化的属性键,以便运行时可以更快地进行差异比较(无需担心已删除的属性)。 -
FULL_PROPS
:1<<4,表示具有具有动态键的属性的元素。当键发生变化时,总是需要进行完整的差异比较以删除旧键。这个标志与CLASS
、STYLE
和PROPS
是互斥的。 -
NEED_HYDRATION
:1<<2=5,表示需要对属性进行“hydration”(即初始化)的元素,但不一定需要进行补丁操作。例如,事件监听器和带有属性修饰符的v-bind
。 -
STABLE_FRAGMENT
:1<<6,表示子节点顺序不会改变的片段。 -
KEYED_FRAGMENT
:1<<7,表示具有带有key
或部分带有key
的子节点的片段。 -
UNKEYED_FRAGMENT
:1<<8,表示具有无key
子节点的片段。 -
NEED_PATCH
:1<<9,表示只需要进行非属性补丁操作的元素,例如ref
或指令(onVnodeXXX
钩子)。由于每个已补丁的虚拟节点都会检查ref
和onVnodeXXX
钩子,因此它只是标记虚拟节点,以便父块可以跟踪它。 -
DYNAMIC_SLOTS
:1<<10,表示具有动态插槽的组件(例如,引用v-for
迭代值的插槽,或动态插槽名称)。具有此标志的组件始终会被强制更新。 -
DEV_ROOT_FRAGMENT
:1<<11,表示仅因用户在模板的根级别放置了注释而创建的片段。这是一个仅用于开发环境的标志,因为在生产环境中会剥离注释。 -
HOISTED
:-1,表示一个被提升的静态虚拟节点。这是一个提示,用于在“hydration”过程中跳过整个子树,因为静态内容永远不需要更新。 -
BAIL
:-2,表示差异比较算法应该退出优化模式的特殊标志。例如,在由renderSlot()
创建的块片段中遇到非编译器生成的插槽(即手动编写的渲染函数,应始终完全进行差异比较)时,或者手动克隆VNodes
时。
什么是HoistStatic?
HoistStatic
是Vue3中的一个特殊的patchFlag,值为-1。用于表示一个被提升的静态虚拟节点。当一个虚拟节点被标记为HoistStatic
时,这意味着该节点是静态的,不需要进行更新操作,因为静态内容在渲染过程中永远不会改变。通过将静态内容标记为
HoistStatic
,Vue3可以在“hydration”(即将虚拟DOM转换为真实DOM)过程中跳过整个子树的更新,从而节省时间和资源。这样可以提高渲染性能,特别是在处理大型组件树时。
给定下面的代码,只有{{msg}}是动态的,会导致页面刷新,看下vue3的编译函数
<div>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>{{msg}}</span>
</div>
可以看到静态节点的定义,被提升到父作用域,缓存起来。之后函数怎么执行,这些变量都不会重新定义一遍。
如果有多个静态节点呢?
当静态节点达到一定阈值后会被vue3合并起来
vue3的前置后置预处理+最长递增子序列
vue3
的diff
借鉴于inferno (opens new window),该算法其中有两个理念。第一个是相同的前置与后置元素的预处理;第二个则是最长递增子序列,
前缀后缀预处理
如图所示,新旧
children
拥有相同的前缀节点和后缀节点
对于前缀节点,我们可以建立一个索引j,指向新旧
children
中的第一个节点,并逐步向后遍历,直到遇到两个拥有不同key
值的节点为止
我们需要处理的是相同的后缀节点,由于新旧
children
中节点的数量可能不同,所以我们需要两个索引prevEnd、nextEnd
分别指向新旧children
的最后一个节点,并逐步向前遍历,直到遇到两个拥有不同key
值的节点为止
理想:新节点多余
j > prevEnd
并且j <= nextEnd
此时新节点列表还有节点
新
children
中位于j
到nextEnd
之间的所有节点都应该是新插入的节点
理想: 旧节点多余
j <= prevEnd
并且j > nextEnd
此时旧节点列表有多余节点
旧
children
中有位于索引j
到prevEnd
之间的节点,都应该被移除
非理想:递增子序列
下面这个案例在预处理步骤之后,只有
li-a
节点和li-e
节点能够被提前patch
。换句话说在这种情况下没有办法简单的通过预处理就能够结束Diff
逻辑。这时我们就需要进行下一步操作
构建一个source数组
需要构造一个数组
source
,该数组的长度等于新children
在经过预处理之后剩余未处理节点的数量,并且该数组中每个元素的初始值为-1。
那么这个数组的作用是什么呢?该数组中的每一个元素分别与新children
中剩余未处理的节点对应,实际上source
数组将用来存储新children
中的节点在旧children
中的位置,后面将会使用它计算出一个最长递增子序列,并用于 DOM 移动。
增加key-map映射表
新增一个映射表存储旧节点的key和node。用于 计算新
children
中的节点在旧children
中的位置,将位置信息更新至sources数组中
拿旧
children
中的节点尝试去新children
中寻找具有相同key
值的节点,但并非总是能够找得到,当k === 'undefined'
时,说明该节点在新children
中已经不存在了,这时我们应该将其移除
最长递增子序列
什么是最长递增子序列?
给定一个数值序列,找到它的一个子序列,并且子序列中的值是递增的,子序列中的元素在原序列中不一定连续。
例如给定数值序列为:[ 0, 8, 4, 12 ]
那么它的最长递增子序列就是:[0, 8, 12]
当然答案可能有多种情况,例如:[0, 4, 12] 也是可以的
根据sources计算最长递增子序列LIS
source
数组的值为[2, 3, 1, -1]
,很显然最长递增子序列应该是[ 2, 3 ]
,但为什么计算出的结果是[ 0, 1 ]
呢?其实[ 0, 1 ]
代表的是最长递增子序列中的各个元素在source
数组中的位置索引
最长递增子序列的作用?
最长递增子序列是
[ 0, 1 ]
这告诉我们:新children
的剩余未处理节点中,位于位置0
和位置1
的节点的先后关系与他们在旧children
中的先后关系相同。或者我们可以理解为位于位置0
和位置1
的节点是不需要被移动的节点,即上图中li-c
节点和li-d
节点将在接下来的操作中不会被移动。
节点新增:索引-1
与
li-g
节点位置对应的source
数组元素的值为-1
,这说明li-g
节点应该作为全新的节点被挂载
节点移动
新节点中的节点不在最长递增子序列且索引不等于-1,并且索引与原老节点索引不同,需要移动老节点。将老节点dom挂载到li-g前面
vue3源码分析
核心diff算法在core-main\packages\runtime-core\src\renderer.ts文件中
vue3新增patchFlag
Vue3中的
patchFlag
是编译器生成的优化提示,用于在执行差异比较时进入“优化模式”。在这种模式下,算法知道虚拟DOM是由编译器生成的渲染函数产生的,因此算法只需要处理这些由补丁标志显式标记的更新。
PatchFlags
可以通过位运算符|
进行组合,并可以使用&
运算符进行检查。
PatchFlags
枚举了不同类型的补丁标志
通过组合不同的补丁标志,可以在差异比较过程中针对特定类型的更新进行优化处理。有下面几种类型:
特殊标志:
TEXT
:表示具有动态textContent
(子节点快速路径)的元素。比如{{msg}}CLASS
:表示具有动态类绑定的元素。比如“:class='colorStyle'”-
STYLE
:表示具有动态样式的元素。编译器会将静态字符串样式预编译为静态对象,并检测并提升内联静态对象。例如,style="color: red"
和:style="{ color: 'red' }"
都会被提升为静态对象{ color: 'red' }
,以便在渲染函数中使用。 -
PROPS
:表示具有非类/样式动态属性的元素,或者是具有任何动态属性(包括类/样式)的组件。当存在这个标志时,虚拟节点还会有一个dynamicProps
数组,其中包含可能发生变化的属性键,以便运行时可以更快地进行差异比较(无需担心已删除的属性)。 -
FULL_PROPS
:表示具有具有动态键的属性的元素。当键发生变化时,总是需要进行完整的差异比较以删除旧键。这个标志与CLASS
、STYLE
和PROPS
是互斥的。 -
NEED_HYDRATION
:表示需要对属性进行“hydration”(即初始化)的元素,但不一定需要进行补丁操作。例如,事件监听器和带有属性修饰符的v-bind
。 -
STABLE_FRAGMENT
:表示子节点顺序不会改变的片段。 -
KEYED_FRAGMENT
:表示具有带有key
或部分带有key
的子节点的片段。 -
UNKEYED_FRAGMENT
:表示具有无key
子节点的片段。 -
NEED_PATCH
:表示只需要进行非属性补丁操作的元素,例如ref
或指令(onVnodeXXX
钩子)。由于每个已补丁的虚拟节点都会检查ref
和onVnodeXXX
钩子,因此它只是标记虚拟节点,以便父块可以跟踪它。 -
DYNAMIC_SLOTS
:表示具有动态插槽的组件(例如,引用v-for
迭代值的插槽,或动态插槽名称)。具有此标志的组件始终会被强制更新。 -
DEV_ROOT_FRAGMENT
:表示仅因用户在模板的根级别放置了注释而创建的片段。这是一个仅用于开发环境的标志,因为在生产环境中会剥离注释。 -
HOISTED
:表示一个被提升的静态虚拟节点。这是一个提示,用于在“hydration”过程中跳过整个子树,因为静态内容永远不需要更新。 -
BAIL
:表示差异比较算法应该退出优化模式的特殊标志。例如,在由renderSlot()
创建的块片段中遇到非编译器生成的插槽(即手动编写的渲染函数,应始终完全进行差异比较)时,或者手动克隆VNodes
时。
patchChildren方法
入参解析:n1 与 n2 是待比较的两个节点,n1 为旧节点,n2 为新节点。container 是新节点的容器,而 anchor 是一个锚点,用来标识当我们对新旧节点做增删或移动等操作时,以哪个节点为参照物。optimized 参数是是否开启优化模式的标识。
获取旧子节点和新子节点:首先从传入的参数n1
和n2
中获取旧子节点c1
和新子节点c2
,同时获取旧节点的形状标志prevShapeFlag
。
获取补丁标志和形状标志:接着从新节点n2
中获取补丁标志patchFlag
和形状标志shapeFlag
,用于判断子节点的类型和特性。
-
根据 patchFlag 进行判断:
- 如果 patchFlag 是存在 key 值的 Fragment:KEYED_FRAGMENT,则调用 patchKeyedChildren 来继续处理子节点。
- 如果 patchFlag 是没有设置 key 值的 Fragment: UNKEYED_FRAGMENT,则调用 patchUnkeyedChildren 处理没有 key 值的子节点。
-
根据 shapeFlag (元素类型标记)进行判断:
-
如果新子节点是文本类型,而旧子节点是数组类型,则直接卸载旧节点的子节点。
- 如果新旧节点类型一致,则直接更新新子节点的文本。
-
如果旧子节点类型是数组类型
- 如果新子节点也是数组类型,则调用 patchKeyedChildren 进行完整的 diff。
- 如果新子节点不是数组类型,则说明不存在新子节点,直接从树中卸载旧节点即可。
- 如果旧子节点是文本类型,由于已经在一开始就判断过新子节点是否为文本类型,那么此时可以肯定新子节点肯定不为文本类型,则可以直接将元素的文本置为空字符串。
- 如果新子节点是类型为数组类型,而旧子节点不为数组,说明此时需要在树中挂载新子节点,进行 mount 操作即可。
-
patchKeyedChildren方法
定义三个指针,i,e1,e2。分别表示相同前缀指针,旧节点列表尾指针,新节点列表尾指针。在节点移动过程中i和e1,i和e2之间的关系决定循环是否结束,以及是否旧节点多余还是新节点多余。
前缀比较
首先,代码通过一个while
循环对子节点列表的前缀部分进行比较。在每次循环中,会获取当前位置i
处的两个节点n1
和n2
,然后判断它们是否是相同类型的节点(通过isSameVNodeType
函数)。如果是相同类型的节点,则调用patch
函数对这两个节点进行更新操作;如果不是相同类型的节点,则跳出循环。
后缀比较
接着,代码通过另一个while
循环对子节点列表的后缀部分进行比较。在每次循环中,会获取当前位置e1
和e2
处的两个节点n1
和n2
,同样判断它们是否是相同类型的节点。如果是相同类型的节点,则同样调用patch
函数对这两个节点进行更新操作;如果不是相同类型的节点,则跳出循环。
普通序列+新节点多余
在下面段代码中,首先通过条件判断if (i > e1)
,如果i
已经超过了旧子节点列表的结束索引e1
,说明旧节点遍历结束。如果i<=e2,说明新节点有多余节点。需要处理新增的节点。
-
挂载新增节点:在处理新增节点的情况下,代码通过一个
while
循环,将新增的节点依次挂载到父容器中。具体操作是调用patch
函数,将新增节点添加到父容器中,并根据情况设置适当的锚点位置。这样可以确保新增的节点能够正确地插入到子节点列表中。 -
克隆节点处理:在处理新增节点时,代码会根据是否优化的标志
optimized
来决定是否需要克隆节点。如果需要优化,则会调用cloneIfMounted
函数对节点进行克隆处理;否则会调用normalizeVNode
函数对节点进行规范化处理。
普通序列+旧节点多余
在下面段代码中,通过条件判断else if (i > e2)
,如果i
已经超过了新子节点列表的结束索引e1
2,说明新节点遍历结束。如果i<=e1,说明旧节点有多余节点。需要删除旧节点多余的元素。
-
卸载节点:在处理需要卸载的节点的情况下,代码通过一个
while
循环,将需要卸载的节点依次进行卸载操作。具体操作是调用unmount
函数,将节点从父容器中卸载,并根据情况传入相应的参数,如parentComponent
和parentSuspense
等。 -
卸载操作细节:在卸载节点时,代码会传入参数
true
,表示需要强制卸载节点。这样可以确保节点在卸载时能够正确地执行清理操作,如解绑事件监听器、清除定时器等。
未知序列
将前缀节点i,扩展为旧节点头指针s1,新节点头指针s2。
构建新节点的索引映射表
-
构建新子节点的key:index映射:在处理未知序列的情况下,首先定义了两个起始索引
s1
和s2
,分别表示前一个子节点列表和后一个子节点列表的起始索引。 -
构建key:index映射:接着通过循环遍历后一个子节点列表,对每个子节点进行处理。对于每个子节点,会先进行克隆或规范化处理,然后判断是否存在
key
属性。如果存在key
属性,则将key
与当前索引i
建立映射关系,并存储在keyToNewIndexMap
中。 -
重复key检查:在存储
key
与索引映射关系时,代码会进行重复key
检查,确保每个key
在映射中是唯一的。如果发现重复的key
,则会在开发环境下给出警告提示,提示开发者需要确保key
的唯一性。
构建newIndexToOldIndexMap
:通过循环初始化newIndexToOldIndexMap
数组,用于记录新旧子节点之间的索引映射关系。其中,newIndexToOldIndexMap
的索引表示新子节点的索引,值表示对应的旧子节点的索引(偏移了1,0表示新节点没有对应的旧节点)。
遍历旧节点列表+填充newIndexToOldIndexMap
遍历旧子节点列表:接着通过循环遍历旧子节点列表,对每个旧子节点进行处理。如果已经处理的节点数量patched
超过了需要处理的节点数量toBePatched
,则表示所有新子节点已经处理完毕,剩下的旧子节点需要被移除。
匹配新旧节点:对于每个旧子节点,首先尝试通过key
值在keyToNewIndexMap
中查找对应的新子节点索引。如果未找到对应的key
,则尝试在新子节点列表中查找类型相同的无key
节点进行匹配。
更新节点:如果成功找到对应的新子节点索引newIndex
,则更新newIndexToOldIndexMap
中的映射关系,并调用patch
函数对旧子节点和新子节点进行更新操作,包括属性更新、DOM操作等。
移除节点:如果未找到对应的新子节点索引,说明该旧子节点在新子节点列表中已经不存在,需要将其移除,调用unmount
函数进行卸载操作。
最长递增子序列+移动挂载逻辑
-
生成最长稳定子序列:在代码中首先判断是否有节点发生移动(
moved
为真),如果有节点移动,则调用getSequence
函数生成最长稳定子序列increasingNewIndexSequence
,用于确定哪些节点需要移动。 -
逆序遍历处理节点:接着通过逆序遍历处理需要更新的节点。从最后一个节点开始向前遍历,以便使用最后一个已更新的节点作为锚点。
-
挂载新节点:对于未在
newIndexToOldIndexMap
中找到对应关系的节点(值为0),表示这是新节点,需要挂载到DOM树上。调用patch
函数进行挂载操作。 -
移动节点:如果存在节点移动(
moved
为真),则需要判断是否需要移动节点。判断条件为:没有稳定子序列(例如逆序情况)或当前节点不在稳定子序列中。根据判断结果,调用move
函数进行节点移动操作。
patchUnkeydChildren方法
通过遍历子节点列表,对公共部分进行更新,移除多余的旧节点,挂载剩余的新节点,可以实现对没有
key
的子节点列表的更新和维护。