本文是关于虚拟 DOM 和 diff 算法的学习笔记,目的在于更好的理解 Vue 的底层原理,篇幅较长,故而拆分为几篇,此篇为完结篇。
之前内容传送门:
《虚拟 DOM 和 diff 算法 -01》
《虚拟 DOM 和 diff 算法 -02》
《虚拟 DOM 和 diff 算法 -03》
承接上篇,当遇到新旧节点均为 children 属性有值的情况,继续分析四种命中查找的后两种:
3. 新后与旧前
先判断新前与旧前,不命中;再判断新后与旧后,不命中;再判断新后与旧前,发现命中,先进行 patchVnode 处理,再移动节点,把旧前指向的节点移动到旧后指向的节点的后面,然后旧前指针下移一位改变所指的节点,新后指针上移一位改变所指的节点:
此时又是新后与旧前命中,则新后继续上移,旧前继续下移:
以此类推,最终跳出 while 循环 。
// 3. 新后与旧前
if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
if(newEndVnode) newEndVnode.elm = oldStartVnode?.elm
// 把旧前指向的节点移动到旧后指向的节点的后面
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
注意这里用的是 insertBefore 而不是 appendChild,因为旧后指针指向的节点不一定是 parentElm 的最后一位(比如之前的操作已经有节点被移动到后面来了)。
4. 新前与旧后
依次经过新前与旧前、新后与旧后、新后与旧前的判断都没命中,在新前与旧后命中,先进行 patchVnode 处理,再移动节点,将旧后指向的节点移动到旧前的前面,然后旧后指针上移一位,新前指针下移一位:
接下去就是新前与旧前命中了。
// 新前与旧后
if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
if(newStartVnode) newStartVnode.elm = oldEndVnode?.elm
// 将旧后指向的节点移动到旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
四种命中之外的情况
我们需要循环遍历 oldVnode.children 中的旧前指针到旧后指针之前的节点,看看有没有 key 值与新前的 key 值是一样的(这里不考虑 sel 的值不一样的情况)。
// 创建一个 key 的映射对象,方便新节点在旧节点中寻找是否有相同的 key
const keyMap = {}
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if (key) keyMap[key] = i
}
const idxInOld = keyMap[newStartVnode.key] // 在旧节点中寻找新前指向的节点
如果 idxInOld 有值
说明此时的新前指向的节点在旧节点中存在 key 值相同的节点,只需要移动该旧节点位置。 比如下面这种情况,一开始的这个新前 h('li', { key: 'B'}, 'BBB') 不满足四种命中任何一种,但是在旧节点中存在 sel 为 li,key 为 B 的节点。
在旧节点中找到后,用 elmToMove 变量保存它,再进行新旧同一节点的 patchVnode,然后要把处理过的旧节点赋值 undefined(因为一个节点不能同时位于文档的两个点中),最后用 insertBefore 移动 elmToMove:
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
// 处理过的节点赋值为 undefined
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
处理完就要移动新前指针,让新前指针指向的节点改为下一个 newStartVnode = newCh[++newStartIdx]。
此时情况如下图,新前指针来到了 h('li', { key: 'A'}, 'AAA'),此时新前与旧前命中,则同时下移:
当旧前移动到 undefined 的位置,则跳过继续下移一位,所以 while 循环最开始的时候要先有相应判断,再去匹配四种命中:
if (oldStartVnode === undefined) { // 有可能已经是处理过的情况
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode === undefined) {
oldEndVnode = oldCh[--oldEndIdx]
}
则接下去的情况如下图,剩余操作的图示省略
如果 idxInOld 没有值
说明是个新节点:parentElm.insertBefore(creatElement(newStartVnode), oldStartVnode.elm)。
while 循环之后分析
while 循环结束的条件只有两种:
1. oldStartIdx > oldEndIdx
旧节点先处理完毕,说明新节点还有指针没指到并处理的节点,新节点有增加,比如下图这种情况:
2. newStartIdx > newEndIdx
新节点先处理完毕,说明新节点有删除,图略。
if (oldStartIdx > oldEndIdx) { // 新节点有增加
// 这里不能用真实 DOM 才有的属性 nextSibling
const before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null
for (let i = newStartIdx; i <= newEndIdx; i++) {
parentElm.insertBefore(creatElement(newCh[i]), before)
}
} else { // 新节点有删除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
parentElm.removeChild(oldCh[i].elm)
}
}
注意,这里用到了 newCh[newEndIdx + 1].elm, 所以前面四种命中的情况要给新节点的 elm 赋上相应的值
新旧节点均为 children 的情况至此我们分析完了,流程图继如下:
抽离成 updateChildren 函数,文件如下:
// updateChildren.js
import patchVnode from './patchVnode.js'
import creatElement from './creatElement.js'
// 判断两个虚拟节点是否为同一节点
function sameVnode(vnode1, vnode2) {
return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}
export default (parentElm, oldCh, newCh) => {
let oldStartIdx = 0 // 旧前指针
let oldEndIdx = oldCh.length - 1 // 旧后指针
let newStartIdx = 0 // 新前指针
let newEndIdx = newCh.length - 1 // 新后指针
let oldStartVnode = oldCh[0] // 初始时旧前指针指向的虚拟节点
let oldEndVnode = oldCh[oldEndIdx] // 初始时旧后指针指向的虚拟节点
let newStartVnode = newCh[0] // 初始时新前指针指向的虚拟节点
let newEndVnode = newCh[newEndIdx] // 初始时新后指针指向的虚拟节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if (oldStartVnode === undefined) { // 有可能已经是处理过的情况
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode === undefined) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 1. 新前与旧前
patchVnode(oldStartVnode, newStartVnode)
if(newStartVnode) newStartVnode.elm = oldStartVnode?.elm
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 2. 新后与旧后
patchVnode(oldEndVnode, newEndVnode)
if(newEndVnode) newEndVnode.elm = oldEndVnode?.elm
console.log(oldEndVnode.elm, newEndVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 3. 新后与旧前
patchVnode(oldStartVnode, newEndVnode)
if(newEndVnode) newEndVnode.elm = oldStartVnode?.elm
// 把旧前指向的节点移动到旧后指向的节点的后面
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 新前与旧后
patchVnode(oldEndVnode, newStartVnode)
if(newStartVnode) newStartVnode.elm = oldEndVnode?.elm
// 将旧后指向的节点移动到旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 四种命中都没有成功
// 创建一个 key 的映射对象,方便新节点在旧节点中寻找是否有相同的 key
const keyMap = {}
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i]?.key
if (key) keyMap[key] = i
}
const idxInOld = keyMap[newStartVnode.key] // 在旧节点中寻找新前指向的节点
if (idxInOld) {
// 如果有,说明该节点在旧节点中存在,只需要移动节点位置
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
// 处理过的节点赋值为 undefined
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
} else {
// 如果没有,说明是个新节点
parentElm.insertBefore(creatElement(newStartVnode), oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
/**
* while 循环结束的条件只有两种
* 1. oldStartIdx > oldEndIdx
* 旧节点先处理完毕,说明新节点还有指针没指到并处理的节点,新节点有增加
* 2. newStartIdx > newEndIdx
* 新节点先处理完毕,说明新节点有删除
*/
if (oldStartIdx > oldEndIdx) { // 新节点有增加
// 这里不能用真实 DOM 才有的属性 nextSibling
const before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null
console.log(newCh[newEndIdx + 1],newCh[newEndIdx + 1].elm)
for (let i = newStartIdx; i <= newEndIdx; i++) {
parentElm.insertBefore(creatElement(newCh[i]), before)
}
} else { // 新节点有删除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
parentElm.removeChild(oldCh[i].elm)
}
}
}