概念

diff算法的产生主要是直接操作dom很浪费性能,dom-diff可以在每次渲染的时候进行比对,比如有一个列表里面有三个元素,过一会又产生了一条新的数据,如果可以复用的元素就直接复用而不去操作相同的元素,diff算法的特点是平级比较
image.png

dom-diff操作的是虚拟节点,虚拟节点的属性很少(tag,data,children,key)
image.png

真实dom,每次操作真实dom就会重新创建一下非常多的属性,大大的消耗了性能
image.png

diff算法的实现

上面我们也了解了diff算法就是使用旧的虚拟节点和新的虚拟节点进行比较,那么我们看一下其中的比较规则吧。
那么我们现在使用vue源码中的三个方法生成两个虚拟节点。

//compileToFunction 解析html生成render函数
import {compileToFunction} from './compiler/index.js';
//patch 虚拟节点对比
//createElm 创建真是dom
import { patch,createElm } from './vdom/patch';
// 1.创建第一个虚拟节点
let vm1 = new Vue({data:{name:'meng'}});
let render1 = compileToFunction('<div>{{name}}</div>')
let oldVnode = render1.call(vm1)
// 2.创建第二个虚拟节点
let vm2 = new Vue({data:{name:'li'}});
let render2 = compileToFunction('<p>{{name}}</p>');
let newVnode = render2.call(vm2);
// 3.通过第一个虚拟节点做首次渲染
let el = createElm(oldVnode)
document.body.appendChild(el);
// 4.调用patch方法进行对比操作
patch(oldVnode,newVnode);
生成之后的虚拟节点,如下图
console.log(oldVnode,newVnode)

image.png

1.标签对比

如果标签不一样直接让新的替换老的

 if(oldVnode.tag !== vnode.tag){
    oldVnode.el.parentNode.replaceChild(createElm(vnode),oldVnode.el)
 }

2.标签内的文字对比

// 如果标签不存在那就是文本 如果文本不一致那就让新的替换老的内容 同时复用老的标签 只是内容替换 节省了创建标签的性能
if(!oldVnode.tag){
    if(oldVnode.text !== vnode.text){
        oldVnode.el.textContent = vnode.text;
    }
}

3.对比属性

// 复用标签,并且更新属性
let el = vnode.el = oldVnode.el;
updateProperties(vnode,oldVnode.data);
function updateProperties(vnode,oldProps={}) {
    let newProps = vnode.data || {};
    let el = vnode.el;
    // 比对样式
    let newStyle = newProps.style || {};
    let oldStyle = oldProps.style || {};
    //如果新的属性不存在 直接将旧清空
    for(let key in oldStyle){
        if(!newStyle[key]){
            el.style[key] = ''
        }
    }
    // 删除多余属性
    for(let key in oldProps){
        if(!newProps[key]){
            el.removeAttribute(key);
        }
    }
    for (let key in newProps) {
        if (key === 'style') {
            for (let styleName in newProps.style) {
                el.style[styleName] = newProps.style[styleName];
            }
        } else if (key === 'class') {
            el.className = newProps.class;
        } else {
            el.setAttribute(key, newProps[key]);
        }
    }
}

4.子元素的对比

// 比较孩子节点
let oldChildren = oldVnode.children || [];
let newChildren = vnode.children || [];
// 新老都有需要比对儿子
if(oldChildren.length > 0 && newChildren.length > 0){
    
// 老的有儿子新的没有清空即可
}else if(oldChildren.length > 0 ){
    el.innerHTML = '';
// 新的有儿子
}else if(newChildren.length > 0){
    for(let i = 0 ; i < newChildren.length ;i++){
        let child = newChildren[i];
        el.appendChild(createElm(child));
    }
}

新老都有需要比对儿子,这一步比较复杂一些 我们拎出来看一看
diff中做了几步优化

1 头和头对比
2 尾和尾对比
3 头移尾
4 尾移头
5 乱序对比

下面我们看看依次看一下diff中的几种对比孩子元素的优化策略
每次我们进行数据绑定的时候都会绑定一个key

function isSameVnode(oldVnode,newVnode){
    // 如果两个人的标签和key 一样我认为是同一个节点 虚拟节点一样我就可以复用真实节点了
    return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)
}
1 头和头对比

指定双指针依次从头开始对比
image.png

代码实现

    let oldStartIndex = 0;//旧的指针起始位置
    let oldStartVnode = oldChildren[0];//旧的指针起始元素
    let oldEndIndex = oldChildren.length - 1;//旧的指针截止位置
    let oldEndVnode = oldChildren[oldEndIndex];//旧的指针截止元素

    let newStartIndex = 0;//新的指针起始位置
    let newStartVnode = newChildren[0];//新的指针起始元素
    let newEndIndex = newChildren.length - 1;//新的指针截止位置
    let newEndVnode = newChildren[newEndIndex];//新的指针截止元素
//当新的和老的虚拟节点指针没有重合之前
 while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
        // 如果标签和key相同
        if(isSameVnode(oldStartVnode,newStartVnode)){
        //进行上面1,2,3,4对比
            patch(oldStartVnode,newStartVnode);
         //对比完成之后指针向后依次对比,对比之后的结果是直接将内容为`D`的替换为`E`
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVnode = newChildren[++newStartIndex];
        }
    }
2.尾和尾对比

理解了上面的对比方式,这个对比方式只是指针对换了一下,当发现新的头和旧的头不一致时,diff算法有改变了一下对比策略,就是尾与尾对比,头对比是指针从开始指到结尾,尾对比是从结尾指到开始
image.png

