双端diff算法

文章讨论了双端diff算法在JavaScript框架Vue中的应用,尤其是在处理虚拟DOM更新时,相比于简单diff,双端diff能更高效地利用节点复用,减少DOM移动。文中详细解释了算法原理和在不同情况下的处理策略。
摘要由CSDN通过智能技术生成

1.双端diff算法

如下图,如果使用简单diff算法真实 DOM 节点会移动两次,但是实际上通过简单的观察可以发现只需要移动一次p-3就可以。

所以得出结论:简单diff算法的性能在某些场景下并不是最好的。对于上述的例子,使用双端diff算法的性能会更高。  

 

2.双端diff算法比较原理

双端diff是一种对新旧两组子节点的端点进行比较的算法。(比较的是key)

vue对每个虚拟节点都会设置key,对比时是通过key来进行对比,
但是for循环这种情况比较特殊,vue不会给for循环自动添加key,而需要编码时手动添加key,因为for循环里面的顺序是随时变化的。
通过打印虚拟节点就可以看到,for循环是没有手动给key的

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

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

在双端比较中,每一轮比较都分为四个步骤。

  • 第一步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的第一个子节点 p-4,看它们是否相同。这里不相同,什么都不做。

  • 第二步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的最后一个子节点 p-3,看它们是否相同。这里不相同,什么都不做。

  • 第三步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的最后一个子节点 p-3,看它们是否相同。这里不相同,什么都不做。

  • 第四步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的第一个子节点 p-4。由于它们key相同,所以可以进行 DOM 复用。

 思考下,p-4节点所对应的真实 DOM 此时应该如何移动?代码如何实现?

/*
* n1 旧的一组子节点
* n2 新的一组子节点
* container 真实 DOM 的锚点
*/
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]

    // 双端比较
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (!oldStartVNode) {
            oldStartVNode = oldChildren[++oldStartIdx]
        } else if (!oldEndVNode) {
            newEndVNode = oldChildren[--oldEndIdx]
        } else if (oldStartVNode.key === newStartVNode.key) {
            // 节点在新的顺序中都处于头部, DOM 不需要移动的
            // 更新索引
            oldStartVNode = oldChildren[++oldStartIdx]
            newStartVNode = newChildren[++newStartIdx]
        } else if (oldEndVNode.key === newEndVNode.key) {
            // 节点在新的顺序中都处于尾部,DOM 不需要移动的
            // 更新索引尾部节点的变量
            oldEndVNode = oldChildren[--oldEndIdx]
            newEndVNode = newChildren[--newEndIdx]

        } else if (oldStartVNode.key === newEndVNode.key) {
            // 移动 DOM 操作
            // oldStartVNode.el 移动到 oldEndVNode.el.nextSibling
            insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
            // DOM 移动完成后,更新索引值,然后指向下一个位置
            oldStartVNode = oldChildren[++oldStartIdx]
            newEndVNode = newChildren[--newEndIdx]
        } else if (oldEndVNode.key === newStartVNode.key) {
            // 移动 DOM 操作
            // oldEndVNode.el 移动到 oldStartVNode.el 前面
            insert(oldEndVNode.el, container, oldStartVNode.el)
            oldEndVNode = oldChildren[--oldEndIdx]
            newStartVNode = newChildren[++newStartIdx]
        } else {
            // 遍历旧的一组子节点,找到newStartVNode拥有相同key的元素
            const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)
            // idxInOld 大于 0,说明找到了可复用的节点,并且需要将对应的真实 DOM 移动到头部
            if (idxInOld > 0) {
                // idxInOld 对应旧子节点中需要移动的节点
                const vnodeToMove = oldChildren[idxInOld]
                // 将 vnodeToMove.el 移动到头部节点 oldStartVNode 前面
                insert(vnodeToMove.el, container, oldStartVNode.el)
                // idxInOld 处的节点对应的真实 DOM 已经处理过,这里设置为undefined
                oldChildren[idxInOld] = undefined
                // 更新新子节点头部节点的指针
                newStartVNode = newChildren[++newStartIdx]
            }
        }
    }
}

 

