菜鸡手写vue(八)-diff算法

144 阅读6分钟

说明

当数据发生变化时,vue是如何更新页面的,是重新渲染一次页面,还是只需局部更新区域,显然,前者的性能花销太大了,后者更符合我们的需求。那么问题来了,vue是怎么精确做到局部页面更新的呢?将回流和重绘降到最低的?最小化dom操作的?

需求

当数据name发生改变时,只需更新.name的dom元素的文本节点,不影响其他元素,实现高效的复用dom元素。

<div id="app">
    你好
    <p>
        <span>{{name}}</span>
    </p>
</div>

<script>
const vm = new Vue({
    el: '#app',
    data(){
        return{
            name: 'lily',
        }
    },
})
setTimeout(() => {
    vm.name = 'jusco';
}, 3000);
</script>

diff算法

定义

diff算法主要用来对两个虚拟树进行对比,并且是同层级的对比,不会跨层级比较。目的是获知哪些dom可以复用,哪些dom需要移除,尽可能的复用已有元素,而不是每次改变数据都要重新创建一个新的元素。

原理

diff比较方式

首先要知道diff在比对两个虚拟树的时候,只会同层级的比较。例如左边的A只会和右边的A进行比较,绝对不会和右边的B进行比较。

image.png

步骤

  1. 如果两个虚拟节点的标签不一致,那就直接使用新虚拟节点替换掉旧节点;
  2. 如果两个虚拟节点的标签一样,但是是两个文本元素,那就使用新虚拟节点的文本替换掉旧虚拟节点的文本;
  3. 如果不是前两种情况的话,那说明这是两个相同元素并且不是文本元素,那就复用原节点,更新节点属性,更新儿子节点; 更新儿子节点
  • 如果旧虚拟节点有儿子,新虚拟节点没有儿子,则删除旧节点的儿子;
  • 如果旧虚拟节点没有儿子,新虚拟节点有儿子,则旧节点加上新虚拟节点的儿子;
  • 旧虚拟节点和新虚拟节点都有儿子; 对于旧虚拟节点和新虚拟节点都有儿子的情况,会采取一种双指针比较的方法。举个例子:
原代码:
<div id="app">
    <li key="a">A</li>
    <li key="b">B</li>
    <li key="c">C</li>
    <li key="d">D</li>
</div>

更新代码:
<div id="app">
    <li key="a">A</li>
    <li key="c">C</li>
    <li key="b">B</li>
    <li key="d">D</li>
</div>

旧虚拟节点的头指针为oldStartIndex,尾指针为oldEndIndex;新虚拟节点的头指针为newStartIndex,尾指针为newEndIndex。

  1. oldStartIndex指向的节点会和newStartIndex指向的节点进行比较,判断是否为相同节点(主要根据两个节点的标签名和key值是否都相同),如果是相同节点的话,就会复用旧节点,oldStartIndex指针和newStartIndex指针往后移动一位。 image.png

  2. 同样对B节点和C节点进行比较,但是这两不是相同节点;接着就会oldEndIndex指向的节点会和newEndIndex指向的节点进行比较,这两是相同节点,复用旧节点,oldEndIndex指针和newEndIndex指针往前移动一位。 image.png

  3. 再次对B节点和C节点进行比较,但是这两不是相同节点;接着对C节点和B节点进行比较,同样不相等;接着对oldStartIndex指向的节点会和newEndIndex指向的节点进行比较,节点相同,复用旧节点,将oldStartIndex指向的dom节点移动到oldEndIndex指向节点的dom元素后面,oldStartIndex往后移动一位,newEndIndex往前移动一位。 image.png

  4. 最后剩下的c节点和c节点比对,就是复用了。 image.png

  5. 加一种复杂情况的分析,旧虚拟节点有E节点,复用E节点,但是该E节点位置在oldStartIndex和oldEndIndex之间,就会将E节点对应的dom元素移动到oldStartIndex指向节点的dom元素前面,并且将E节点置空。 image.png

  6. 再加一种复杂情况的分析,旧虚拟节点中没有E节点,就会新创建E节点dom元素放到oldStartIndex指向节点的dom元素前面。 image.png

  7. 最后如果新虚拟节点如果比旧虚拟节点多的话,就会将剩余的节点插入到dom里面;如果新虚拟节点如果比旧虚拟节点少的话,也会进行删除dom。

文字描述可能不太清楚,最好还是看代码。

实现

将虚拟节点转变为真实节点时做两个虚拟节点的比较

 Vue.prototype._update = function(vnode){
    const vm = this;

    // 第一次初始化,第二次走diff算法
    const prevVnode = vm._vnode;
    vm._vnode = vnode;              // 保存上一次的虚拟节点
    if(!prevVnode){
        vm.$el = patch(vm.$el, vnode);
    }else{
        vm.$el = patch(prevVnode, vnode);
    }
}

