Vue.js中的Diff算法解析:优化渲染性能的关键

99 阅读4分钟

Diff算法介绍

在比较两组子节点(前文已经提到过)时用于比较的算法就叫做Diff算法,它的出现是为了减小操作DOM产生的性能开销,总的来说Diff算法的作用就是在比较一组新旧vnode子节点时能够以最小的性能开销完成节点的更新操作。

回想之前遇到的一种情况就是新的vnode的子节点是一组子节点类型,旧的vnode子节点也是一组子节点类型,当时采取的方法是先把旧的vnode全部卸载,再全部挂载新的vnode,比如:

// 旧vnode
const oldVnode = {
    type:'div',
    children:[
        {type:'p',children:1},
        {type:'p',children:2},
        {type:'p',children:3},
    ]
}

// 新vnode
const newVnode = {
    type:'div',
    children:[
        {type:'p',children:1},
        {type:'p',children:2},
        {type:'p',children:3},
    ]
}

如果按照上述所说,两个长度相同的vnode的子节点总共将要进行6次DOM操作,我们完全可以对其进行优化。

function patchChildren(oldVnode,newVnode,container){
    // ...省略部分代码 只讨论节点是一组子节点类型的情况
    if(Array.isArray(newVnode.children)){
        if(Array.isArray(oldVnode.children)){
            const oldChildren = oldVnode.children;
            const newChildren = newVnode.children;
            
            for(let i = 0;i < oldChildren.length;i++){
                patch(oldChildren[i],newChildren[i],container)
            }
        }
    }
}

在上述代码中,当旧的节点和新的节点都是一组子节点类型时,遍历其中一个节点并将两者中对应位置的节点分别传递给patch函数中进行更新,然后又会命中patchChildren函数中文本节点类型的判断并且只更新其文本节点的内容。这样一来就把原来的6次DOM操作减少为3次DOM操作。

新旧子节点长度不同

上述新旧子节点在对比时长度是相同的,问题很明显,因为还会有长度不一样的情况。当两者数量不相同时,应当遍历其中最短的那一组子节点的长度然后调用patch函数进行更新,这个时候最短的那一组节点已经给更新完了,接着再来判断新旧两组子节点的长度,如果新的一组子节点的长度更长说明有新的子节点需要挂载,如果旧的一组子节点的长度更长说明有旧的子节点需要卸载。

function patchChildren(oldVnode,newVnode,container){
    if(Array.isArray(newVnode.children)){
        if(Array.isArray(oldVnode.children)){
            const oldChildren = oldVnode.children;
            const newChildren = newVnode.children;
            const oldLen = oldVnode.children;
            const newLen = newVnode.children;
            const commonLen = Math.min(oldLen.newLen);// 找出最短一组子节点的长度
            for(let i = 0;i < commonLen;i++){
                patch(oldChildren[i],newChildren[i],container); // 先把两者的公共部分进行对比更新
            }
            if(newLen > oldLen){ // 说明需要挂载新的子节点
                for(let i = commonLen;i < newLon;i++){
                    patch(null,newChildren[i],container);
                }
            }   
            if(oldLen > newLen){ // 说明需要卸载旧的子节点
                for(let i = commonLen;i < oldLen;i++){
                    unmount(oldChildren[i])
                }
            }
        }
    }
}

这样无论两组子节点的数量如何都能正确的挂载或卸载它们。

key的作用

还有一种情况是新旧子节点的长度相同但是顺序不同,比如:

const oldVnode = {
    {type:'p',children:1},
    {type:'span',children:2},
    {type:'div',children:3},
}

const newVnode = {
    {type:'div',children:3},
    {type:'p',children:1},
    {type:'span',children:2},
}

可以发现两组节点中只是顺序不同,所以最佳的处理方式应该是通过移动子节点来完成更新,但是通过移动的方式进行更新有个前提就是新旧两组子节点的确存在可复用的节点。 也就是说新的子节点在旧的一组子节点中要有相同的或者说是对应的,有的同学可能会想到使用vnode.type来进行区分,只要vnode.type的值相同就认为两者是相同的节点,但是这样会有缺陷,比如以下这种情况:

const oldVnode = {
    {type:'p',children:1},
    {type:'p',children:2},
    {type:'p',children:3},
}

const newVnode = {
    {type:'p',children:3},
    {type:'p',children:1},
    {type:'p',children:2},
}

如果新旧子节点的vnode.type都一样这时候就不知道应该怎么移动DOM元素了,所以就需要引入额外的key来作为vnode的标识。 只要两个vnode的type和key值相同我们就认为它们是相同的,就可以进行DOM元素的复用。

function patchChildren(oldVnode,newVnode,container){
    if(Array.isArray(newVnode.children)){
        if(Array.isArray(oldVnode.children)){
            const oldChildren = oldVnode.children;
            const newChildren = newVnode.children;
            for(let i =0;i < newChildren.length; i++){
                const newVnode = newChildren[i];
                for(let i =0;i < oldChildren.length; i++){
                    const oldVnode = oldChildren[i];
                    if(newVnode.key === oldVnode[key]){
                        patch(oldVnode,newVnode,container);
                        break;
                    }
                }
            }
        }
    }
}

在上面的代码中使用了两次循环,外层循环新节点,内层循环旧节点,在内层循环中逐个对比新旧子节点的key值,找出相同的后调用patch函数更新,经过这一步操作后就能够保证所有可复用的节点本身已经更新完毕。

以上就是关于Diff算法的内容,里面其实还有很多东西,这里只算是简单的Diff算法后面继续了解其他Diff算法实现。