likes
comments
collection
share

diff进化,从简单到双端到快速

作者站长头像
站长
· 阅读数 3

diff算法应该是每一个前端儿日常逛论坛或者面试的时候都经常会遇到的一个问题,本文是对简单diff、双端diff、快速diff章节的总结。

简单diff

最简单的更新

在进入简单diff之前,先来说一种更为简单粗暴的更新,分为3种情况:

1. 新旧节点列表数量相同,逐一更新节点列表

diff进化,从简单到双端到快速

2. 旧节点比较多时,卸载掉多余的旧节点

diff进化,从简单到双端到快速

3. 新节点比较多时,挂载新节点

diff进化,从简单到双端到快速

示意代码如下,patch函数用于更新节点,umnount函数用于卸载节点,下同,文章主要介绍diff算法,具体实现不列出。

/**
 * 节点更新函数
 * @param {*} oldVNode 旧节点
 * @param {*} newVNode 新节点
 * @param {*} container 父容器
 * @param {*} anchor 需要插入的话,插入的位置锚点
 */
function patch(oldVNode, newVNode, container, anchor) {
    // 省略具体实现,和具体diff算法无关
}

// 新旧节点列表的长度
const oldLen = oldChildren.length
const newLen = newChildren.length

// 两组子节点的公共长度,即两者中较短的那一组子节点的长度
const commonLength = Math.min(oldLen, newLen)

// 遍历 commonLength 次
for (let i = 0; i < commonLength; i++) {
	patch(oldChildren[i], newChildren[i], container)
}

// 如果 newLen > oldLen,说明有新子节点需要挂载
if (newLen > oldLen) {
    for (let i = commonLength; i < newLen; i++) {
        patch(null, newChildren[i], container)
    }
} else if (oldLen > newLen) {
    // 如果 oldLen > newLen,说明有旧子节点需要卸载
    for (let i = commonLength; i < oldLen; i++) {
        unmount(oldChildren[i])
    }
}

如何判断DOM节点可复用

在上面的更新策略中,不管两个节点是不是同类型的都直接patch了,但在一些情况下节点列表是只调换下顺序就可以完成更新的,如下:

// oldChildren
[
    { type: 'p', children: '1' },
    { type: 'p', children: '2' },
    { type: 'p', children: '3' }
]
// newChildren
[
    { type: 'p', children: '3' },
    { type: 'p', children: '1' },
    { type: 'p', children: '2' }
]

但是还有一个问题是,列表中都是p标签,无法判断他们之间的对应关系,这个时候我们就引入了key来作为节点的额外标识,如下:

// oldChildren
[
  { type: 'p', children: 'p1', key: 1 },
  { type: 'p', children: 'p2', key: 2 },
  { type: 'p', children: 'p3', key: 3 },
]
// newChildren
[
  { type: 'p', children: 'p3', key: 3 },
  { type: 'p', children: 'p1', key: 1 },
  { type: 'p', children: 'p2', key: 2 },
]

节点的type和key都相同,我们就认为他们是可复用的节点,在进行移动操作之前,我们先对可复用的节点进行更新:

const oldChildren = n1.children
const newChildren = n2.children

// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    // 遍历旧的 children
    for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]
        // 如果找到了具有相同 key 值的两个节点,说明可以复用,但仍然需要调用 patch 函数更新
        if (newVNode.key === oldVNode.key) {
            patch(oldVNode, newVNode, container)
            break // 这里需要 break
        }
    }
}

(PS:第一节的代码已经没用了)我们遍历新节点列表,在旧节点列表中寻找可复用的节点进行patch,到这一步,简单diff的可复用节点的更新就全部完成了。

判断节点是否需要移动

在如何判断节点列表是否要做移动的问题上,可以反过来想节点列表怎么样不需要做移动,节点顺序没有变动不就不用移动了嘛,好!好一句有用的废话!

实际上我们就是要找出节点顺序没有变动的情况时节点列表呈现什么规律,如果这个规律被打破了,那么就说明节点顺序变动了。

