patchVnode
在 patch
方法中对比两个新旧 Vnode
节点时,如果新旧节点是相同节点,调用 patchVnode
方法。在 patchVnode
方法内,分为以下几步:
- 触发用户设置的
prepatch
钩子函数(不论新旧节点是否相同,都会触发) - 触发用户设置的
update
钩子函数 - 比较相同新旧节点的差异
- 触发用户设置的
postpatch
钩子函数,相当于vue
的updated
钩子函数
比较相同新旧节点的差异
开始我们需要声明一些常量:
js
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
老节点是一定有 elm
属性的,对应其真实 DOM
元素,将其赋值给新节点的 elm
和常量 elm
(此时新节点的 DOM
其实就是复用了老节点的 DOM
)。
接下来就是分析新老节点的各种情况,做出不同的处理,总结来说就是以下四点:
- 新老节点都有
children
且不相等,调用updateChildren
- 添加新节点的
children
用addVnodes
- 移除老节点的
children
用removeVnodes
(移除老节点,而不是直接替换,就是为了触发remove
钩子函数) - 设置或者清除新老节点的
text
用setTextContent
(仅仅更新了文本的内容,没有重新创建节点)
js
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 新老节点都有 children 且不相等,调用 updateChildren
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
// 新节点有 children,老节点没有
// 如果老节点有 text 属性,清空对应 DOM 元素的 textContent
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// 添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 新节点没有 children,老节点有,移除所有的老节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 新节点没有 children,也没有 text,老节点没有 children
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 新节点有 text,且不等于老节点的 text
if (isDef(oldCh)) {
// 如果老节点有 children,移除老节点 children 对应的 DOM 元素
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 设置新节点对应 DOM 元素的 textContent
api.setTextContent(elm, vnode.text!)
}
那么在这段比较中,最为复杂的就是当新老节点都有 children
且不相等时,调用的 updateChildren
方法。
updateChildren
js
// 新老节点都有 children 且不相等,调用 updateChildren
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
在 updateChildren
中接收了新老节点的子节点,并从中获取了新旧子节点的开始结束节点和他们的索引:
js
function updateChildren (parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue)
接下来会分情况来进行 Vnode
之间的比较,这个比较也叫做 diff
算法。
diff 算法
diff
算法用于查找两棵树每一个节点的差异。当数据变化后,不直接操作 DOM
,而是先比较 js
对象(vnode
)是否发生变化,找到所有变化的位置,只最小化的更新变化的位置,从而提高性能。
通常我们会想到遍历 A
树上的所有节点,让其中的每一个节点和 B
树上的每一个节点做对比,这样需要做大量的比较。Snabbdom
根据 DOM
的特点做了一些优化,因为 DOM
操作时很少会跨级别操作节点,所以可以只比较同级别的节点,从而减少比较次数。
sameVnode
比较的就是 key
和 sel
相同。如果2个节点是 sameVnode
,会重用之前的旧节点对应的 DOM
,patchVnode
会对比差异然后将更新应用到重用的 DOM
元素上,如果文本元素和子元素也相同的话,就无需再操作 DOM
。
同级别节点比较,直到新子节点或者旧子节点有一个全部遍历完
1. oldCh 的开始节点 == newCh 的开始节点
当比较两个节点的时候,先会比较两个节点的开始节点是否为 sameVnode
,如果是则调用 patchVnode
比较差异然后更新到真实 DOM
上,然后将开始节点置为第2个节点接着比较。
js
... else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
2. oldCh 的结束节点 == newCh 的结束节点
当比较到开始节点不再相等,就比较两个节点的结束节点,,如果是 sameVnode
,则调用 patchVnode
比较差异然后更新到真实 DOM
上,然后将开始节点置为倒数第2个节点接着比较。
js
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
3. oldCh 的开始节点 == newCh 的结束节点
比较 oldCh
的开始节点和 newCh
的结束节点,如果是 sameVnode
,则调用 patchVnode
比较差异然后更新到真实 DOM
上,并将 oldCh
的开始节点对应的 DOM
元素移动到 parentElm
的最后(不是移动 oldCh
,而是移动真实 DOM
),然后将 oldCh
的第2个节点置为开始节点,将 newCh
的倒数第2个节点置为结束节点,接着比较。
js
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
4. oldCh 的结束节点 == newCh 的开始节点
js
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
5. oldCh 的开始节点 !== newCh 的任何节点
不是上述4种情况的,遍历 oldCh
中的节点,没有查找到 newCh
的开始节点,说明 newCh
中的开始节点是一个新的节点,此时需要创建新的 DOM
元素,并插入到 oldCh
对应的 DOM
元素的最前面。
6. oldCh 的开始节点 == newCh 的中间某一节点
不是上述4种情况的,遍历 oldCh
中的节点,其中查找到了 newCh
的开始节点,则调用 patchVnode
比较差异然后更新,再将这个节点移动到 oldCh
对应的 DOM
元素的最前面。
js
else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
idxInOld = oldKeyToIdx[newStartVnode.key as string]
if (isUndef(idxInOld)) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined as any
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
newStartVnode = newCh[++newStartIdx]
}
结束遍历后,处理剩余节点
1. 老节点先遍历完
说明新节点有剩余,把新节点的剩余节点调用 addVnodes
批量插入到 oldCh
对应的 DOM
元素的最右边
2. 新节点先遍历完
说明老节点有剩余,调用 removeVnodes
把老节点的剩余节点批量删除
js
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}