写在前面
本文衔接上篇 Vue 原理之从新建实例到 Diff ,对 Vue Diff 算法进行深入解析。
通过三个典型的例子,来理解 Diff 算法对不同情况的处理流程(多图预警)。最后结合源码进行疑点分析。
Diff 算法基本逻辑
作用:找到最小差异部分的 DOM 进行更新,减少 DOM 重绘重排。
比较对象:新旧节点中父节点是相同节点的那一层 子节点。
核心:在父节点相同的前提下,找到相同节点进行复用。
比较的过程中,不会对新旧 vnode 进行修改,而是以比较的结果直接修改真实 DOM。
处理流程:
算法中使用新旧各两个索引、两个 vnode 节点作为参数进行循环比较,其中:
- 每次比较后,对索引进行加减移动,并对 vnode 节点进行更新赋值:
- 头部索引后移
oldStartVnode = oldCh[++oldStartIdx]newStartVnode = newCh[++newStartIdx]
- 尾部索引前移
oldEndVnode = oldCh[--oldEndIdx]newEndVnode = newCh[--newEndIdx]
- 头部索引后移
- 仍然使用
sameVnode方法判断新旧节点是否相同,同则进行patchVnode递归比较,直至最后执行到文本节点或空节点结束递归(patch 流程) - 关于新节点与真实 DOM 的关联:
patchVnode时,vnode.elm = oldVnode.elm- 新增新节点时,
createElm: vnode.elm = nodeOps.create...
以上为基本的理论基础,接下来通过示例作具体描述。
示例图解
从上文中 Diff 流程可得知,Diff 算法中提取出了新旧子节点进行比较的四种特殊情况,本节示例以此为基础,图解不同情况的处理流程。
图中省略了索引的走向,以四个节点参数作为代表:
- 旧头节点
oldStartVnode、尾节点oldEndVnode - 新头节点
newStartVnode、尾节点newEndVnode
例 1. 旧头=新头、旧尾=新尾
本例中新旧的头部节点相同,尾部节点相同。处理过程:
- 第一轮循环,判断得知 oldStartVnode (1) 与 newStartVnode (1) 相同
- 将 newVnode-1 的 elm 指向 DOM-1,并以 newVnode-1 内容更新 DOM-1
- 然后 oldStartVnode 与 newStartVnode 赋值为子节点数组中的下一节点(2;2)
- 第二轮循环,同第一轮,oldStartVnode (2) 与 newStartVnode (2) 相同
- 将 newVnode-2 的 elm 指向 DOM-2,并以 newVnode-2 内容更新 DOM-2
- oldStartVnode 与 newStartVnode 指向子节点数组中的下一节点(3;5)
- 第三轮循环,判断得 oldStartVnode(3) 与 newStartVnode(5) 不同,但 oldEndVnode (4) 与 newEndVnode (4) 相同
- 将 newVnode-4 的 elm 指向 DOM-4,并以 newVnode-4 内容更新 DOM-4
- oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(3;3)
- 第四轮循环,同第三轮,oldEndVnode (3) 与 newEndVnode (3) 相同
- 将 newVnode-3 的 elm 指向 DOM-3,并以 newVnode-3 内容更新 DOM-3
- oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(2;5)
- 此时,旧子节点数组的头部索引已经大于尾部索引,循环结束
- 新增新子节点数组两索引之间的节点 newVnode-5
此过程中未对新旧节点数组进行遍历比较。
例 2. 旧头=新尾、旧尾=新头
本例中,最初旧头节点与新尾节点相同,旧尾节点与新头节点相同,但处理几轮后会发现情况发生了变化。过程:
- 第一轮循环,oldStartVnode (1) 与 newEndVnode (1) 相同
- 将 newVnode-1 的 elm 指向 DOM-1,以 newVnode-1 内容更新 DOM-1
- 移动 DOM-1
- oldStartVnode 向后移动(2);newEndVnode 向前移动(2)
- 第二轮循环,同第一轮,oldStartVnode (2) 与 newEndVnode (2) 相同
- 将 newVnode-2 的 elm 指向 DOM-2,以 newVnode-2 内容更新 DOM-2
- 移动 DOM-2
- oldStartVnode 向后移动(5);newEndVnode 向前移动(3)
- 第三轮循环,尾部节点 oldEndVnode (4) 与头部节点 newStartVnode (4) 相同
- 将 newVnode-4 的 elm 指向 DOM-4,以 newVnode-4 内容更新 DOM-4
- 移动 DOM-4
- oldEndVnode 向前移动(3);newStartVnode 向后移动(3)
- 第四轮循环,此时,newStartVnode 与 newEndVnode 指向了同一位置,先匹配上的条件是 oldEndVnode (3) 与 newEndVnode (3) 相同
- 将 newVnode-3 的 elm 指向 DOM-3,并以 newVnode-3 内容更新 DOM-3
- oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(5;4)
- 此时,新子节点数组的头部索引已经大于尾部索引,循环结束
- 删除旧子节点数组两索引之间的节点 oldVnode-5
此过程中也未对新旧节点数组进行遍历比较。
例 3. else 流程 > 查找相同节点
本例中,最初没有特殊情况可以匹配,但处理后又会发现情况有所变化。过程:
- 第一轮循环,无特殊情况,else 流程,查找与 newStartVnode (3) 相同的节点
- 找到相同的旧节点 oldVnode-3
- 将 newVnode-3 的 elm 指向 DOM-3,并以 newVnode-3 内容更新 DOM-3
- 将
oldCn[idxInOld]置为 undefined,移动 DOM-3 - newStartVnode 向后移动(4)
- 第二轮循环,出现特殊情况,oldEndVnode (4) 与 newStartVnode (4) 相同
- 将 newVnode-4 的 elm 指向 DOM-4,以 newVnode-4 内容更新 DOM-4
- 移动 DOM-4
- oldEndVnode 向前移动(3);newStartVnode 向后移动(5)
- 第三轮循环,图中省略了此轮,oldEndVnode 指向的节点为 undefined,所以继续将 oldEndVnode 向前移动(2)
- 第四轮循环,此时又有特殊情况,oldEndVnode (2) 与 newEndVnode (2) 相同
- 将 newVnode-2 的 elm 指向 DOM-2,并以 newVnode-2 内容更新 DOM-2
- oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(1;5)
- 第五轮循环,无特殊情况,查找与 newStartVnode (5) 相同的节点
- 未找到相同的旧节点,新增此新子节点 newVnode-5
- newStartVnode 向后移动(2)
- 此时,新子节点数组的头部索引已经大于尾部索引,循环结束
- 删除旧子节点数组两索引之间的节点 oldVnode-1
此过程中,对旧子节点数组进行了两次匹配查找,但遍历次数可能只有一次。
疑点分析
上文示例主要介绍 DIff 算法的处理流程,其中处理的细节需结合源码进行疑点分析。
1. 如何将新节点内容更新至 DOM
if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
// ...
}
使用sameVnode方法判断新旧节点相同后,继续通过patchVnode进一步间接递归比较:
- 若新旧节点都有子节点,继续
updateChildrendiff - 若是文本节点或空节点,更新 DOM 文本内容,结束处理
2. 如何移动 DOM
因为 patchVnode 方法中将 vnode 的 elm 属性指向其对应的 DOM 节点,所以使用 vnode.elm 进行 DOM 操作。
特殊情况下的移动
if (isUndef(oldStartVnode)) {
// else if ....
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, /*...*/);
// 在 oldEndVnode.elm 的下一节点前移动已存在的节点 oldStartVnode.elm
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, /*...*/);
// 在 oldStartVnode.elm 前移动已存在的节点 oldEndVnode.elm
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// ...
}
// nodeOps.insertBefore
function insertBefore(parentNode, newNode, referenceNode) {
// 原生 insertBefore,在指定的已有子节点之前插入新的子节点,或移动/移除已有的子节点。
parentNode.insertBefore(newNode, referenceNode);
}
其中,nodeOps.insertBefore封装了原生 DOM 操作方法,nextSibling返回下一个兄弟节点
- 旧头部节点 oldStartVnode 与新尾部节点 newEndVnode 相同
- 对应的 DOM 节点需要向后(向右)移动至 oldEndVnode 节点后的位置
- oldEndVnode 后若有节点,都已比较并更新
- 旧尾部节点 oldEndVnode 与新头部节点 newStartVnode 相同
- 对应的 DOM 节点需要向前(向左)移动至 oldStartVnode 节点前的位置
- oldStartVnode 前若有节点,都已比较并更新
else 流程下的移动
if (isUndef(oldStartVnode)) {
// else if ....
} else {
// ...
if (isUndef(idxInOld)) { // New element
// ...
} else {
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, /*...*/);
oldCh[idxInOld] = undefined;
// 在 oldStartVnode.elm 前移动已存在的节点 vnodeToMove.elm
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
// same key but different element. treat as new element
// createElm...
}
}
newStartVnode = newCh[++newStartIdx];
}
同理,因为以 newStartVnode 作为比较对象,所以将vnodeToMove.elm移至oldStartVnode.elm前。
3. else 流程如何查找相同节点
if (isUndef(oldStartVnode)) {
// else if ....
} else {
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// ...
newStartVnode = newCh[++newStartIdx];
}
其中,oldKeyToIdx存储通过createKeyToOldIdx方法转换的旧子节点数组节点key与索引index的map表 {key: index}。oldKeyToIdx 只赋值一次,此为 else 流程中对旧子节点数组的一次遍历。
如此以对象索引查找替代数组遍历,
- 可快速通过新子节点的 key 查找到与其 key 相同的旧子节点
- 此流程只查找到 key 相同的节点,后续还需进行
sameVNode比较
但如果新子节点没有定义 key 属性,则需通过findIdxInOld遍历旧子节点数组查找相同的节点。
4. 循环后新增或删除节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}
if (oldStartIdx > oldEndIdx) {
// 旧子节点数组先比较结束,新增新子节点数组中未比较的节点
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
// 旧子节点数组先比较结束,删除旧子节点数组中未比较的节点
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
for 循环遍历,进行新增或删除:
function addVnodes(parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm, false, vnodes, startIdx);
}
}
function removeVnodes(vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
var ch = vnodes[startIdx];
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch);
invokeDestroyHook(ch);
} else { // Text node
removeNode(ch.elm);
}
}
}
}
其中,新增流程中refElm获取的是 newEndIdx+1位即 newEndVnode 后一位的节点,在此位置前插入新的节点。newEndVnode 后若有节点,都已比较并更新。
小结
3个示例中:
- 示例 1 没有对节点数组进行遍历,对已有的 DOM 节点只进行了内容更新
- 示例 2 同样没有对节点数组进行遍历,对需保留的 DOM 节点移动三次后就可以只更新内容
- 示例 3 从无特殊匹配的情况,逐渐向极限情况的处理靠近
可见 Diff 算法在比较两组子节点和减少 DOM 操作方面的巧妙性。
此外,以对象索引查找替代数组遍历,也是典型的减少数组遍历的方法;else 流程查找到相同的节点后,将旧子节点数组中此节点置为 undefined,方便后续循环时直接移动索引进行流转;使用 elm 属性将新旧节点 vnode 与 DOM 进行关联......都是很好的处理做法。
学到的东西会用才是自己的 🆙
2020中国年前的最后一篇博文终于码出来了😶
图解过程不知是否容易理解。文中如有疏漏,欢迎指出 🤝