// oldchildren
[
  { type: 'p', children: 'p1', key: 1 },
  { type: 'p', children: 'p2', key: 2 },
  { type: 'p', children: 'p3', key: 3 },
]
// newChildren
[
  { type: 'p', children: 'p1', key: 1 },
  { type: 'p', children: 'p2', key: 2 },
  { type: 'p', children: 'p3', key: 3 },
]

针对这一组新旧节点列表用上节的遍历方法进行遍历并记录每一次找到时的在旧列表中的索引,把这个记录下来的列表依次排序,为0、1、2,是一个递增的序列,在索引序列呈现递增的情况下,我们认为节点无须移动。

再来看看新旧节点列表需要调整顺序的例子:

// oldchildren
[
  { type: 'p', children: 'p1', key: 1 },
  { type: 'p', children: 'p2', key: 2 },
  { type: 'p', children: 'p3', key: 3 },
]
// newChildren
[
  { type: 'p', children: 'p3', key: 3 },
  { type: 'p', children: 'p1', key: 1 },
  { type: 'p', children: 'p2', key: 2 },
]

我们按照之前说的方法边遍历边记录索引列表:

  1. p3节点,索引顺序为2;
  2. p1节点,索引顺序为0,此时递增的规律被打破了,0 < 2说明在旧节点列表中,p1在p3前面,但是新节点列表中p1是在p3后面的,所以出现了这种打破递增规律的情况下,我们就认为该节点需要移动
  3. p2节点,索引顺序为1,同样的 1 < 2,p2节点也需要移动

索引序列为2、0、1

更新记录索引的代码如下:

const oldChildren = n1.children
const newChildren = n2.children

// 记录遍历过程中遇到的最大索引
+ let lastIndex = 0
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    // 遍历旧的 children
    for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]
        // 如果找到了具有相同 key 值的两个节点,说明可以复用,但仍然需要调用 patch 函数更新
        if (newVNode.key === oldVNode.key) {
            patch(oldVNode, newVNode, container)
+            if (j < lastIndex) {
+                // 需要移动
+            } else {
+                // 不需要移动时则更新最大索引
+                lastIndex = j
+            }
            break // 这里需要 break
        }
    }
}

移动元素

判断完节点需要移动后,接下来的问题就是如何移动元素,其实也很简单,就是:移动到该节点的前一个节点的后面,如下:

if (j < lastIndex) {
    // 需要移动
    // 先获取newVNode的前一个vnode,即prevVNode
    const prevVNode = newChildren[i - 1]
    if (prevVNode) {
        // 由于我们要将newVNode对应的真实DOM移动到prevVNode所对应真实DOM后面
        // 所以我们需要获取prevVNode所对应真实DOM的下一个兄弟节点,并将其作为锚点
        // nextSibling, 如果指定的节点为最后一个节点,则返回 null。
        const anchor = prevVNode.el.nextSibling
        insert(newVNode.el, container, anchor)
    }
} else {
    lastIndex = j
}

// insert的实现如下
function insert(el, parent, anchor = null) {
    // insertBefore语法:var insertedNode = parentNode.insertBefore(newNode, referenceNode);
    // 如果 referenceNode 为 null 则 newNode 将被插入到子节点的末尾*。*
    parent.insertBefore(el, anchor)
}

梳理一下简单diff从1、2、3到3、1、2的整个过程

  1. 遍历到p3节点,可以在旧节点列表中找到,patch,大于lastIndex,无需移动,更新lastIndex为2;
  2. 遍历到p1节点,可以在旧节点列表中找到,patch,小于lastIndex,需要移动,prevVNode为p3,p3节点没有下一个兄弟节点,anchor为null,将p1移动到了最后面;
  3. 遍历到p2节点,可以在旧节点列表中找到,patch,小于lastIndex,需要移动,prevVNode为p1,p1节点没有下一个兄弟节点,anchor为null,将p2移动到了最后面
