介绍
Vue是数据驱动视图的,数据发生变化视图就要随之更新,在更新视图的时候难免要操作DOM,而操作真实DOM非常耗费性能。
Vue通过JS模拟出一个称为虚拟DOM节点来代表一个真实的DOM节点。当数据发生变化时,我们对比变化前后的虚拟DOM节点,通过DOM-Diff算法计算出需要更新的地方,然后去更新需要更新的视图。
本文主要介绍DOM-diff算法中的子节点更新策略。
首先看下面的流程图的位置A,当我们发现新旧的虚拟DOM节点是同一个节点时(key值相同),我们会对这两个节点进行精细化比较。
然后进入了更新节点的流程,现在要根据新的虚拟DOM节点来修改旧的虚拟DOM节点对应的真实DOM节点。看下面流程图的B位置,当我们的新旧虚拟DOM节点都有子节点的时候,情况就很复杂了,我们需要去比较新旧虚拟节点的子节点。也就是本文要介绍的子节点更新策略。
下面的流程图展示的过程是更新节点的过程,是源码中patchVnod函数做的事情。
子节点更新
这里先定义一些变量
newChildren:数组,里面是新虚拟DOM节点中包含的子虚拟节点。
oldChildren: 数组,里面是旧虚拟DOM节点中包含的子虚拟节点。
新前:指针,指向新虚拟节点的第一个未处理的子虚拟节点。
新后:指针,指向新虚拟节点的最后一个未处理的子虚拟节点。
旧前:指针,指向旧虚拟节点的第一个未处理的子虚拟节点。
旧后:指针,指向旧虚拟节点的最后一个未处理的子虚拟节点。
子节点更新策略的原则就是:如果newChildren中的某个子节点在oldChildren数组中存在,那么就进行位置移动和节点更新。如果newChildren中的某个子节点在oldChildren数组中找不到,那么我们就创建一个新的DOM节点插入到合适位置。如果oldChildren数组中存在某个元素是newChildren数组中没有的,那么我们就把它对应的真实DOM节点删除了。
注意:所有都是对照新虚拟节点,来操作旧虚拟节点对应的真实DOM。这样就可以更新视图,使视图和新的虚拟节点一样了。
上面的原则,可以简单的通过双层for循环来做,外层循环newChildren数组,内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点。
但是上面这样做会很乱,而且时间复杂度很高。
DOM-diff算法中对子节点更新设计了很好的策略,下面具体讲解。
DOM-diff算法中的子节点更新策略
我们要在旧的虚拟节点中查找是否有新的虚拟节点,然后进行更新,新增或删除操作,那么DOM-diff算法是怎么查找的呢?
四种命中策略查找:
策略1:先看看 新前和旧前 是否是同一个节点,如果是,表示策略1命中,就去更新,新前这个节点就处理好啦。
策略2:再看 新后与旧后 是否是同一个节点,如果是,表示策略2命中,就去更新,新后这个节点就处理好啦。
策略3:再看 新后与旧前 是否是同一个节点,如果是,表示策略3命中,就去更新,新后这个节点就处理好啦。
策略4:再看 新前与旧后 是否是同一个节点,如果是,表示策略4命中,就去更新,新前这个节点就处理好啦。
如果策略1命中,那么根据新前去更新旧前,并移动指针。
如果策略1没有命中,策略2命中,则根据新后去更新旧后,并移动指针。
如果策略1 和 2都没有命中,策略3命中,则根据新后去更新旧前,并且移动旧前所指真实DOM到合适的位置。并移动指针。
如果策略1 和 2 和 3都没有命中,策略4命中,则根据新前去更新旧后,并且移动旧后所指真实DOM到合适的位置。并移动指针。
如果策略1 和 2 和 3 和 4都没有命中,那么只能依次去比较每一个新旧节点拉。
这个命中策略很符合我们平时的操作,比起双层循环的查找要好很多。
再讨论之前定义的指针,新前,新后,旧前,旧后。
旧前(包括)和旧后(包括)之前的虚拟节点是没有处理的节点(如果之间有已经处理的,会把打成undefined标记),旧前之前和旧后之后的节点都是已经处理好的节点。所以我们处理一个节点后,就要正确的移动的指针,使得旧前之前和旧后之后的节点都是已经处理好的节点
下面的h('li', {key: 'C'}, 'C')
只需要看作一个虚拟节点即可,标签名是li
, key值为C, 标签的innerText是C
源码
// 源码位置:src/core/vdom/patch.js
//更新子节点的策略,其中使用的四个索引和搜索方法,很大程度上优化了时间复杂度.
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
//以"新前"、"新后"、"旧前"、"旧后"的方式开始比对节点
// 因为我们处理过程中对于处理过的 位于旧前旧后之间的节点 如果被处理了 会被标记为undefined
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//如果旧节点的前后开始的节点都为null,就往中间走,继续下一个节点的判断
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
}
//从这里开始 新前 旧前 对比 相同,就把两个节点进行patch更新,并且移动索引位置
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
//从这里开始 新后 旧后 对比
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
//从这里开始 新后 旧前 对比
//相同使用patch更新,更新之后还需要将旧前对应的真实DOM移到旧后对应的真实DOM之后,也就是旧的所有未处理节点后.
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
//从这里开始 新前 旧后 对比
//相同使用patch更新,更新之后还需要将旧后对应的真实DOM移到旧前对应的真实DOM之前,也就是旧的所有未处理节点前.
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
// 如果不属于以上四种情况,就进行常规的循环比对patch
else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
//如果在当前oldnode上找不到同样的new,新创建
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
//找到了.判断是不是内容都一样
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
/**
* 如果oldChildren比newChildren先循环完毕,
* 那么newChildren里面剩余的节点都是需要新增的节点,
* 把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
*/
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
}
else if (newStartIdx > newEndIdx) {
/**
* 如果newChildren比oldChildren先循环完毕,
* 那么oldChildren里面剩余的节点都是需要删除的节点,
* 把[oldStartIdx, oldEndIdx]之间的所有节点都删除
*/
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
图解子节点更新策略
验证策略1命中的情况
第一步:策略1新前与旧前命中:
- 执行更新节点操作,根据新前更新旧前指的真实DOM
- 改变指针。将新前和旧前往下移。
第二步:策略1新前与旧前又命中了:
- 执行更新节点操作,根据新前更新旧前指的真实DOM
- 改变指针。将新前和后前往下移。
直至,循环结束,因为旧后在旧前下面了。
旧前于旧后之前没有节点了,且新前与新后之间也没了。处理完了。
上面的小蓝圈圈就代表页面DOM,页面DOM已经和新的虚拟节点一致了。
验证策略3命中的情况
第一步:新后与旧前命中:
更新key为c的真实DOM
将旧前指向的真实DOM移动到所有未处理节点之后,也就是把旧前指向的真实DOM移动到旧后指向的真实DOM之后。
改变指针
第二步:新后与旧前命中:
更新key为B的真实DOM
将旧前指向的真实DOM移动到所有未处理节点之后,也就是把旧前指向的真实DOM移动到旧后指向的真实DOM之后。
改变指针
第三步:新前与旧前命中:
更新key为A的节点
不需要移动节点
改变指针
直至,循环结束,因为旧后在旧前下面了。
旧前于旧后之前没有节点了,且新前与新后之间也没了。处理完了。
上面的小蓝圈圈就代表页面DOM,页面DOM已经和新的虚拟节点一致了。DOM节点的顺序是ABC与新的虚拟节点一致。
验证4种策略都没有命中的情况
第一步:策略二新后与旧后命中:
1.更新key为A的节点
2.无需移动节点
3.改变指针
第二步:四种策略都无法命中
维护一个对象,主要是维护的旧的虚拟节点的key属性和他的索引
oldKeyToIdx: {
F: 0,
E: 1,
C: 2
}
通过这个oldKeyToId这个对象,我们发现新前这个节点D在旧的虚拟节点中是没有的所以创建一个新的DOM,插入到所有未处理之前,也就是插入到旧前的前面
然后移动指针
第三步:四种策略都没有命中
通过这个oldKeyToId这个对象,
我们发现新前这个节点E在旧的虚拟节点中是有的, 且能获得它的索引是1,对新旧的E节点进行更新节点操作
并且需要移动位置,将旧的E的DOM移动到所有未处理之前,也就是移动到旧前的前面。
处理了E节点后我们需要打标记,防止重复处理。把E这个位置 置为undefined
然后移动指针
第四步:四种策略都没有命中
通过这个oldKeyToId这个对象,我们发现新前这个节点B在旧的虚拟节点中是没有的
所以创建一个新的DOM,插入到所有未处理之前,也就是插入到旧前的前面
然后移动指针
循环结束,因为新前在新后的下面了
由于旧前仍然小于旧后指针。说明在oldChildren中还有未处理的子节点,那么把这些节点移除。把节点C和F对应的DOM移除。
注意,E已经处理了不需要移除
看结果:旧的节点,看小蓝色的圈圈就是DOM
DOM已经和新的一样了。DOM的顺序的DEBA。和新的一样。
参考:
《Vue中的虚拟DOM》