3.非理想状况的处理方式

在进行双端比较中,可能会出现第一轮比较时,无法找到可以复用的节点。

在这种情况下,需要尝试非头部,尾部的节点能否复用。可以用新的一组子节点去旧的一组子节点去寻找。

新节点在旧节点中不存在时会直接忽略这个节点,更改索引进行下一轮的查找。
其实无论是哪种情况,数据或者节点变化都不会立即去更新页面。vue的渲染更新本身是异步的。

这里拿新的一组子节点的头部节点p-2去旧的一组子节点中查找时,会在索引为1的位置找到可复用的节点。说明p-2旧子节点对应的真实 DOM 需要移动到旧子节点 p-1 所对应的真实 DOM 之前。

已经处理过的旧子节点后续不用继续比较,这里设置为undefined。然后开始进行双端比较。在这一轮的比较第四步,找到了可复用的节点。所以将真实 DOM 中的p-4移动到p-1的前面,并且更新对应指针。

在这一轮比较的第一步就找到了可复用的节点,不需要移动 DOM 节点,并更新指针。

在这一轮比较中发现旧子节点中头部节点为undefined。不用比较,直接跳过。

继续进行比较,发现第一轮比较就可以找到复用节点。不需要移动 DOM,更新指针。这时满足循环停止条件,双端比较结束。

思考下,以上内容转变为代码将如何编写?

4..重点(总结双端diff算法查找过程)

1.双端diff算法即对新旧虚拟节点按照首首,尾尾,旧头新尾,新头旧尾的顺序进行查找。

2.查找到以后头尾的索引进行对应递增或者递减,如果是首首和尾尾直接更改索引值,如果是旧头新尾,新头旧尾查找到的会移动旧虚拟节点对应的真实的DOM顺序;

3.如果查找不到则通过newVnode节点去oldVnode中进行遍历查找,查找到以后需要移动oldVnode对应的真实DOM;再将newVnode索引加1,旧的虚拟节点设置标识为undefined,查找索引不变;

4.设置为undefined的oldVnode下次进行双端查找时将被忽略不进行查找直接更改对应索引值;

5.如果newVnode通过索引方式也查找不到,就证明这个节点属于新增节点,对这种节点不会进行特殊操作直接修改索引进行下轮查找。

6.进行索引方式查找后,只更改新的虚拟节点对应的索引值,然后继续按顺序进行双端diff算法查找

7.整个双端diff算法的比对过程是一个循环过程,通过新旧虚拟节点的头尾索引不断递增或递减进行查找,循环结束的标志即新头索引<=新尾索引 && 旧头索引 <= 旧尾索引

8.注意所有处理都是先移动DOM节点,再更新索引。

5.简单diff算法和双端diff算法对比

简单diff算法和双端diff算法都有各自的优缺点。
简单diff算法是针对虚拟DOM节点数据较少的情况,而双端diff算法针对节点数据较多的情况。
如果虚拟DOM节点数据较少时,使用简单diff算法的效率比使用双端diff算法的效率更高。
而数据量大时,因为双端diff算法是通过双端查找,查找的次数会较少,所以效率会更高