代码实现,接着上面的while循环

 while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    else if(isSameVnode(oldEndVnode,newEndVnode)){ 
            patch(oldEndVnode,newEndVnode); // 比较孩子 
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVnode = newChildren[--newEndIndex];
        }
   }

上面两种会出现的情况
头与头对比,尾与尾对比可能新的元素比旧的元素多,我们需要处理一下,那么可能问了,那老的元素比新的元素多不应该也处理一下么,这种情况我们再patch方法中已经处理过了
头与头对比情况
image.png

尾与尾的对比情况
image.png

代码处理
当while循环完成之后
//如果跳出了while循环那就是老的开始指针和老的结束指针重合了,那么这种情况下新的开始指针和新的结束指针并没有重合 就会走到下面的逻辑里

if(newStartIndex <= newEndIndex){
//循环新的剩下的数据
        for(let i = newStartIndex ; i<=newEndIndex ;i++){
        创建新的元素 依次将剩下的插入到对应位置
            let ele = newChildren[newEndIndex+1] == null? null:newChildren[newEndIndex+1].el;
            parent.insertBefore(createElm(newChildren[i]),ele);
        }
    }
3.头移尾

如果oldVnode和vNode都不符合上面两种情况
image.png
那么diff又进行了头尾相比,如果新的尾和旧的头相同,那么旧的头进行位移。
image.png
代码实现

// 头移动到尾部
else if(isSameVnode(oldStartVnode,newEndVnode)){
    patch(oldStartVnode,newEndVnode);
    //将老的头插入到老的尾之后
    parent.insertBefore(oldStartVnode.el,oldEndVnode.el.nextSibling);
    oldStartVnode = oldChildren[++oldStartIndex];
    newEndVnode = newChildren[--newEndIndex]
}
4.尾移头

如果oldVnode和vNode都不符合上面两种情况
image.png
那么diff又进行了头尾相比,如果新的头和旧的尾相同,那么将旧的尾进行位移。
对比
image.png
调换位置之后
image.png

代码实现

//如果旧的尾和新的头相等
else if(isSameVnode(oldEndVnode,newStartVnode)){
    patch(oldEndVnode,newStartVnode);
    //将旧的尾插入到新的头前面
    parent.insertBefore(oldEndVnode.el,oldStartVnode.el);
    oldEndVnode = oldChildren[--oldEndIndex];
    newStartVnode = newChildren[++newStartIndex]
}
5.乱序对比

如果新的和老的都不符合上述的条件,那么就属于乱序了,diff算法也进行了相对的操作。
先对老的虚拟dom创建一个映射表,如下图,创建出的映射表如下:
map = {C:0,A:1,B:2,D:3}
image.png

第一步:

老的指针位置      新的指针位置
     C              Z
map.Z不存在  插入到老的开始指针前面 
老的指针还是C 新的指针向前走一步Y
     

image.png

第二步

老的指针位置      新的指针位置
     C              Y
map.Y不存在 插入到老的开始指针前面
新的指针向前走一步还是C 老的指针还是C

image.png

第三步

老的指针位置      新的指针位置
     C              C
map.C存在 C这个节点不需要动 老的开始指针向前移一步
新的指针向前走一步N 老的指针向前走一步A(头和头对比向前走一步)

image.png

第四步

老的指针位置      新的指针位置
     A              N
map.N不存在 插入到老的开始指针前面 
新的指针向前走一步M 老的指针A

image.png

第四步

老的指针位置      新的指针位置
     A              M
map.M不存在 插入到老的开始指针前面 
新的指针向前走一步H 老的指针A

image.png

第四步

老的指针位置      新的指针位置
     A              H
map.H不存在 插入到老的开始指针前面 
老的指针A 新的已经在上一次重合
终止while循环

image.png

第五步 将老的开始指针 --- 结束指针的内容删除,最终形成
Z,Y,C,N,M,H

代码实现
对所有孩子进行编号

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

用新的元素去老的中进行查找,如果找到则移动,找不到则直接插入

let moveIndex = map[newStartVnode.key];
if (moveIndex == undefined) { // 老的中没有将新元素插入
    parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
} else { // 有的话做移动操作
    let moveVnode = oldChildren[moveIndex]; 
    oldChildren[moveIndex] = undefined;
    parent.insertBefore(moveVnode.el, oldStartVnode.el);
    patch(moveVnode, newStartVnode);
}
newStartVnode = newChildren[++newStartIndex]

如果有剩余则直接删除

if(oldStartIndex <= oldEndIndex){
    for(let i = oldStartIndex; i<=oldEndIndex;i++){
        let child = oldChildren[i];
        if(child != undefined){
            parent.removeChild(child.el)
        }
    }
}

更新操作

Vue.prototype._update = function (vnode) {
    const vm  = this;
    const prevVnode = vm._vnode; // 保留上一次的vnode
    vm._vnode = vnode;
    //如果是第一次渲染
    if(!prevVnode){
        vm.$el = patch(vm.$el,vnode); // 需要用虚拟节点创建出真实节点 替换掉 真实的$el
        // 我要通过虚拟节点 渲染出真实的dom 
    }else{
    //如果上次是更新操作 需要进行diff对比
        vm.$el = patch(prevVnode,vnode); // 更新时做diff操作
    }
}

新的虚拟dom和旧的虚拟dom对比,操作的是真实dom,所以当老的虚拟dom不存在时我们将新的dom插进去 不会改变原来的虚拟dom 所以我们并没有在乱序对比中操作老的虚拟dom的指针


mengyuhang4879
13 声望7 粉丝