diff进化,从简单到双端到快速

图源《Vue设计与实现》

添加新节点和移除旧节点

在遍历过程中,没有能在旧节点列表中找到可复用节点的即为新增的节点,需要挂载,定义变量find,用于判断是否有找到可复用的节点:

let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    let find = false
    for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]
        // type和key相同,则认为是可以复用的节点
        if (newVNode.key === oldVNode.key) {
            find = true
           // ...省略
        }
    }
    // 挂载新节点
    if (!find) {
        const prevVNode = newChildren[i - 1]
        let anchor = null
        // 挂载的位置和移动时差不多,如果没有找到前一个元素,则挂载在第一个元素之前
        if (prevVNode) {
            anchor = prevVNode.el.nextSibling
        } else {
            anchor = container.firstChild
        }
        // 挂载newVNode
        patch(null, newVNode, container, anchor)
    }
}

判断旧节点是否要移除,则反过来,在新节点列表中寻找是否有key相同的节点,没有则需要卸载

const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
// 其实就是应该是递增的,如果找到的是小于上一个的,那就说明要移动
for (let i = 0; i < newChildren.length; i++) {
    for (let j = 0; j < oldChildren.length; j++) {
        // ...省略
    }
    // 挂载新节点
    if (!find) {
        // ...省略
    }
}

// 遍历旧节点,新节点中如果没有就卸载
for (let i = 0; i < oldChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const has = newChildren.find((vNode) => vNode.key === oldVNode.key)
    if (!has) {
        console.log('not has');
        unMounted(oldVNode)
    }
}

至此就完成了整个简单diff的过程,过程中比较重要的点有:

  1. 寻找可复用的节点,可复用的条件是type和key相同,不过在文章中省略了type的判断;
  2. 根据是否递增的索引列表来判断是否要移动。

双端diff

在介绍简单diff时,举例了从1、2、3变化到3、1、2时需要移动两次来完成列表的更新,但是就算时凭借我们这算力不是很高的脑子来看,其实只需要把3移动到最前面就可以完成更新。为此介绍更高效的双端diff,这也是Vue2中使用的diff算法。

分别定义指向新旧节点列表首尾的四个索引,并记录指向的具体节点:

function patchKeyedChildren(n1, n2, container) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 四个索引值
    let oldStartIdx = 0
    let oldEndIdx = oldChildren.length - 1
    let newStartIdx = 0
    let newEndIdx = newChildren.length - 1
    
    // 四个索引指向的vNode节点
    let oldStartVNode = oldChildren[oldStartIdx]
    let oldEndVNode = oldChildren[oldEndIdx]
    let newStartVNode = newChildren[newStartIdx]
    let newEndVNode = newChildren[newEndIdx]
}

以如下的新旧节点列表来进行双端diff的过程说明:

// old
[
    { type: 'p', children: 'p1', key: 1 },
    { type: 'p', children: 'p2', key: 2 },
    { type: 'p', children: 'p3', key: 3 },
    { type: 'p', children: 'p4', key: 4 }
]
// new
[
    { type: 'p', children: 'p4', key: 4 },
    { type: 'p', children: 'p2', key: 2 },
    { type: 'p', children: 'p1', key: 1 },
    { type: 'p', children: 'p3', key: 3 }
]

在双端diff的每一轮中都分为四个步骤:

  1. 头头比较
  2. 尾尾比较
  3. 旧头新尾比较
  4. 新头旧尾比较

对示例的新旧节点列表运用上述步骤:

  1. 头头比较,1和4,不同
  2. 尾尾比较,4和3,不同
  3. 旧头新尾比较,1和3,不同
  4. 新头旧尾比较,4和4,相同,复用

知道了可以复用了,又因为从尾巴成了头头,所以需要移动,那么应该移动到哪里呢?很明显,因为变成了头头,所以要移动到头部,如下:

function patchKeyedChildren(n1, n2, container) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 四个索引值
    let oldStartIdx = 0
    let oldEndIdx = oldChildren.length - 1
    let newStartIdx = 0
    let newEndIdx = newChildren.length - 1
    
    // 四个索引指向的vNode节点
    let oldStartVNode = oldChildren[oldStartIdx]
    let oldEndVNode = oldChildren[oldEndIdx]
    let newStartVNode = newChildren[newStartIdx]
    let newEndVNode = newChildren[newEndIdx]
    
    if (oldStartVNode.key === newStartVNode.key) {
        // 头头比较

    } else if (oldEndVNode.key === newEndVNode.key) {
        // 尾尾比较
    } else if (oldStartVNode.key === newEndVNode.key) {
        // 旧头新尾比较
    } else if (oldEndVNode.key === newStartVNode.key) {
        // 旧尾新头比较,先更新
        patch(oldEndVNode, newStartVNode, container)
        // 移动DOM操作
        // oldEndVNode.el 移动到 oldStartVNode.el前面
        insert(oldEndVNode.el, container, oldStartVNode.el)

        // 移动完之后更新索引值,指向下一个位置
        oldEndVNode = oldChildren[--oldEndIdx]
        newStartVNode = newChildren[++newStartIdx]
    } else {
        // 头头、尾尾、头尾、尾头都没有命中
    }
}

更新移动完之后,我们更新索引和指向的节点,命中了新头旧尾情况,所以oldEndIdx需要向后移动,即减,newStartIdx需要向前移动,即加。

依此类推其他三种情况的更新和移动逻辑:

  1. 头头匹配成功,更新,因为都在头部,所以无需移动,oldStartIdxnewStartIdx分别向前移动;
  2. 尾尾匹配成功,更新,因为都在尾部,所以无需移动,oldEndIdxnewEndIdx分别向后移动;
  3. 旧头新尾匹配成功,更新,因为从头部变成了尾部,所以移动到最后,oldStartIdx向前移动,newEndIdx向后移动。

循环节点列表,添加逻辑如下:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVNode.key === newStartVNode.key) {
        // 头头比较
        patch(oldStartVNode, newStartVNode, container)

        oldStartVNode = oldChildren[++oldStartIdx]
        newStartVNode = newChildren[++newStartIdx]

    } else if (oldEndVNode.key === newEndVNode.key) {
        // 尾尾比较
        patch(oldEndVNode, newEndVNode, container)

        oldEndVNode = oldChildren[--oldEndIdx]
        newEndVNode = newChildren[--newEndIdx]
    } else if (oldStartVNode.key === newEndVNode.key) {
        // 旧头新尾比较
        patch(oldStartVNode, newEndVNode, container)
        // 插到最后
        insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)

        oldStartVNode = oldChildren[++oldStartIdx]
        newEndVNode = newChildren[--newEndIdx]
    } else if (oldEndVNode.key === newStartVNode.key) {
        // 旧尾新头比较
        patch(oldEndVNode, newStartVNode, container)
        // 移动DOM操作
        // oldEndVNode.el 移动到 oldStartVNode.el前面
        insert(oldEndVNode.el, container, oldStartVNode.el)

        // 移动完之后更新索引值,指向下一个位置
        oldEndVNode = oldChildren[--oldEndIdx]
        newStartVNode = newChildren[++newStartIdx]
    } else {
        // 头头、尾尾、头尾、尾头都没有命中,就直接找
    }
}

四种情况都没有命中的处理方式

当然也会有四种匹配方式都没有命中的情况出现,如下面的新旧节点列表:

// old
[
    { type: 'p', children: 'p1', key: 1 },
    { type: 'p', children: 'p2', key: 2 },
    { type: 'p', children: 'p3', key: 3 },
    { type: 'p', children: 'p4', key: 4 }
]
// new
[
    { type: 'p', children: 'p2', key: 2 },
    { type: 'p', children: 'p4', key: 4 },
    { type: 'p', children: 'p1', key: 1 },
    { type: 'p', children: 'p3', key: 3 }
]