6.手写整个过程

        /* 1.双端diff算法即对新旧虚拟节点按照首首,尾尾,旧头新尾,新头旧尾的顺序进行查找。
        2.查找到以后头尾的索引进行对应递增或者递减,如果是首首和尾尾直接更改索引值,如果是旧头新尾,新头旧尾查找到的会移动旧虚拟节点对应的真实的DOM顺序;
        3.如果查找不到则通过newVnode节点去oldVnode中进行遍历查找,查找到以后需要移动oldVnode对应的真实DOM;再将newVnode索引加1,旧的虚拟节点设置标识为undefined,查找索引不变;
        4.设置为undefined的oldVnode下次进行双端查找时将被忽略不进行查找直接更改对应索引值;
        5.如果newVnode通过索引方式也查找不到,就证明这个节点属于新增节点,对这种节点不会进行特殊操作直接修改索引进行下轮查找。
        6.进行索引方式查找后,只更改新的虚拟节点对应的索引值,然后继续按顺序进行双端diff算法查找
        7.整个双端diff算法的比对过程是一个循环过程,通过新旧虚拟节点的头尾索引不断递增或递减进行查找,循环结束的标志即新头索引<=新尾索引 && 旧头索引 <= 旧尾索引
        */
        // 理想状态和非理想状态
        function patchChildren(n1, n2, container) {

            let newChildren = n1.children;
            let oldChildren = n2.children;

            // 设置双端的四个索引初始值
            let newStartIdx = 0;
            let oldStartIdx = 0;
            let newEndIdx = newChildren.length - 1;
            let oldEndIdx = oldChildren.length - 1;

            // 获取双端初始的虚拟节点数据
            let newStartVnode = newChildren[newStartIdx];
            let oldStartVnode = oldChildren[oldStartIdx];
            let newEndVnode = oldChildren[newEndIdx];
            let oldEndVnode = oldChildren[oldEndIdx];

            // 整个过程是循环执行的,结束标识为新头索引<=新尾索引 && 旧头索引 <= 旧尾索引
            while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
                // 对设置为undefined的虚拟节点进行处理(直接更改索引值)
                if (!oldStartVNode) { //或者判断!oldStartVNode
                    oldStartVnode = oldChildren[++oldStartIdx]
                } else if (!oldEndVnode) {
                    oldEndVnode = oldChildren[--oldEndIdx]
                } else if (newStartVnode.key === oldStartVnode.key) {
                    // 进行双端diff算法查找(首首,尾尾,旧头新尾,新头旧尾),比对的是两个子节点的key
                    newStartVnode = newChildren[++newStartIdx]
                    oldStartVnode = oldChildren[++oldStartIdx]

                } else if (newEndVnode.key === oldEndVnode.key) {
                    // 尾尾相等,都是处于末端,不需要移动虚拟DOM,只需要更改索引
                    newEndVnode = newChildren[--newEndIdx]; //先将索引+1再赋值给虚拟DOM
                    oldEndVnode = oldChildren[--oldEndIdx];

                } else if (oldStartVnode.key === newEndVnode.key) {
                    // 旧尾新尾相等,需要移动虚拟DOM,且更改索引
                    insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling()); //oldStartIdx.el 移动到 newEndIdx.el 后面

                    oldStartVnode = oldChildren[++oldStartIdx]; //先将索引+1再赋值给虚拟DOM
                    newEndVnode = newChildren[--newEndIdx];
                } else if (newStartVnode.key === oldEndVnode.key) {
                    // 新头旧尾相等,则移动旧虚拟节点对应的真实DOM,并且更改两端索引
                    // 移动DOM节点
                    insert(oldEndVNode.el, container, oldStartVNode.el); //此时是oldEndVNode.el 移动到 oldStartVNode.el 前面

                    // ++i后者是先自增,后赋值
                    newStartVnode = newChildren[++newStartIdx]; //先将索引+1再赋值给虚拟DOM
                    oldEndVnode = oldChildren[--oldEndIdx];

                } else {
                    // 这种是通过首首,尾尾,旧头新尾,新头旧尾没有查找到时,通过新头去旧的虚拟节点中遍历查找
                    let idxInOld = oldChildren.findIndex(node => newStartVnode.key === node.key);

                    if (idxInOld) {
                        // 注意需要先移动当前index所在的虚拟节点
                        // idxInOld 对应旧子节点中需要移动的节点
                        const vnodeToMove = oldChildren[idxInOld]
                        // 将 vnodeToMove.el 移动到头部节点 oldStartVNode 前面
                        insert(vnodeToMove.el, container, oldStartVNode.el)

                        // 新头去旧的虚拟节点中遍历查找到了,需要先移动虚拟节点,再将当前虚拟节点标识为undefined即可,且修改索引
                        oldChildren[idxInOld] = undefined;

                        // 将新头的索引+1,旧头不变
                        newStartVnode = [++newStartIdx];
                    } else {
                        // 没有找到会忽略这个节点
                        newStartVnode = [++newStartIdx];
                    }
                }
            }
        }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值