分析diff算法与虚拟dom(理解现代前端框架)

4,220 阅读5分钟

React和Vue作为目前国内主力的前端开发框架,想必大家在日常的开发当中也是非常熟悉了。不可否认的它们的存在大大地提高了我们的开发效率以及使得我们的代码可维护性得到提高,但是使用它们的“巧妙”的之后,对技术有着追求的你,是不是应该了解一下这些框架背后的一些思想呢?如果还没有,没关系,我们一起来! 本文全部代码小的已经上传github🐶

虚拟DOM

直观来说,虚拟DOM其实就是用数据结构表示真实的DOM结构。使用它的原因是,频繁的操作DOM会使得网站的性能下降,为了保证性能,我们需要使得DOM的操作尽量精简,我们可以通过操作虚拟DOM的方法,去比较新旧节点的差异然后精确的获取最小的,最为必要的DOM集合,最终挂载到真实的DOM上。因为操作数据结构,远比我们直接修改DOM节点来的快,我们真实的DOM操作在最好的情况下,其实只需要在最后来那么一下,不是吗

如何表示DOM结构

这是一段列表的DOM结构,我们分析一下,其中需要包含的信息有

1. 标签类型 ul,li...

2. 标签属性 class,style...

3. 孩子节点ul->li li->text ...

无论再复杂的结构,也都是类似的,那么我们在找到DOM结构的共性之后,我们应该怎么表示呢

通过这张图我们可以发现,我们可以用对象JS对象轻易地就将它表示出来,几个属性也是非常好理解

  • tagName对应真实的标签类型
  • attrs表示节点上的所有属性
  • child表示该节点的孩子节点

那这样我们是不是可以给这个虚拟DOM设定一个类 like this

function newElement(tag,attr,child){ //创建对象函数
    return new Element(tag,attr,child)
}

测试一下

ok没问题是不是,那现在虚拟DOM其实就已经被创建出来了,那么有了虚拟DOM之后怎么挂载到真实DOM上呢

生成真实DOM节点

首先我们会需要一个根据对象属性来设置标签属性的方法

然后我们在类的内部添加创建节点的render方法

到这里我们就可以通过使用render方法创建真实的DOM节点了,在方法内部,我们通过调用SetVdToDom方法对属性进行设置,然后对子节点进行类型判断,递归到最后剩下的文本节点。

最后我们通过一个renderDom方法将dom渲染到浏览器看看

//vdmock.js 部分
const VdObj1 = newElement('ul',{id: 'list'},[
    newElement('li',{class: 'list-1',style:'color:red' }, ['lavie']),
    newElement('li',{class: 'list-2' }, ['virtual dom']),
    newElement('li',{class: 'list-3' }, ['React']),  
    newElement('li',{class: 'list-4' }, ['Vue']) 
])
const RealDom = VdObj1.render()
const renderDom = function(element,target){
    target.appendChild(element)
}
export default function start(){
   renderDom(RealDom,document.body)
}

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script type="module" src="./vdmock.js"  ></script>
    
    <title>Document</title>
</head>
<body >
    <script type="module" >
        import start from './vdmock.js'
        start()
    </script>
</body>
</html>

结果如下:

虚拟DOM diff

通过上面方法,我们可以很简单的生成虚拟DOM并且将它渲染到浏览器上面,那么我们在用户进行操作之后,如何计算出前后虚拟DOM之间的差异呢?下面就来介绍一下diff算法

我们通过给diff传入新旧的两个节点通过内部的getDiff递归对比节点并存储变化然后返回,下面我们来实现一下getDiff

获取最小差异数组