在这种情况下我们就直接拿新节点列表的第一个节点去旧节点列表找,然后把找到的节点插到最前面,如果还是没有找到,那么就说明是新增的节点:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVNode.key === newStartVNode.key) {
        // 头头比较
    } else if (oldEndVNode.key === newEndVNode.key) {
        // 尾尾比较
    } else if (oldStartVNode.key === newEndVNode.key) {
        // 旧头新尾比较
    } else if (oldEndVNode.key === newStartVNode.key) {
        // 旧尾新头比较
    } else {
        // 头头、尾尾、头尾、尾头都没有命中,就直接找
        const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key) 
        
        if (idxInOld > 0) {
            // 能找到就是插入到最前面
            const oldNodeToMove = oldChildren[idxInOld]
            patch(oldNodeToMove, newStartVNode, container)
            insert(oldNodeToMove.el, container, oldStartVNode.el)

            // 值为空
            oldChildren[idxInOld] = undefined
        } else {
            // 没找到,说明是新增的节点
            patch(null, newStartVNode, container, oldStartVNode.el)
        }
        // 移动新队列的开始索引
        newStartVNode = newChildren[++newStartIdx]
    }
}

需要注意,如果能直接找到,需要把旧节点oldChildren[idxInOld]置空,由此在循环过程中就需要新增对于旧节点为undefined的判断,如果为空时就跳过处理该节点:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
+   if (!oldStartVNode) {
+       oldStartVNode = oldChildren[++oldStartIdx]
+   } else if (!oldEndVNode) {
+       oldEndVNode = oldChildren[--oldEndIdx]
    } else if (oldStartVNode.key === newStartVNode.key) {
        // 头头比较
    } else if (oldEndVNode.key === newEndVNode.key) {
        // 尾尾比较
    } else if (oldStartVNode.key === newEndVNode.key) {
        // 旧头新尾比较
    } else if (oldEndVNode.key === newStartVNode.key) {
        // 旧尾新头比较
    } else {
        // 头头、尾尾、头尾、尾头都没有命中,就直接找
    }
}

添加新节点

另外还有一种需要挂载新节点的情况,如下面的新旧节点列表:

// old
[
    { type: 'p', children: 'p1', key: 1 },
    { type: 'p', children: 'p2', key: 2 },
    { type: 'p', children: 'p3', key: 3 },
]
// new
[
    { type: 'p', children: 'p5', key: 5 },
    { type: 'p', children: 'p4', key: 4 },
    { type: 'p', children: 'p1', key: 1 },
    { type: 'p', children: 'p2', key: 2 },
    { type: 'p', children: 'p3', key: 3 },
]

这种情况下每次都会命中尾尾的分支,最后剩下p5和p4节点需要挂载,这种情况下oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx,旧节点列表的尾部索引已经小于头部索引,但是新节点列表的头部索引还是小于等于尾部索引,这个时候我们就需要挂载这一部分的节点:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
	// ...
}

if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
    // 遗漏节点的情况
    // 如 1 2 3 -> 4 1 2 3
    // 如 1 2 3 -> 5 4 1 2 3
    for (let i = newStartIdx; i <= newEndIdx; i++) {
        patch(null, newChildren[i], container, oldStartVNode.el)
    }
} 

移除节点

最后就需要移除不存在的节点,例如从1 2 3 4 -> 1 4,中间的2和3就需要卸载,这种情况下newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx,卸载旧节点列表这之间的节点:

if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
    // 遗漏节点的情况
} else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
    // 旧子节点列表有多余的情况
    // 1 2 3 - 1 3
    // 1 2 3 4 -> 1 4
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
        unMounted(oldChildren[i])
    }
}

快速diff

快速diff是Vue3版本中采用的diff算法,相对于Vue2的双端diff性能更优。

节点预处理

快速diff的一个特点就是对节点进行预处理,整个diff过程中对列表前后可以复用的节点先处理,以旧节点列表1、2、3,新节点列表:1、4、2、3为例。

