Vue之diff算法,看完你就算不会,也知道虚拟节点和diff算法是什么了!

1,550 阅读2分钟

前言

学过Vue或React的肯定都知道虚拟dom是什么,vue就是通过虚拟dom与diff算法来对真实的Dom进行最小量更新,说白了就是比对虚拟dom,一样的不更新,不一样的更新dom。diff算法就是做了这样一件事。

那么了解diff算法的,肯定知道一些关键词,类似于VirtualNode,h,patch 之类的,比如别人考察你时,你也会通过几个关键词模棱两可的过关,但是我们还是要真正的会的。将真实的dom变为虚拟节点这个过程,这里不是我们要研究的,我们只是来研究虚拟节点的diff。

废话也不多说了,下面直接开始了:

封装基本功能函数

首先虚拟dom,我们只写一个简易版本的。虚拟dom就是把真实dom转为js对象。

Vnode虚拟节点

// 创建虚拟dom Vnode
/**
 * 
 * @param {*} sel 虚拟节点名称 tagName
 * @param {*} data  虚拟节点上的属性  如:key props...
 * @param {*} children  虚拟节点的子节点
 * @param {*} text 虚拟节点的文本 innerText
 * @param {*} elm  虚拟节点的真正dom 
 * @returns 
 */
export default function(sel,data,children,text,elm){
    const key = data.key;
    return {
        sel,data,children,text,key,elm
    }
}

真实dom:

 <div>
   <p>这是一个虚拟节点</p>
 </div>

对应虚拟节点:

image.png

虚拟节点是由AST(抽象语法树)得到的,后面再继续更新AST的原理,本章只管阐述虚拟DOM,以及DOM-Diff

h函数生成虚拟dom

import Vnode from "./Vnode.js";
// 将tokens转成虚拟dom
//我们来写一个简易版的diff,所以就不考虑特殊的情况,这个是功能比较弱的版本,但是可以体现diff算法的
/**
 * 三个参数,第一个是tagName, 第二个是节点上的属性  如:<div  key='myKey'  ></div>,第三个是节点内的文本或子节点 
 * 
 * 1.h('div',{},'苹果')
 * 2.h('div',{},[
 *   h('div',{},'西瓜')
 * ])
 * 
 */

export default function h(sel, data, kind) {
    if (arguments.length != 3) alert('参数传递错误')
    if (typeof kind == 'string' || typeof kind == 'number') {
        //kind是文本
        return Vnode(sel, data, undefined, kind, undefined);
    } else if (Array.isArray(kind)) {
        //kind是子节点chuildren []
        let children = [];
        for (let i = 0; i < kind.length; i++) {
            children.push(kind[i])
        };
        return Vnode(sel, data, children, undefined, undefined);
    } else if (Object.prototype.toString.call(kind) == '[object Object]' && kind.hasOwnproperty('sel')) {
        // h()返回的是一个对象 所以用此判断
        let children = [kind]
        return Vnode(sel, data, children, undefined, undefined);
    }
}

/**
 * Array.isArray(kind) 这个地方很巧妙,但是也很绕,当kind为数组时,我们去遍历这个数组,
 * 里面得 h(...)函数 ,函数是自行调用的,所以得到的其实就是vnode对象,push到children []中 
 * 当子节点 h(...)中也有children, 也会走到这里,得到一个子节点的子节点vnode对象(递归),直至没有children,返回的是文本节点为止
 */


我们来测试一下:

let v1 = h('div', { key: 'v1' }, [
    h('p', { key: 'p' }, '这是一个虚拟节点')
]);
console.dir(v1) 

image.png

没有问题,有了虚拟dom了,肯定得有将虚拟dom转换为真实dom的方法:

createElement将虚拟dom转换为真实dom

export default function createElement(Vnode) {
    let tagName = document.createElement(Vnode.sel.toLowerCase());
    //节点添加属性
    for(let key in Vnode.data){
        tagName.setAttribute(key,Vnode.data[key])
    }
    
    if (Vnode.text != undefined && (Vnode.children == undefined || Vnode.children.length == 0)) {
        //没有子节点  文本节点
        tagName.innerText = Vnode.text;
    } else if ( Array.isArray(Vnode.children) && Vnode.children.length > 0) {
        //有字节点
        for(let i=0;i<Vnode.children.length;i++){
           //子节点需要递归,因为子节点是嵌套的
           let ch = Vnode.children[i];
           tagName.appendChild(createElement(ch));  //递归添加
        }
    }
    Vnode.elm = tagName;
    return Vnode.elm;   //elm  虚拟节点对应的真实dom
}