const REMOVE = 'remove'
const MODIFY_TEXT =  'modify_text'
const CHANGE_ATTRS = 'change_attrs'
const TAKEPLACE = 'replace'
let initIndex = 0
const getDiff = (oldNode,newNode,index,difference)=>{
    let diffResult = []
    //新节点不存在的话说明节点已经被删除
    if(!newNode){
        diffResult.push({
            index,
            type: REMOVE
        }) //如果是文本节点直接替换就行
    }else if(typeof newNode === 'string' && typeof oldNode === 'string'){
        if(oldNode !== newNode){
            diffResult.push({
                index,
                value: newNode,
                type: MODIFY_TEXT
            })
        } //如果节点类型相同则则继续比较属性是否相同
    }else if(oldNode.tagName === newNode.tagName){
        let storeAttrs = {}
        for(let  key in oldNode.attrs){ 
            if(oldNode.attrs[key] !== newNode.attrs[key]){
               
                storeAttrs[key] = newNode.attrs[key]
            }
        }
        for (let key in newNode.attrs){
            if(!oldNode.attrs.hasOwnProperty(key)){
                storeAttrs[key] = newNode[key]
            }
        }   
        
        //判断是否有不同
        if(Object.keys(storeAttrs).length>0){
            diffResult.push({
                index,
                value: storeAttrs,
                type: CHANGE_ATTRS
            })
        } //遍历子节点
        oldNode.child.forEach((child,index)=>{
            //深度遍历所以要保留index
             getDiff(child,newNode.child[index],++initIndex,difference)
        }) 
        //如果类型不相同,那么无需对比直接替换掉就行
    }else if(oldNode.tagName !== newNode.tagName){
        diffResult.push({
            type: TAKEPLACE,
            index,
            newNode
        })
    } //最后将结果返回
    if(!oldNode){
        diffResult.push({
            type: TAKEPLACE,
            newNode
        })
    }
    if(diffResult.length){
        difference[index] = diffResult
    }
}


测试结果如下:

更新dom

现在我们已经生成了两个虚拟DOM,并且将两个DOM之间的差异用对象的方式保存了下来,接下来,我们就要通过这些来将差异更新到真实的DOM上面去!!!

pace函数会自身进行递归,对当前节点的差异用dofix进行更新

const doFix = (node,difference) =>{
     difference.forEach(item=>{
         switch (item.type){
             case 'change_attrs':
                 const attrs = item.value
                 for( let key in attrs ){
                     if(node.nodeType !== 1) 
                     return 
                     const value = attrs[key]
                     if(value){
                         SetVdToDom(node,key,value)
                         
                     }else{
                         node.removeAttribute(key)
                     }
                 }
                 break
                 case 'modify_text':
                     node.textContent = item.value
                     break
                case 'replace': 
                   let newNode = (item.newNode instanceof Element) ? item.newNode.render(item.newNode) : 
                   document.createTextNode(item.newNode)
                    node.parentNode.replaceChild(newNode,node)
                    break
                case 'remove' :
                    node.parentNode.removeChild(node)
                    break
                default: 
                    break
         }
     })
}

万事具备,那我们来测试一下!

const VdObj1 = newElement('ul',{id: 'list'},[
    newElement('li',{class: 'list-1',style:'color:red' }, ['lavie']),
    newElement('li',{class: 'list-2' }, ['virtual dom']),
    newElement('li',{class: 'list-3' }, ['React']),  
    newElement('li',{class: 'list-4' }, ['Vue']) ,
])
const VdObj = newElement('ol',{id: 'list'},[
    newElement('h2',{class: 'list-1',style:'color:green' }, ['lavieee']),
    newElement('li',{class: 'list-2' }, ['virtual dom']),
    newElement('li',{class: 'list-3' }, ['React']), 
    newElement('li',{class: 'list-4' }, ['Vue']) ,
    newElement('li',{class: 'list-5' }, ['Dva']) ,
    newElement('li',{class: 'list-5' }, ['Dva']) 
 
])
const RealDom = VdObj1.render()
const renderDom = function(element,target){
    target.appendChild(element)
}
export default function start(){
   renderDom(RealDom,document.body)
   const diffs = diff(VdObj1,VdObj)
   fixPlace(RealDom,diffs)
}

before

diff after

嘻嘻完美

通过这几个例子下来,其实虚拟dom的思想就已经可以实现了,我们在使用框架的过程中如果可以梳理清楚其中的核心概念,一定会走的更加踏实。

2020.2.12 更新

修改了dofix函数和getdiff函数的代码,之前的有点问题~~

最后祝大家新年快乐,牛年offer拿到手软,加薪不加班🐶!!!!