先处理相同的前置节点,建立索引j指向新旧两组节点的开头,从头遍历新旧节点列表,直到遇到不同的节点。

function patchKeyedChildren(n1, n2, container) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 处理相同的前置节点
    // 索引j指向新旧两组子节点的开头
    let j = 0
    let oldVNode = oldChildren[j]
    let newVNode = newChildren[j]
    // while向后遍历, 直到遇到拥有不同key值的节点为止
    while (oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        j++
        oldVNode = oldChildren[j]
        newVNode = newChildren[j]
    }
}

前置节点预处理完成后,再预处理后置节点,定义索引oldEndnewEnd分别指向新旧节点列表的尾部节点:

function patchKeyedChildren(n1, n2, container) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 处理相同的前置节点
    // ...
    
    // 更新相同的后置节点
    let oldEnd = oldChildren.length - 1
    let newEnd = newChildren.length - 1
    oldVNode = oldChildren[oldEnd]
    newVNode = newChildren[newEnd]

    while (oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        oldEnd--
        newEnd--
        oldVNode = oldChildren[oldEnd]
        newVNode = newChildren[newEnd]
    }
}

处理有新增节点的情况

前后置节点处理完成后,如果j > oldEndnewEnd >= j,说明有新增的节点需要挂载,挂载的位置以现在的newEnd的后一个元素为锚点逐个插入:

function patchKeyedChildren(n1, n2, container) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 处理相同的前置节点
    // ...
    
    // 更新相同的后置节点
    // ...
    
    // 预处理完毕后,如果满足以下条件,说明j 到 newEnd之间存在新节点
    if (j > oldEnd && j <= newEnd) {
        console.log('has new');
        // 锚点的索引
        const anchorIndex = newEnd + 1
        const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
        while (j <= newEnd) {
            patch(null, newChildren[j++], container, anchor)
        }
    } 
}

需要删除节点的情况

新旧节点的情况为新:1、3旧:1、2、3,当处理完前后置节点后,索引j为1,oldEnd为1,newEnd为0,此时j > newEnd && j <= oldEnd,说明j~oldEnd之间的节点需要卸载:

function patchKeyedChildren(n1, n2, container) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 处理相同的前置节点
    // ...
    
    // 更新相同的后置节点
    // ...
    
    // 预处理完毕后,如果满足以下条件,说明j 到 newEnd之间存在新节点
    if (j > oldEnd && j <= newEnd) {
    } else if (j > newEnd && j <= oldEnd) {
        console.log('has old');
        // j到oldEnd之间的节点应该被卸载
        while (j <= oldEnd) {
            unMounted(oldChildren[j++])
        }
    }
}

如何判断节点是否要进行移动

上面的情况都比较理想,前后置节点处理完之后刚好有一组节点全部处理完成,实际情况会更复杂,以下面的例子来说明节点移动的情况,有复用(1、5)、删除(6),新增(7),移动(2)

旧: 1 2 3 4 6 5

新: 1 3 4 2 7 5

经过前后置节点处理过程后,索引j为1,oldEnd为4,newEnd为4

创建source数组

如何判断节点是否要移动,要新增一个source数组,他的意义是:新节点对应的旧节点中的位置索引,用于计算最长递增子序列,辅助完成节点的移动:

if (j > oldEnd && j <= newEnd) {
    console.log('has new');
} else if (j > newEnd && j <= oldEnd) {
    console.log('has old');
    // j到oldEnd之间的节点应该被卸载
    // ...
} else {
    const count = newEnd - j + 1
    const source = new Array(count)
    source.fill(-1)
    
    const oldStart = j
    const newStart = j

    for (let i = j; i <= oldEnd; i++) {
        const oldVNode = oldChildren[i]

        // 遍历一组新的子节点
        for (let k = j; k <= newEnd; k++) {
            const newVNode = newChildren[k]
            if (newVNode.key === oldVNode.key) {
                patch(oldVNode, newVNode, container)
                source[k - newStart] = i // k - newStart source数组要从0开始
            }
        }
    }
}