ok,我们来测试一下吧。

image.png

上面为虚拟节点,下面为真实的dom。

有了上面三个函数,其实下面根据这三个函数,照着流程图判断就行了。

开始

流程

我们先列出diff算法的整个流程图,也就是patch(),大致知道是经过怎么样的流程就行了。

b5ab55a9ca9ad417b134c9ae5ca3be6.png

这就是patch两个虚拟节点的整个流程。

patch函数 开启diff算法

import createElement from "./createElement.js";
import Vnode from "./Vnode.js";


/**
 * diff算法开始
 * 
 * diff算法始终是比对新旧虚拟dom,对旧dom做patch
 */

export default function patch(oldVnode, newVnode) {
    /**假如oldVnode不是虚拟dom  手动去创建一个子节点*/
    //如第一次初始化的时候
    if (oldVnode.sel == undefined) {
        oldVnode = Vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
    }
    //判断新旧虚拟节点sel,key是否相等
    if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key ){
        //相等,进一步去判断
       
    }else{
        //不相等,暴力删除旧,替换新
        let newElement = createElement(newVnode);
        if(oldVnode.elm){
            oldVnode.elm.parentNode.insertBefore(newElement,oldVnode.elm);
        }
        oldVnode.elm.parentNode.removeChild(oldVnode.elm);
    }
}

写代码一定对着流程图看,提问一下到了流程图哪一步了呢?

image.png

下一步也是判断,为了简介与维护,我们再抽出一个函数:

patchVnode


import createElement from "./createElement.js";
import updateChildren from './updateChildren.js'

export default function patchVnode(oldVnode,newVnode){
   //判断newVnode是否为text节点
   if(newVnode.text != '' && (newVnode.children == undefined || newVnode.children.length == 0)){
        //newVnode是text节点
        oldVnode.elm.innerText = newVnode.text;
   }else{
      //newVnode不是text节点  有children []
      //判断oldVnode是不是文本节点 
      let ch = newVnode.children;
      if(oldVnode.text != '' && (oldVnode.children == undefined || oldVnode.children.length == 0)){
          oldVnode.elm.innerText = ''
          for(let i=0;i<ch.length;i++){
            oldVnode.elm.parentNode.appendChild(createElement(ch[i])); 
          }
      }else{
          //这块是最小量更新判断的核心算法
          //新旧虚拟节点都有children
          updateChildren(oldVnode,newVnode)
      }
   }
}


可以看出来,我们已经到这了

image.png

updateChildren

最下面那一块,尤为复杂,可以算diff的核心与核心了,所以我们又抽出了一个updateChildren函数了,也就是当新旧虚拟节点都有 children时,我们去比对他们的children时的具体方法。

四种比对,看是否命中,命中了做相应的操作:

未命名文件 (3).png

未命名文件 (4).png

先定义新旧虚拟节点的指针:


import patchVnode from './patchVnode.js';
import createElement from './createElement.js';

// 判断是否是同一个虚拟节点
function sameVnode(a, b) {
    return a.sel == b.sel && a.key == b.key;
};

export default function updateChildren(oldVnode,newVnode) {
    //移动是指在真实dom中移动  虚拟dom数组是不变的  新旧都不变  
    //旧虚拟节的elm,也是旧虚拟节点children 的父节点
    let parentElm = oldVnode.elm;
    //新虚拟节点的子节点
    let newCh = newVnode.children;
    //旧虚拟节点的子节点
    let oldCh = oldVnode.children;
    //定义新旧虚拟节点的头部指针
    let newS = 0;
    let newSNode = newCh[newS];
    let oldS = 0;
    let oldSNode = oldCh[oldS];
    //定义新旧虚拟节点的尾部指针
    let newE = newCh.length - 1;
    let newENode= newCh[newE]
    let oldE = oldCh.length - 1;
    let oldENode = oldCh[oldE];

    console.log(newSNode,oldSNode,newENode,oldENode)

};



