虚拟DOM和diff算法

一、前言

1、什么是虚拟DOM

在了解虚拟DOM之前先看对应的真是DOM,很多小伙伴都知道原生DOM API,例如document.createElement('div'),我们可以创建一个

真实的div节点,并且使用appendChild('div')将它插入到文档流中,这两个为原生API。

相比之下,vue的虚拟DOM会在每个实例this.$createElement('div')返回一个虚拟节点,用他来表示一个div,但它只是一个js对象。

这个对象包含如下属性:

tag:告诉我们这个元素是”div“

data:数据对象,包含这个元素的一些属性

children:表示这个虚拟节点还有很多虚拟子节点

这样通过这些虚拟节点构成一个虚拟DOM树。

总知:虚拟DOM本质上是轻量的JavaScript数据,用这个对象来表示真实DOM。

2、为什么要用虚拟DOM

  1. 虚拟DOM比真实DOM节省

假设我们用innerHtml去更新应用,需要丢弃以前真实的DOM节点然后重新生成真实的DOM节点,这个成本是高昂的。

而且他会丢弃之前所有表单元素的输入状态和一些输入值,会丢弃元素绑定的一些事件。

         2.以声明式构成你想要的DOM结构

像Javascript,JQuery框架,都是用命令方式编程,例如简单的赋值操作,你叫它干一件事它就干一件,而利用虚拟DOM你叫它干一件事

它会更新一批数据。

         3.把渲染逻辑从真实DOM中分离出来

首先更新数据是无形之中进行的,因为VUE底层对数据的每个属性进行劫持当某个属性值发生变化后会通知所有订阅者更新数据,他不

需要接触DOM。

3、什么是DOM Diff

比较渲染更新前后产生的两个虚拟DOM对象的差异,并生成差异补丁对象,再将差异补丁对象应用到真实DOM节点上,这里的补丁就是

DOM Diff。

4、为什么要用DOM Diff

这里不得不提到进入页面的两个线程:

GUI线程(负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制)

JS引擎线程(负责处理Javascript脚本程序,解析Javascript脚本,运行Js代码)。

GUI线程和JS引擎线程互斥!当JS引擎执行时,GUI线程就会被挂起。

操作DOM的代价是昂贵的,因为操作DOM本质上述两个线程的共同执行的结果,而且浏览器在创建一个元素时,会为其创建很多属性

利用传统声明式编程会操作大量DOM并不断触发重绘与回流,而用命令式编程会通过一些计算尽可能少的操作DOM,这样保证了性能

下限。

二、虚拟DOM从初次渲染到更新

1、虚拟DOM初次渲染

这一过程主要分为以下几个几个步骤:

1>、创建虚拟节点

利用createElement()将传进去的tag、props、children转为JavaScript对象并返回。

var vDom1 = createElement('ul', 
 {class:'list',style:'width:300px;height:300px;background-color:orange'},
 [createElement('li', 
    {class:'item','data-index':0}, 
    [createElement('p', {class:'text'}, ['第一个列表项'])]
  ), 
  createElement('li', 
    {class:'item','data-index':1}, 
    [createElement('p', {class:'text'}, createElement('span', {class:'title'},['第二个列表项']))]
  ),
  createElement('li', {class:'item','data-index':2}, ['第三个列表项']
  )]
 );
const createElement = function(tag,props,children) {
    // 参数拼成对象返回
    return {
        tag,props,children
    }
};

打印出的虚拟DOM对象如下

console.log(vDom1); 

 至此,我们得到了虚拟DOM——>vDom1对象。

2>、将虚拟节点转为真实节点

利用render()函数将上述的虚拟节点对象转为真实节点。思路:

(1) 根据虚拟DOM对象中的tag属性,使用document.createElement()创建对应元素el。

(2) 遍历虚拟DOM对象中的props属性,先判断props是否null,再使用for in遍历props对象并设置属性到父元素el( setAttrs方法 )。

(3) 遍历虚拟DOM对象中的children属性,判断当前遍历项是否对象,是的话说明children是DOM元素,所以需要递归当前遍历项来设置

attrs,否则创建文本节点,并通过appenChild添加到元素el成为el的子节点。

对应代码如下:

var render = function(vDom) {
    const {tag,props,children} = vDom;
    const el = document.createElement(tag);
    for(let key in props){
         setAttrs(el,key,props[key]);
    }
    children.map((c)=>{
        if (typeof(c)=='object') {
            c = render(c);
        } else if (typeof(c)=='string') {
            c = document.createTextNode(c);
        }
        el.appendChild(c);
    })
    return el;
}
const rDom = render(vDom1);
console.log(rDom);

调用并打印rDom得到如下结果:

至此,我们成功通过递归vDom1创建出对应的真实DOM。

3>、真实节点转为真实DOM

真实DOM被创建出来,这时候需要显示在界面上,只需要将真实DOM挂载到根节点上。

document.getElementById('app').appendChild(rDom);

2、当数据发生改变时更新真实DOM

1>、创建补丁包

