虚拟Dom与diff算法

目录

1、文章结构

1.1snabbdom简介

1.2项目所涉及的模块及各模块功能

2、手写一个简易版的snabbdom

2.1、vnode函数

vnode函数代码 

2.2、h函数

h函数的代码 

第一种情况:

第二种情况;

第三种情况:

2.3、patch函数

patch函数思路图

​编辑

 patch函数代码

patch函数讲解

2.4、createElement函数

createElement函数代码

 createElement函数讲解

2.5、patchVnode函数

patchVnode函数的代码

 patchVnode函数讲解

​编辑 2.6、updateChildren函数

updateChilldren函数代码

 updateChildren函数讲解


     本文章是在学习b站 尚硅谷 的视频后总结的,主要是讲解了snabbdom的基本原理与diff算法的实现原理。

1、文章结构

1.1snabbdom简介

 snabbdom是著名的虚拟Dom的库,也是diff算法的鼻祖

1.2项目所涉及的模块及各模块功能

在本文章中会手写一个简易版的snabbdom,下面是所涉及的模块以及各部分的功能

2、手写一个简易版的snabbdom

2.1、vnode函数

功能:生成虚拟Dom对象,返回的对象中主要有5个属性,分别是:

sel                     //选择器

data                  //虚拟dom的属性

children            //虚拟dom的子元素

text                   //虚拟dom的文本

elm                   //虚拟dom对应的真实的dom节点

key                   //虚拟dom的唯一标识

vnode函数代码 

/**
 *vnode的主要功能是返回传入参数的值
 *
 * @export
 * @param {string} sel   节点类型
 * @param {object} data   节点属性
 * @param {Array} children   节点的子节点
 * @param {String|number} text   节点的文本内容
 * @param {object} elm    判断节点有没有上树
 * @return {*} 
 */
export default function(sel,data,children,text,elm){
    let key = data.key
    return {
        sel,
        data,
        children,
        text,
        elm,
        key
    }
}

2.2、h函数

功能:h函数主要是根据传入参数的不同进行if判断,调用vnode函数,产生不同的虚拟节点对象

h函数的代码 

import vnode from "./vnode";

// 低配版的h函数,重载功能较弱
/**
 *调用的时候的参数形态
 h('div',{},'文字')
 h('div',{},[])
 h('div',{},h())
 * @export
 * @param {*} sel
 * @param {*} data
 * @param {*} c
 */
export default function(sel,data,c){
    // 首先进行传入参数的个数,个数不符合,给出错误信息
    if(arguments.length!=3){
        throw new Error('低配版的h函数,请输入三个参数!')
    }
    // 处理第一种情况,也就是没有子节点,节点只有文本内容
    if(typeof c=='string'||typeof c=='number'){
        return vnode(sel,data,undefined,c,undefined)
    }
    // 判断传入的参数是数组,并且数组的子元素必须是h函数的返回值
    if(Array.isArray(c)){
        let children=[]
         for(let i=0;i<c.length;i++){
            if(!(typeof c[i]=='object'&&c[i].hasOwnProperty('sel'))){
                throw new Error('传入的不是h函数!')
            }
            // 如果是h函数,就收集所有的h函数,作为节点的子元素
            children.push(c[i])
         }
         return vnode(sel,data,children,undefined,undefined)
    }
    // 第三种情况,c是h函数
    else if(typeof c=='object'&&c.hasOwnProperty('sel')){
        let children=[c]
        return vnode(sel,data,children,undefined,undefined)

    }else{
        throw new Error('传入的第三个参数类型不对')
    }

}

 由于是简易版的snabbdom(不考虑又有子元素又有文本的情况,两者只能有其中的一个),这里只考虑三种参数传入的情况:

h函数调用时参数形态:

 h('div',{},'文字')

 h('div',{},[ ])

 h('div',{},h())

第一种情况:

如果第三个参数时文字, 说明虚拟Dom只有文本没有children,因此返回的是

return vnode(sel,data,undefined,c,undefined)

所对应的虚拟Dom对象是:

{

     sel:sel,

     data:data,

     children:undefined,

     text:c,

     elm:undefined

第二种情况;

第三个参数传入的是数组,说明没有text,只有children,因此返回

return vnode(sel,data,children,undefined,undefined) 

所对应的虚拟dom对象是:

{

     sel:sel,

     data:data,

     children:children

     text:undefined

     elm:undefined

第三种情况:

第三个参数传入的是一个h函数,但是h函数的返回值是vnode函数,而vnode函数的返回值是一个虚拟Dom对象,因此可以把第三种情况看作是虚拟Dom节点只有一个子元素的情况,因此返回的也是与第二种情况相同的值

return vnode(sel,data,children,undefined,undefined) 

 所对应的虚拟dom对象是:

{

     sel:sel,

     data:data,

     children:children

     text:undefined

     elm:undefined

2.3、patch函数

功能:将虚拟Dom上树(上树:就是将虚拟Dom转变为真实的Dom节点,并插入到dom树中)

patch函数思路图 

 patch函数代码

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

export default function(oldVnode,newVnode){
    // 判断传入的第一个参数,是真实节点还是虚拟节点
    if(oldVnode.sel==''||oldVnode.sel==undefined){
        // 如果是真是节点,则封装成为虚拟节点
        oldVnode=vnode(oldVnode.tagName.toLowerCase(),{},[],undefined,oldVnode)
    }
    // 判断新老节点是不是同一个节点
    if(oldVnode.key===newVnode.key&&oldVnode.sel===newVnode.sel){
        console.log('是同一个节点');
        patchVnode(oldVnode,newVnode)

    }else{
        console.log('不是同一个节点,删除老节点,重建新节点');
        let newVnodeElm=createElement(newVnode)
        if(newVnodeElm&&oldVnode.elm.parentNode){
            // 插入到老节点之前
            oldVnode.elm.parentNode.insertBefore(newVnodeElm,oldVnode.elm)                                                                                       
        }
        oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
}

patch函数讲解

当patch函数被调用时,首先会判断oldVnode是不是虚拟节点,如果不是虚拟节点,就转化为虚拟节点,前面已经说过,vnode函数会返回一个虚拟Dom节点,因此当判断oldVnode不是虚拟节点,会调用vnode函数将oldVnode转化为虚拟Dom。

其次,当oldVnode确定为虚拟节点时,就会判断更新前后的新老节点是不是同一个节点,那又如何判断是不是同一个节点呢?

方法:选择器(sel)相同与key相同 

①如果是同一个节点,则进行精细化比较

②如果不是同一个节点,则去除旧的节点,插入新的节点

最后,根据上面两种情况,对其进行不同的处理。

2.4、createElement函数

功能 :在patch函数中,回调用这个函数,主要用于”更新前后不是同一个节点,则去除旧的节点,插入新的节点“的情况,createElement函数主要将虚拟Dom节点变为真实Dom节点

createElement函数代码

/**
 *
 *真正创建节点,将vnode创建为Dom,插入到pivot这个元素之前
 * @export
 * @param {*} vnode
 * @param {*} pivot
 */
export default function createElement(vnode){
    // console.log('目的是将虚拟节点',vnode);
    // 创建一个DOM节点,这个节点还是孤儿节点
    const domNode=document.createElement(vnode.sel)
    // 第一种情况:只有文本内容,没有子元素
    if(vnode.text!=''&&(vnode.children==undefined||vnode.children.length==0)){
        domNode.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]
            // console.log(ch);
            // 为每一个子元素创建真正的DOM元素
            let chDOM=createElement(ch)
            // console.log(chDOM);
            // 添加进上一层的子元素
            domNode.appendChild(chDOM)
        }
    }
     // 补充elm属性
     vnode.elm=domNode
    return vnode.elm
}

 createElement函数讲解

前面说过了,这是一个简易版的snabbdom,因此只有两种虚拟Dom的情况,一种是text有值children没值,一种是children有值,text没值

①当text有值时,只需要将text的值赋值给新创建的真实的dom(domNode)的innerText就好了

if(vnode.text!=''&&(vnode.children==undefined||vnode.children.length==0)){

        domNode.innerText=vnode.text;

②当children有值时,需要遍历children中的每一个元素,将其转变为真实的Dom节点,再利用appendChild()函数插入的domNode中

else if(Array.isArray(vnode.children)&&vnode.children.length>0){

        for(let i=0;i<vnode.children.length;i++){

            // 获取每一个子元素

            let ch=vnode.children[i]

            // console.log(ch);

            // 为每一个子元素创建真正的DOM元素

            let chDOM=createElement(ch)

            // console.log(chDOM);

            // 添加进上一层的子元素

            domNode.appendChild(chDOM)

        }

    }

最后  ,将新创建的真实Dom(domNode)赋值给newVnode的elm属性(前面说过,elm属性的值是真实的Dom,前面的elm都没有赋值,所以都是undefined,在这里才开始赋值,将自身新创建的真实Dom赋值给它)

2.5、patchVnode函数

功能:在patch函数,会调用这个函数,主要用来处理更新前后是同一个节点的情况

patchVnode函数的代码

import createElement from "./createElement"
import updateChildren from "./updateChildren"

/**
 *虚拟节点比较,主要有三种情况,精细化比较,父节点不同的情况
 *
 * @export
 * @param {*} oldVnode
 * @param {*} newVnode
 */
export default function patchVnode(oldVnode,newVnode){
    // 当新老节点相同时(指的是内存中的储存完全相同)
    if(oldVnode === newVnode) return
    // 当oldVnode有text或者children,newVnode也有text的情况
    if(newVnode.text!=undefined&&(newVnode.children==undefined||newVnode.children.length==0)){
        
        if(newVnode.text!=oldVnode.text){
            oldVnode.elm.innerText=newVnode.text
        }else{
            return
        }
    }else{
        if(oldVnode.children!=undefined&&oldVnode.children.length>0){
             
            // oldVnode有children,新的也有children
            updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
        }else{
            // 老的没有,新的有children
            
            oldVnode.elm.innerText=''
            for(let i=0;i<newVnode.children.length;i++){
                let dom=createElement(newVnode.children[i])
                oldVnode.elm.appendChild(dom)
            }
        }
    }
}

 patchVnode函数讲解

patchVnode函数主要是在新老节点的key与sel属性都相同的情况下进行

主要处理四种情况:

1、当新老节点在内存中完全相同,则什么都不干,直接返回

2、当newVnode有text时,无论oldVnode有text或者children,newVnode中的text都会覆盖oldVnode中的text或者children,所以可以当成一种情况

3、当newVnode有children,oldVnode也有children的时候,这时候会用一个updateChildren()方法进行更新(下面会讲解到这个函数,这个函数是diff算法的核心)

4、当newVnode有children,oldVnode没有children的时候,也当作一种情况处理

 2.6、updateChildren函数

 功能:updateChildren函数是对newVnode有children,oldVnode也有children情况的处理(在是同一个节点的前提下)

updateChilldren函数代码

import createElement from "./createElement";
import patchVnode from "./patchVnode";

// 判断是否是同一个节点
function checkSameVnode(oldVnode,newVnode){
    return oldVnode.sel==newVnode.sel&&oldVnode.key==newVnode.key
}

/**
 *
 *
 * @export
 * @param {*} parentElm
 * @param {*} oldCh
 * @param {*} newCh
 */
export default function updateChildren(parentElm,oldCh,newCh){
    console.log('我是updateChildren');
    console.log(oldCh,newCh);

    // 旧前
    let oldStartIdx=0
    // 新前
    let newStartIdx=0
    // 旧后
    let oldEndIdx=oldCh.length-1
    // 新后
    let newEndIdx=newCh.length-1
    // 旧前节点
    let oldStartVnode=oldCh[0]
    // 旧后节点
    let oldEndVnode=oldCh[oldEndIdx]
    // 新前节点
    let newStartVnode=newCh[0]
    // 新后节点
    let newEndVnode=newCh[newEndIdx]

    let keyMap=null
    // 开始循环
    while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
        if(checkSameVnode(oldStartVnode,newStartVnode)){
            console.log('新前与旧前命中');
            console.log(oldStartVnode,newStartVnode);
            // 是同一个节点,通过patchVnode函数对他进行比较,然后指针下移
            patchVnode(oldStartVnode,newStartVnode)
            oldStartVnode=oldCh[++oldStartIdx]
            newStartVnode=newCh[++newStartIdx]
        }else if(checkSameVnode(oldEndVnode,newEndVnode)){
            console.log('新后与旧后命中');
            patchVnode(oldEndVnode,newEndVnode)
            oldEndVnode=oldCh[--oldEndIdx]
            newEndVnode=newCh[--newEndIdx]

        }else if(checkSameVnode(oldStartVnode,newEndVnode)){
            console.log('新后与旧前命中');
            // 当新后与旧前命中的时候,此时要移动节点,移动旧前指向的节点到旧后的后面
            parentElm.insetBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
            patchVnode(oldStartVnode,newEndVnode)
            oldStartIdx=oldCh[++oldStartIdx]
            newEndVnode=newCh[--newEndIdx]

        }else if(checkSameVnode(newStartVnode,oldEndVnode)){
            console.log('新前与旧后命中');
            // 当新后与旧前命中的时候,此时要移动节点,移动旧后指向到节点到旧前的前面
            parentElm.insetBefore(oldEndVnode.elm,oldStartVnode.elm)
            patchVnode(newStartVnode,oldEndVnode)
            oldEndVnode=oldCh[--oldEndIdx]
            newStartVnode=newCh[++newStartIdx]

        }else{
            // 四种都没有命中
            // 寻找key的map
            if(!keyMap){
                keyMap={}
                for(let i=oldStartIdx;i<=oldEndIdx;i++){
                    const key=oldCh[i].key
                    if(key!=undefined){
                        keyMap[key]=i
                    }
                }
            }
            console.log(keyMap);
            // 寻找当前项,这项在keyMap中的映射序号
            const idxInOld=keyMap[newStartVnode.key]
            console.log(idxInOld);
            if(idxInOld==undefined){
                // 说明该项是全新的项
                // 在oldStartVnode前面加入
                parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)

            }else{
                // 如果不是undefined,说明该项已经存在,而是需要移动
                const elmToMove=oldCh[idxInOld]
                if(elmToMove.elm.nodeType==1){
                    patchVnode(elmToMove,newStartVnode)
                // 把这项设置成undefined,表示已经处理完了
                oldCh[idxInOld]=undefined
                // 移动到oldStartVnode之前
                parentElm.insertBefore(elmToMove,oldStartVnode.elm)
                }

            }
            
            newStartVnode=newCh[++newStartIdx]
        }
    }

    // 继续看看,有没有剩余节点
    if(newStartIdx<=newEndIdx){
        console.log('new还有剩余节点没有处理');
        const before =oldCh[oldEndIdx+1]==null?null:oldCh[oldEndIdx+1].elm
        for(let i=newStartIdx;i<=newEndIdx;i++){
            // 调用insertBefore方法可以自动识别null,如果是null,会自动排到队尾,和appendChild一致
            parentElm.insertBefore(createElement(newCh[i]),before)
        }
    }else if(oldStartIdx<=oldEndIdx){
        console.log('old还有剩余节点没有处理');
        for(let i=oldStartIdx;i<=oldEndIdx;i++){
            parentElm.removeChild(oldCh[i].elm)
        }
    }
}

 updateChildren函数讲解

updateChildren函数是对newVnode有children,oldVnode也有children情况的处理

先看一下图示:

解释一下:

首先会有四个指针,新前,旧前,新后,旧后

有四种情况:

1、新前与旧前

2、新后与旧后

3、新后与旧前

4、新前与旧后

有一个循环,当命中一种情况后,将不会命中其他种情况,而且是按顺序命中,首先命中1情况,1情况不命中,才开始2情况,2情况不命中,再开始3情况,以此类推...,最后还有一个所有情况都不命中的情况下,会进行对应处理。

当循环结束后,还有oldCh有剩余与newCh有剩余的情况

因此总的来说,有7种情况:

①新前与旧前命中

②新后与旧后命中

③新后与旧前命中

④新前与旧后命中

⑤前面四种都不命中的情况

⑥循环结束后oldCh有剩余的情况

⑦循环结束后newCh有剩余

第一种情况 (新前与旧前命中

if(checkSameVnode(oldStartVnode,newStartVnode)){
            console.log('新前与旧前命中');
            console.log(oldStartVnode,newStartVnode);
            // 是同一个节点,通过patchVnode函数对他进行比较,然后指针下移
            patchVnode(oldStartVnode,newStartVnode)
            oldStartVnode=oldCh[++oldStartIdx]
            newStartVnode=newCh[++newStartIdx]

通过patchVnode将oldStartVnode与newStartVnode进行比较,再将 oldStartVnode与newStartVnode下移

第二种情况(新后与旧后命中)

lse if(checkSameVnode(oldEndVnode,newEndVnode)){
            console.log('新后与旧后命中');
            patchVnode(oldEndVnode,newEndVnode)
            oldEndVnode=oldCh[--oldEndIdx]
            newEndVnode=newCh[--newEndIdx]

通过patchVnode将oldEndVnode与newEndVnode进行比较,再将 oldEndVnode与newEndVnode上移 

第三种情况(新后与旧前命中)

else if(checkSameVnode(oldStartVnode,newEndVnode)){
            console.log('新后与旧前命中');
            // 当新后与旧前命中的时候,此时要移动节点,移动旧前指向的节点到旧后的后面
            parentElm.insetBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
            patchVnode(oldStartVnode,newEndVnode)
            oldStartIdx=oldCh[++oldStartIdx]
            newEndVnode=newCh[--newEndIdx]

当新后与旧前命中的时候,此时要移动节点,移动旧后指向的节点到旧前的前面,通过patchVnode将oldStartVnode与newEndVnode进行比较,再将oldStartVnode下移,而newEndVnode上移 

第四种情况(新前与旧后命中

else if(checkSameVnode(newStartVnode,oldEndVnode)){
            console.log('新前与旧后命中');
            // 当新后与旧前命中的时候,此时要移动节点,移动旧后指向到节点到旧前的前面
            parentElm.insetBefore(oldEndVnode.elm,oldStartVnode.elm)
            patchVnode(newStartVnode,oldEndVnode)
            oldEndVnode=oldCh[--oldEndIdx]
            newStartVnode=newCh[++newStartIdx]

当新后与旧前命中的时候,此时要移动节点,移动旧后指向到节点到旧前的前面, 通过patchVnode将newStartVnode与oldEndVnode进行比较,再将newStartVnode下移,而oldEndVnode上移 

第五种情况(前四种都不命中的情况

else{
            // 四种都没有命中
            // 寻找key的map
            if(!keyMap){
                keyMap={}
                for(let i=oldStartIdx;i<=oldEndIdx;i++){
                    const key=oldCh[i].key
                    if(key!=undefined){
                        keyMap[key]=i
                    }
                }
            }
            console.log(keyMap);
            // 寻找当前项,这项在keyMap中的映射序号
            const idxInOld=keyMap[newStartVnode.key]
            console.log(idxInOld);
            if(idxInOld==undefined){
                // 说明该项是全新的项
                // 在oldStartVnode前面加入
                parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)

            }else{
                // 如果不是undefined,说明该项已经存在,而是需要移动
                const elmToMove=oldCh[idxInOld]
                if(elmToMove.elm.nodeType==1){
                    patchVnode(elmToMove,newStartVnode)
                // 把这项设置成undefined,表示已经处理完了
                oldCh[idxInOld]=undefined
                // 移动到oldStartVnode之前
                parentElm.insertBefore(elmToMove,oldStartVnode.elm)
                }

            }
            
            newStartVnode=newCh[++newStartIdx]
        }

在这里有设置一个对象keyMap,keyMap的作用是将旧节点(oldCh)未被扫描的子元素进行存储,在keyMap中:

键:' key属性 '     值 :子元素的下标

在新节点(newCh)中根据key在keyMap中查找当前项,用idxInOld来存储,若 idxInOld为undefined,则说明当前项在oldCh是不存在的,则在oldCh直接插入该项就好,若inxInOLd不为undefined,则说明oldCh本来就存在该项,这是就需要进行最小量更新。

①idxInOld为undefined

if(idxInOld==undefined){
                // 说明该项是全新的项
                // 在oldStartVnode前面加入
                parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)

② idxInOld不为undefined

else{
                // 如果不是undefined,说明该项已经存在,而是需要移动
                const elmToMove=oldCh[idxInOld]
                if(elmToMove.elm.nodeType==1){
                    patchVnode(elmToMove,newStartVnode)
                // 把这项设置成undefined,表示已经处理完了
                oldCh[idxInOld]=undefined
                // 移动到oldStartVnode之前
                parentElm.insertBefore(elmToMove,oldStartVnode.elm)
                }

            }

 第六种情况(循环结束后oldCh有剩余的情况)

else if(oldStartIdx<=oldEndIdx){
        console.log('old还有剩余节点没有处理');
        for(let i=oldStartIdx;i<=oldEndIdx;i++){
            parentElm.removeChild(oldCh[i].elm)
        }
    }

直接将oldStartIdx与oldEndIdx之间的元素从oldCh中移除就好

第七种情况 (循环结束后newCh有剩余)

if(newStartIdx<=newEndIdx){
        console.log('new还有剩余节点没有处理');
        const before =oldCh[oldEndIdx+1]==null?null:oldCh[oldEndIdx+1].elm
        for(let i=newStartIdx;i<=newEndIdx;i++){
            // 调用insertBefore方法可以自动识别null,如果是null,会自动排到队尾,和appendChild一致
            parentElm.insertBefore(createElement(newCh[i]),before)
        }

直接将oldStartIdx与oldEndIdx之间的元素插入到oldCh最后就行

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值