单元测试一下:

let v1 = h('div', { key: 'key' }, [
    h('div',{key:'div1'},'999'),
    h('div',{key:'div2'},'888'),
    h('div',{key:'div3'},'777'),
]);

let v2 = h('div', { key: 'key' }, [
    h('div',{key:'div1'},'999'),
    h('div',{key:'div2'},'888'),
    h('div',{key:'div4'},'666'),
]);


btn.onclick = function () {
    patch(v1, v2)
}

image.png

没有问题,新旧虚拟节点的首尾指针正确!下面我们来将updateChildren写出来:

这里其实也不难,就是需要判断的地方有点多,就显得代码量看起来很大,其实都是一些基础的判断,

下面需要进行三个步骤:

1.4种方式命中判断做对应操作。

2.4种方式都没命中,判断新虚拟节点是否在旧虚拟节点中,做对应操作。

3.遍历结束,新或旧虚拟节点没遍历完,做对应操作。

关于指针的移动,图上以标明,下面注释写的也很清楚啦!总结一下就是头部指针每次都需要下移,尾部指针每次都需要上移,哪个命中,就要移哪个

四种都没有命中 看看新虚拟节点存不存在旧虚拟节点中,存在:移动到旧前,不存在:插入都旧前(判断是否存:在是将oldCh的key 存到一个对象中,value值为它在oldCh中的索引 {key:index},这样就能知道是哪个子虚拟节点了)。

遍历结束,头尾中间的就是要新增或删除的项(新虚拟节点没有循环完毕 有新增;旧虚拟节点没有循环完毕 要删除),下面都注释好了,这里再强调一下,移动是指移动真实dom,而不是旧虚拟节点,这个地方不知道的话很让人困惑。

2.png

import patchVnode from './patchVnode.js';
import createElement from './createElement.js';

// 判断是否是同一个虚拟节点
function sameVnode(a, b) {
    return a.sel == b.sel && a.key == b.key;
};