export function patch(oldVnode, vnode){    
    if(!oldVnode){      // 1、组件
        return createElm(vnode);
    }

    const isRealElement =  oldVnode.nodeType; // 如果有nodeType说明是一个dom元素
    if(isRealElement){  // 2、初次渲染
        const oldEle = oldVnode;

        // 需要获取父节点,将当前节点的下一个元素作为参照参照物,之后删除老节点
        const parentNode = oldEle.parentNode;
        const el = createElm(vnode);
        parentNode.insertBefore(el, oldEle.nextSibling);
        parentNode.removeChild(oldEle);
        console.log('虚拟dom渲染出来的真实dom', el)
        return el;
    }else{              // 3、diff算法,两个虚拟节点比对
        /**
         * 1、如果两个虚拟节点的标签不一致,那就直接替换掉结束
         * 2、标签一样,但是是两个文本元素
         */
        if(oldVnode.tag !== vnode.tag){
            return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
        }
        if(!oldVnode.tag){
            if(oldVnode.text !== vnode.text){
                return oldVnode.el.textContent = vnode.text;
            }
        }
        /**
         * 3、元素相同,复用老节点,并且更新属性
         */
        let el = vnode.el = oldVnode.el;
        updateProperties(vnode, oldVnode.data);

        /* 4、更新儿子
         *  1、老的有儿子 新的也有儿子 dom-diff
         *  2、老的有儿子 新的没儿子 =》 删除老儿子
         *  3、新的有儿子 老的没儿子 =》老节点增加儿子
         */
        let oldChildren = oldVnode.children || [];
        let newChildren = vnode.children || [];

        if(oldChildren.length > 0 && newChildren.length > 0){
            updateChildren(el, oldChildren, newChildren);
        }else if(oldChildren.length > 0){
            el.innerHTML = '';
        }else if(newChildren.length > 0){
            newChildren.forEach(child => el.appendChild(createElm(child)))
        }
    }
}

整个diff算法过程都是先考虑处理简单的场景,最后再处理复杂场景,这也是diff的一个优化方式吧。vue是通过标签名与key值来确定两个元素是否为相同节点的,在v-for添加key值也是为了更好地复用节点。同时应避免避免使用index下标作为key值,因为这和没设置key值是同一个效果,看代码吧不想写了。

function updateChildren(parent, oldChildren, newChildren){
    let oldStartIndex = 0;                          // 老的头索引
    let oldEndIndex = oldChildren.length - 1;       // 老的尾索引
    let oldStartVnode = oldChildren[oldStartIndex]; // 老的开始节点
    let oldEndVnode = oldChildren[oldEndIndex];     // 老的结束节点

    let newStartIndex = 0;                          // 新的头索引
    let newEndIndex = newChildren.length - 1;       // 新的尾索引
    let newStartVnode = newChildren[newStartIndex]; // 新的开始节点
    let newEndVnode = newChildren[newEndIndex];     // 新的结束节点

    function makeIndexByKey(oldChildren){
        let map = {};
        oldChildren.forEach((item, index) => {
            map[item.key] = index;
        })
        return map;
    }
    let map = makeIndexByKey(oldChildren);

    while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){
        // 常见操作:尾部插入、头部插入、正序、反序
        if(!oldStartVnode){
            oldStartVnode = oldChildren[++oldStartIndex];
        }else if(!oldEndVnode){
            oldEndVnode = oldChildren[--oldEndIndex];
        }else if(isSameVnode(oldStartVnode, newStartVnode)){      // 1)头头比较 向后插入的操作
            patch(oldStartVnode, newStartVnode);            // 递归比对节点
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVnode = newChildren[++newStartIndex];
        }else if(isSameVnode(oldEndVnode, newEndVnode)){    // 2) 尾尾比较 向前插入的操作
            patch(oldEndVnode, newEndVnode);
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVnode = newChildren[--newEndIndex];
        }else if(isSameVnode(oldStartVnode, newEndVnode)){  // 3) 头尾比较 头移动到尾部
            patch(oldStartVnode, newEndVnode);
            parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
            oldStartVnode = oldChildren[++oldStartIndex];
            newEndVnode = newChildren[--newEndIndex];
        }else if(isSameVnode(oldEndVnode, newStartVnode)){  // 4) 尾头比较  尾移动到头部
            patch(oldEndVnode, newStartVnode);
            parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
            oldEndVnode = oldChildren[--oldEndIndex];
            newStartVnode = newChildren[++newStartIndex];
        }else{                                              // 5) 最终比较方法
            let moveIndex = map[newStartVnode.key];
            if(!moveIndex){
                parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
            }else{
                let moveVnode = oldChildren[moveIndex];
                oldChildren[moveIndex] = undefined;
                patch(moveVnode, newStartVnode);
                parent.insertBefore(moveVnode.el, oldStartVnode.el);
            }
            newStartVnode = newChildren[++newStartIndex];
        }
        

    }
    if(newStartIndex <= newEndIndex){               // 新的比老的多,插入新节点
        for(let i=newStartIndex; i<=newEndIndex; i++){      
            // 向前插入 向后插入

            let nextEle = newChildren[newEndIndex+1] == null ? null : newChildren[newEndIndex+1].el;
            // 如果newEndIndex的下一个元素是空的话,那就是尾部插入,反之,则是头部插入
            parent.insertBefore(createElm(newChildren[i]), nextEle);
        }
    }
    if(oldStartIndex <= oldEndIndex){               // 删除老的后面多余的节点
        for(let i=oldStartIndex; i<=oldEndIndex; i++){
            let child = oldChildren[i];
            if(child != undefined){
                parent.removeChild(child.el);
            }
        }
    }
}

export function isSameVnode(oldVnode, newVnode){
    return (oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key);
}