source数组长度为newEnd - j + 1,也就是4 - 1 + 1 = 4,经过填充后结果为 [2, 3, 1, -1],p3对应旧节点列表索引位置为2,p4为3,p2为1,p7无对应节点

优化source数组生成

优化source数组的生成填充,为新的一组子节点构建一张索引表,用来存储节点的key和节点位置索引之间的映射关系:

if (j > oldEnd && j <= newEnd) {
    console.log('has new');
} else if (j > newEnd && j <= oldEnd) {
    console.log('has old');
    // j到oldEnd之间的节点应该被卸载
    // ...
} else {
    const count = newEnd - j + 1
    const source = new Array(count)
    source.fill(-1)
    
    const oldStart = j
    const newStart = j
    
    const keyIndex = {}
    for (let i = newStart; i <= newEnd; i++) {
        keyIndex[newChildren[i].key] = i // 这个i不是从0开始的喔
    }
}

keyIndex的结果如下:

{
    3: 1,
    4: 2,
    2: 3,
    7: 4
}

这样就可以依照key值是否有对应结果来快速判断旧节点列表中是否有对应节点:

if (j > oldEnd && j <= newEnd) {
    console.log('has new');
} else if (j > newEnd && j <= oldEnd) {
    console.log('has old');
    // j到oldEnd之间的节点应该被卸载
    // ...
} else {
    const count = newEnd - j + 1
    const source = new Array(count)
    source.fill(-1)
    
    const oldStart = j
    const newStart = j
    
    const keyIndex = {}
    for (let i = newStart; i <= newEnd; i++) {
        keyIndex[newChildren[i].key] = i // 这个i不是从0开始的喔
    }
    
    // 遍历旧的一组子节点中剩余未处理的节点
    for (let i = oldStart; i <= oldEnd; i++) {
        oldVNode = oldChildren[i]
        // 通过索引表快速找到新的一组子节点中具有相同 key 值的节点位置
        const k = keyIndex[oldVNode.key]
        
        if (typeof k !== 'undefined') {
            newVNode = newChildren[k]
            // 调用 patch 函数完成更新
            patch(oldVNode, newVNode, container)
            // 填充 source 数组
            source[k - newStart] = i
        } else {
            // 没找到
            unmount(oldVNode)
        }
    }
}

判断节点是否要移动

简单diff的判断是否要移动很相似,如果寻找旧节点过程中对应的索引值不是递增的,说明要需要移动,新增变量posmovedpatched,pos代表遍历过程中遇到的最大的索引值,如果出现某个节点匹配到时小于pos,那么moved就置为true,代表是需要移动节点的,每一次匹配成功就将patched的值递增,如果patched的值大于了新节点列表的值,说明剩下的节点是多余的,需要卸载:

if (j > oldEnd && j <= newEnd) {
    console.log('has new');
} else if (j > newEnd && j <= oldEnd) {
    console.log('has old');
    // j到oldEnd之间的节点应该被卸载
    // ...
} else {
    const count = newEnd - j + 1
    const source = new Array(count)
    source.fill(-1)
    
    const oldStart = j
    const newStart = j
    
    let moved = false
    let pos = 0
    // 构建索引表
    const keyIndex = {}
    for (let i = newStart; i <= newEnd; i++) {
        keyIndex[newChildren[i].key] = i
    }
    let patched = 0
    for (let i = oldStart; i <= oldEnd; i++) {
        oldVNode = oldChildren[i]

        if (patched <= count) {
            const k = keyIndex[oldVNode.key]
            if (typeof k !== 'undefined') {
                newVNode = newChildren[k]

                patch(oldVNode, newVNode, container)
                patched++
                source[k - newStart] = i
                // 判断节点是否需要移动
                if (k < pos) {
                    moved = true
                } else {
                    pos = k
                }
            } else {
                // 没找到
                unMounted(oldVNode)
            }
        } else {
            // 多出来了
            unMounted(oldVNode)
        }
    }
}