export default function updateChildren(oldVnode, newVnode) {
    //移动是指在真实dom中移动  虚拟dom数组是不变的  新旧都不变  
    //旧虚拟节的elm,也是旧虚拟节点children 的父节点
    let parentElm = oldVnode.elm;
    //新虚拟节点的子节点
    let newCh = newVnode.children;
    //旧虚拟节点的子节点
    let oldCh = oldVnode.children;
    //定义新旧虚拟节点的头部指针
    let newS = 0;
    let newSNode = newCh[newS];
    let oldS = 0;
    let oldSNode = oldCh[oldS];
    //定义新旧虚拟节点的尾部指针
    let newE = newCh.length - 1;
    let newENode = newCh[newE]
    let oldE = oldCh.length - 1;
    let oldENode = oldCh[oldE];

    // 开始比对 ,1 2 3 4 四种情况是新旧虚拟节点都存在的,只是顺序不一样
    //是前面判断两个一样的虚拟节点 也就是当新旧虚拟节点sel,key相等时
    //递归patchVnode(oldVnode,newVnode)    这里为什么要递归patchVnode呢?是因为子节点可能本身也有子节点,需要去diff一下,
    //并且patchVnode会把新旧虚拟节点变为真正的节点,插到其父节点上的!
    //1 2是不需要移动节点,  只要patchVnode变成真正的dom 插入到其父节点上
    //3 4 需要移动节点,也需要patchVnode变成真正的dom 插入到其父节点上
    //移动是真实dom移动,旧虚拟节点oldCh 不变,只是其前后指针变
    while (newS <= newE && oldS <= oldE) {
        // 后面会把部分节点置为null,直接跳过
        if (newSNode == null) {
            newSNode = newCh[++newS];
        } else if (newENode == null) {
            newENode = newCh[--newE];
        } else if (oldSNode == null) {
            oldSNode = oldCh[++oldS];
        } else if (oldENode == null) {
            oldENode = oldCh[--oldE];
        }
        else if (sameVnode(newSNode, oldSNode)) {
            //1.新前与旧前一样
            console.log('1.新前与旧前一样')
            //继续 patchVnode这两个虚拟节点
            patchVnode(oldSNode, newSNode);
            //新前与旧前指针下移  
            newSNode = newCh[++newS];
            oldSNode = oldCh[++oldS];
        } else if (sameVnode(newENode, oldENode)) {
            //2.新后与旧后一样
            console.log('2.新后与旧后一样')
            patchVnode(oldENode, newENode);
            //新后与旧后指针上移
            newENode = newCh[--newE];
            oldENode = oldCh[--oldE];
        } else if (sameVnode(newSNode, oldENode)) {
            //3.新前与旧后一样
            console.log('3.新前与旧后一样')
            patchVnode(oldENode, newSNode);
            //移动,要将旧后dom插入到旧前dom上(改变的是真实dom,旧children其实是没有变的,它是用了对比的),新前指针下移,旧后指针上移
            parentElm.insertBefore(oldENode.elm, oldSNode.elm)
            newSNode = newCh[++newS];
            oldENode = oldCh[--oldE];
        } else if (sameVnode(newENode, oldSNode)) {
            //4.新后与旧前一样  
            console.log('4.新后与旧前一样')
            patchVnode(oldSNode, newENode);
            //移动,将旧前dom插入到旧后,新后指针上移,旧前指针下移
            parentElm.insertBefore(oldSNode.elm, oldENode.elm)
            newENode = newCh[--newE];
            oldSNode = oldCh[++oldS];
        } else {
            //上面四种没有命中  看看新虚拟节点存不存在旧虚拟节点中
            //这里将oldCh的key 存在一个对象中,value值为它在oldCh中的索引  {key:index}
            let keyMap = {};
            for (let i = 0; i < oldCh.length; i++) {
                keyMap[oldCh[i].key] = i;
            };
            //看看新虚拟节点的key在不在keyMap中有
            const keyInOldCh = keyMap[newSNode.key];
            if (keyInOldCh) {
                //存在  oldToMove 与newSNode一样   移动到旧前
                let oldToMove = oldCh[keyInOldCh];
                patchVnode(oldToMove, newSNode);
                parentElm.insertBefore(oldToMove.elm, oldSNode.elm);
                //这里解释一下为什么,因为这个节点已经比对过了,后面不需要再去比对了
                oldCh[keyInOldCh] = null;
            } else {
                //不存在  是新增的节点 直接插入
                parentElm.insertBefore(createElement(newSNode), oldSNode.elm)
            }
            //只移动新的开始指针,因为我们刚用它去比对oldCh的
            newSNode = newCh[++newS];
        }
    }

    //判断有没有剩余  删除是删除真实dom上的节点  新增是新增在真实的dom上
    if (newS <= newE) {
        //新虚拟节点没有循环完毕  有新增
        for (let i = newS; i <= newE; i++) {
            //将虚拟节点变为真实dom,添加到节点上
            parentElm.appendChild(createElement(newCh[i]));
        }
    } else if (oldS <= oldE) {
        //旧虚拟节点没有循环完毕  要删除
        for (let i = oldS; i <= oldE; i++) {
            //删除子节点
            parentElm.removeChild(oldCh[i].elm);
        }
    }
};

测试

let contain = document.getElementById('contain')
let btn = document.getElementById('btn')

let v1 = h('div', { key: 'key' }, [
    h('div',{key:'999'},'999'),
    h('div',{key:'888'},'888'),
    h('div',{key:'777'},'777'),
]);

patch(contain, v1)



let v2 = h('div', { key: 'key' }, [
    h('div',{key:'111'},'111'),
    h('div',{key:'999'},'999'),
    h('div',{key:'888'},'888'),
    h('div',{key:'666'},'666'),
    h('div',{key:'777'},'777'),
    h('div',{key:'333'},'333'),
    h('div',{key:'222'},'222')
]);


btn.onclick = function () {
    patch(v1, v2)
}

image.png

点击变成

image.png

我们在浏览器中手动更改下 999 888 777 ==> 虚拟节点999 虚拟节点888 虚拟节点77

image.png

点击按钮:

image.png

可以看到的是:节点位置改变了,但是并没有重写渲染一遍,只是移动了一下位置,并且把不存在的节点删除了,新增的节点也添加进去了,这就是diff算法,最小量更新!源代码会放在github上!