由于用户操作导致数据发生改变或真实节点的属性发生变更而生成了vDom2,然后比较vDom1和vDom2的差异,生成差异补丁对象。 

利用domDiff(vDom1,vDom2)方法,接收两个虚拟Dom对象,通过深度优先搜索遍历来进行查找比对(只比较同级节点,不跨级比较),

每次遍历到一个节点,就记录一个索引值(从0递增),如果比对发现有差异,就把索引值对应的补丁对象存起来。

一共有以下几种补丁类型:

(1) 节点类型不同,就执行节点替换,则新增补丁{ type: REPLACE, newNode }

(2) 节点相同,就比较属性是否相同,不相同则生成补丁{ type: ATTR, attrs: { class: 'list-group' } }

(3) 新的节点不存在,说明被删除了,则生成补丁{ type: REMOVE }

(4) 如果当前节点都是字符串且不相等,说明都是文本节点,且文本内容发生了改变,则生成补丁{ type: TEXT, text: 'xxx' }

假设新的虚拟DOM对象为:

var vDom2 = createElement('ul', 
 {class:'list-wrap',style:'width:300px;height:300px;background-color:orange'},
 [createElement('li', 
    {class:'item','data-index':0}, 
    [createElement('p', {class:'title'}, ['特殊列表项'])]
  ), 
  createElement('li', 
    {class:'item','data-index':1}, 
    [createElement('p', {class:'text'}, [])]
  ),
  createElement('div', {class:'item','data-index':2}, ['第三个列表项']
  )]
 );

一张图直观明了的看一下新旧虚拟DOM的差异。

domDiff部分的代码如下:

let patches = {},vnIndex = 0;
var domDiff = function(oldVDom,newVDom) {
    let index = 0;
    // 遍历
    vNodeWalk(oldVDom,newVDom,index);
    return patches;
}
function vNodeWalk(oldNode,newNode,index) {
   let vnPatch = [];
   if (!newNode) {
    vnPatch.push({ // 没有新的说明被删除
        type:'REMOVE',
        index
    })
   } else if (typeof oldNode==='string' && typeof newNode==='string') { // text变更
       if (oldNode!=newNode) {
            vnPatch.push({
                type:'TEXT',
                text:newNode
            })
       }
   } else if (oldNode.tag===newNode.tag) { // 标签名相等,则对比属性
       const attrPatch = attrsWalk(oldNode.props,newNode.props);
       if (Object.keys(attrPatch).length>0) {
            vnPatch.push({
                type:'ATTR',
                attrs:attrPatch
            });
       }
       childrenWalk(oldNode.children,newNode.children);
   } else {
        vnPatch.push({
            type:'REPLACE',
            newNode
        });
   }
   if (vnPatch.length>0) {
    patches[index] = vnPatch;
   }
}
function attrsWalk(oldAttrs,newAttrs) {
   let attrPatch = {};
   for(let key in oldAttrs){
       // 修改属性
       if (oldAttrs[key]!=newAttrs[key]) {
        attrPatch = newAttrs[key];
       }
   }
   for (let key in newAttrs) {
       // 增加属性
       if (!oldAttrs.hasOwnProperty(key)) {
        attrPatch[key]=newAttrs[key];   
       }
   }
   return attrPatch;
}
function childrenWalk(oldChildren,newChildren) {
    oldChildren.map((c,index)=>{
        vNodeWalk(c,newChildren[index],++vnIndex);
    })
}
export {
    domDiff  
}
View Code
const patchs = domDiff(vDom1,vDom2);
console.log('patchs',patchs);

 打印出的补丁包如下:

补丁对象和上图索引为0-8的节点产生的差异一一对应,一共有上述5个变动。

2>、打补丁(将补丁对象更新到到真实DOM上)

补丁包创建成功后接下来就是打补丁,这里通过一个方法doPatch(rDom,patchs)来实现,需要传入两个参数因为是给真实DOM打补丁,

所以需要传入真实DOM,另外还传入补丁包。

遍历补丁包,针对每种情况分别处理,涉及增、删、改操作。

function patchAction(rNode,rnPatch) {
    rnPatch.map(p=>{
        switch (p.type){
            case 'ATTR':
                for(let key in p.attrs){
                    const value = p.attrs[key];
                    if (value) {
                        setAttrs(rNode,key,value);
                    } else {
                        rNode.removeAttribute(key);
                    }
                }
                break;
            case 'TEXT':
                rNode.textContent = p.text;
                break;
            case "REPLACE":
                const newNode = typeof(p.newNode)=='object' ? render(p.newNode) : document.createTextNode(p.newNode);
                rNode.parentNode.replaceChild(newNode,rNode);
            case "REMOVE":
                if (rNode.parentNode) {
                    rNode.parentNode.removeChild(rNode);
                }
               
            default:
                break;
        }
    })
}
View Code

这时我们查看真实DOM,可以看到对应的补丁已经更新。

posted on 2021-08-25 10:00  程序员阿田  阅读(541)  评论(0)    收藏  举报

导航