列一下循环的过程:

  1. 处理到旧节点p2,索引为1,key为2,keyIndex[2]为3,patched++,3 < 0 为false,pos更新为3,source[2] = 1;
  2. 处理到旧节点p3,索引为2,key为3,keyIndex[3]为1,patched++,1 < 3为true,moved更新为true,source[0] = 2;
  3. 处理到旧节点p4,索引为3,key为4,keyIndex[4]为2,patched++,2 < 3为true,moved为true,source[1] = 3
  4. 处理到旧节点p6,索引为4,key为6,keyIndex[6]为undefined,卸载旧节点p6

由此我们就知道了处理了一些不需要的节点,并且知道了有节点需要移动。

最长递增子序列和节点移动

什么是最长递增子序列?分开来看:最长、递增、子序列(虽然好像很傻,但就是这样),在所有子序列中,最长的呈递增趋势的就是最长递增子序列,例如序列 [0, 8, 4, 12] 的最长递增子序列可以是 [0, 8, 12],也可以是 [0, 4, 12]

为啥要求这个东西呢?还是那个原因,判断元素要不要移动,就是判断节点索引是不是在递增。

在我们例子中source数组是 [2, 3, 1, -1],那么最长递增子序列就是 [0, 1],即位置0和1的节点不需要移动。

最长递增子序列的计算参考源码(埋坑,后面研究研究):

function getSequence(arr: number[]): number[] {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      u = 0
      v = result.length - 1
      while (u < v) {
        c = (u + v) >> 1
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }
  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

为了完成节点的移动,我们还需要建立索引si,分别指向指向最长递增子序列的尾部和剩余子节点列表的尾部。 i值递减

  1. 如果source[i]为-1,说明是要挂载的新节点
  2. 如果i不是最长递增子序列中的seq[s],那么说明需要移动
  3. 如果i可以在最长递增子序列中,那么说明需要移动
if (j > oldEnd && j <= newEnd) {
    console.log('has new');
} else if (j > newEnd && j <= oldEnd) {
    console.log('has old');
    // j到oldEnd之间的节点应该被卸载
    // ...
} else {
    // ...
}

// 计算最长递增子序列
// 如果不存在需要移动的节点,则不需要计算最长递增子序列
const seq = moved ? getSequence(source) : []

// s指向最长递增子序列的最后一个元素
let s = seq.length - 1
// i指向新的一组子节点的最后一个元素
let i = count - 1

for (i; i >= 0; i--) {
    if (source[i] === -1) {
        // 需要新增
        // 该节点在新children的真实位置索引
        const pos = i + newStart
        const newVNode = newChildren[pos]

        // 该节点的下一个节点的位置索引
        const nextPos = pos + 1
        // 锚点
        const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
        patch(null, newVNode, container, anchor)
    } else if (moved) {
        if (i !== seq[s]) {  
            // 如果节点的索引i不等于seq[s]的值,说明该节点需要移动
            // 说明该节点需要移动
            // 该节点在新的一组子节点中的真实位置索引
            const pos = i + newStart
            const newVNode = newChildren[pos]
            // 该节点的下一个节点的位置索引
            const nextPos = pos + 1
            // 锚点
            const anchor = nextPos < newChildren.length
                ? newChildren[nextPos].el
                : null
            // 移动
            insert(newVNode.el, container, anchor)
        } else {
            // 当i === seq[s]时,说明该位置的节点不需要移动
            // 只需要让s指向下一个位置
            s--
        }
    } 
}

在我们的例子中,p7为-1,需要挂载,p2需要移动,p4、p3不需要移动,这样我们就完成了整个快速diff的过程。

有什么写的不对的地方或者什么疑问可以留言,希望文章有帮助到你~

参考书籍

《Vue.js设计与实现》————霍春阳