了解虚拟DOM和Dom-Diff?
随着前端领域的飞速发展,越来越多的前端框架层出不穷。目前,React和Vue这两个前端框架已经是前端开发者的必备技能。我经常在采访中被问到:你了解虚拟DOM吗?简单说一下diff算法?有没有研究过React/Vue框架源码,他们的分层源码Dom-Diff是如何实现的?接下来我会一步步实现一个虚拟DOM,讲解它的核心逻辑和算法。图文结合,方便有需要的朋友了解,总结自己对虚拟DOM的理解,方便以后复习。注本文来自作者:txmhttps://juejin.im/post/5e7ac6365188255de700f7ed什么是virtualDOMVirtualdom,即虚拟DOM节点。它通过JSObject对象模拟DOM中的节点,然后通过特定的render方法渲染成真实的DOM节点。为什么操作dom的性能开销高从上图可以看出,真正的DOM元素是非常大的,因为浏览器标准设计DOM是非常复杂的。当我们频繁地做DOM更新时,会出现一定的性能问题。而VirtualDOM是使用一个原生的JS对象来描述一个DOM节点,所以比创建一个DOM要便宜很多。将真实DOM转换为虚拟DOM虚拟DOM是一个普通的JavaScript对象,它包含三个属性:tag、props和children。TXMtoSFM
以上HTML元素转为虚拟DOM:{tag:'div',props:{id:'app'},chidren:[{tag:'p',props:{className:'text'},chidren:['TXMtoSFM']}]}下面详细介绍一下上面的HTML是如何转换成的下面的JS树结构的虚拟DOM对象。初始化项目创建项目我们使用React脚手架创建项目,方便调试、编译、打开效果...//全局安装sudonpminstall-gcreate-react-app//生成项目create-react-appdom-diff//进入项目目录cddom-diff//compilenpmrunstartvirtualDOMcreateElement核心方法createElement接受type,props,children三个参数创建虚拟标签元素DOM方法。functioncreateElement(type,props,children){returnnewElement(type,props,children);}为了提高代码的高复用性,我们将创建虚拟DOM元素的核心逻辑代码放到了Element类中。classElement{constructor(type,props,children){this.type=type;this.props=道具;this.children=孩子们;}}注意:将这些参数挂载到对象的私有属性中,让new也有这些属性。render核心方法render方法接受一个虚拟节点对象参数,其作用是将虚拟DOM转换为真实DOM。函数渲染(eleObj){让el=document.createElement(eleObj.type);//创建元素for(letkeyineleObj.props){//设置属性的方法setAttr(el,key,eleObj.props[key])}eleObj.children.forEach(child=>{//判断孩子是否element是一个Element类型,如果是则递归创建一个文本节点如果不是child=(childinstanceofElement)?render(child):document.createTextNode(child);el.appendChild(child);});returnel;}注意:在将虚拟DOM转换为真实DOM时,转换属性时需要考虑很多情况。value、style等属性需要特殊处理。具体处理逻辑请参考下文元素设置属性部分。元素设置属性在为元素设置属性的public方法中接受三个参数:node、key、value分别代表对该元素的设置属性、要设置的属性名、要设置的属性值.functionsetAttr(node,key,value){switch(key){case'value'://节点是输入或文本区域if(node.tagName.toUpperCase()==='INPUT'||node.tagName.toUpperCase()==='TEXTAREA'){node.value=value;}else{//公共属性node.setAttribute(key,value);}休息;case'style':node.style.cssText=value;休息;默认值:node.setAttribute(key,value);休息;}}我们只考虑了上面三种情况的属性。我们设置好属性后,还需要对children属性进行判断。具体的处理逻辑可以参考下面子项的递归设置。递归设置son,判断子元素是否为Element元素类型,是则递归,不是则创建文本节点。注意:我们只考虑了元素类型和文本类型。eleObj.children.forEach(child=>{//判断子元素是否为Element类型,如果是则递归,如果不是则创建文本节点child=(childinstanceofElement)?render(child):document.createTextNode(孩子);el.appendChild(孩子);});我们都知道真正的DOM是渲染给浏览器的。render方法的作用是将虚拟DOM转换为真实DOM,但是为了在浏览器上看到效果,我们需要将真实DOM添加到浏览器上层。我们写了一个方法,它接受两个参数:elrealDOM和target渲染目标。functionrenderDom(el,target){target.appendChild(el);}上诉步骤完成后,可以导出这些方法以供其他使用。export{createElement,render,Element,renderDom}DOMDiffDomdiff在JS层面通过计算返回一个patch对象,即patch对象,然后通过特定的操作解析patch对象,完成页面的重新渲染。Diff算法规则:同层比较Diff算法有很多情况。下面讨论几种常见的情况:当节点类型相同时,检查属性是否相同,生成补丁包{type:'ATTRS',attrs:{class:'list-group'}}新的dom节点不存在{type:'REMOVE',index:xxx}节点类型不一样,直接采用替换方式{type:'REPLACE',newNode:newNode}文本变化:{type:'TEXT',text:1}比较两个虚拟DOM树的核心diff方法接受两个参数:oldTree和newTree,并根据两个虚拟对象创建一个补丁。说明更改内容,使用此补丁更新DOM。该方法的核心是遍历递归树。该方法将比较后的差异节点放入补丁包中。递归树的核心逻辑可以参考下面的走递归树部分。functiondiff(oldTree,newTree){letpatches={};让索引=0;//默认先比较第一层树//将递归树比较后的节点放入patch包walk(oldTree,newTree,index,patches);returnpatches;}注:比较两棵树的差异时,默认先比较树的第一层。walkrecursivetree这种递归树方法接受oldNode老节点,newNode新节点,index比较层数,patches存储补丁包四个参数,返回各种判断情况的公差存入补丁包.情况一:在新节点中删除子节点currentPatch.push({type:REMOVE,index:index});情况2:判断两个文本是否相同currentPatch.push({type:TEXT,text:newNode});注:目前只判断文本字符串的大小写,也有数字的大小写。在判断两个文本是否一致时,首先要判断它们是否属于文本类型。为了程序的可扩展性,我们封装了一个判断是否为字符串的公共方法:functionisString(node){returnObject.prototype.toString。call(node)==='[objectString]';}情况三:两个节点的元素类型相同,比较属性。在比较属性是否更新的时候,我们需要封装一个diffAttr方法。具体核心逻辑可以参考下面的diffAttr属性对比小节。letattrs=diffAttr(oldNode.props,newNode.props);//判断改变的属性是否有值if(Object.keys(attrs).length>0){//属性改变了currentPatch.push({type:ATTRS,attrs})}注:第一层比对后,如果有子节点,需要遍历递归son,找出两个节点号中所有不同的补丁包。我们需要封装一个diffChildren方法。具体核心逻辑可以参考下面diffChildren遍历children小节。diffChildren(oldNode.children,newNode.children,补丁);情况4:不一样,节点被替换currentPatch.push({type:REPLACE,newNode});以上几种情况判断完毕后,需要判断对应的当前元素确实有补丁,然后返回赋值给自定义补丁补丁对象。diffAttr属性比较该方法接受oldAttrs旧节点属性、newAttrs新节点属性两个参数,其作用是比较两个节点编号的属性是否相同,并将差值存储在patch对象中。在属性比较中,有两种情况来判断新旧节点的属性是否不同。判断旧属性和新属性的关系for(letkeyinoldAttrs){if(oldAttrs[key]!==newAttrs[key]){patch[key]=newAttrs[key];//将新属性存储在patch对象中,可能未定义(如果新属性中旧属性中没有属性)}}旧节点中新节点中没有属性for(letkeyinnewAttrs){//旧节点没有属性新节点有属性if(!oldAttrs.hasOwnProperty(key)){patch[key]=newAttrs[key];}}diffChildren在diffChildren方法中遍历sons接受oldChildren旧子节点、newChildren新子节点、patchespatchObject三个参数。functiondiffChildren(oldChildren,newChildren,patches){oldChildren.forEach((child,idx)=>{walk(child,newChildren[idx],++Index,patches);});}注意:索引递增不是遍历idx和index,但是需要全局定义一个Index=0。Patchpatching当我们通过Diff算法得到patch,然后通过patch更新DOM,从而更新pageview。打补丁的核心方法是patch,它接受节点元素节点和补丁的所有补丁两个参数。它的功能是修补元素并重新更新视图。该方法的核心逻辑在于walk方法。请继续阅读下面的部分,用walk修补每个元素。函数补丁(节点,补丁){console.log(节点)allPatches=补丁;//patchanelementwalk(node);}walkpatcheachelement该方法接受一个node元素节点的参数,patch会反复执行,得到元素的子节点进行递归遍历。如果每一层都有补丁,则执行doPatch方法。该方法的具体核心逻辑请阅读下面的doPatch部分。functionwalk(node){让currentPatch=allPatches[index++];让childNodes=node.childNodes;childNodes.forEach(child=>walk(child));如果(当前补丁){doPatch(节点,当前补丁);}}注意:allPatches和index变量需要全局定义。doPatchdoPatch方法接受两个参数:node节点和patch补丁,遍历patch为了判断patch的类型执行不同的操作:当patch的类型为ATTR属性时,遍历属性attrs对象为获取值。如果值存在,setAttr设置属性值,如果值不存在,则删除对应的属性。setAttr方法的具体核心逻辑请阅读下文setAttr设置属性章节。当patch的类型为TEXT属性时,直接将patch的文本赋值给对应节点的textContent。当patch的类型为REMOVE属性时,直接调用parent的removeChild删除节点。当patch的类型为REPLACE属性时,首先需要判断新节点是否为Element元素类型,如果是则直接调用render方法重新渲染新节点;如果没有,通过createTextNode创建一个文本节点。最后调用父节点的replaceChild方法替换新节点。functiondoPatch(node,patches){patches.forEach(patch=>{switch(patch.type){case'ATTRS':for(letkeyinpatch.attrs){letvalue=patch.attrs[key];if(value){setAttr(node,key,value);}else{node.removeAttribute(key);}}break;case'TEXT':node.textContext=patch.text;break;case'REMOVE':node.parentNode.removeChild(node);break;case'REPLACE':让newNode=(patch.newNodeinstanceofElement)?render(patch.newNode):document.createTextNode(patch.newNode);node.parentNode.replaceChild(newNode,node);break;default:break;}})}setAttr设置属性setAttr方法设置属性的具体逻辑可以参考上面元素设置属性SectionVerification在项目根目录下创建一个index.js文件,手工修改DOM结构来验证我们上面写的diff算法的逻辑是否正确。从'./element'导入{createElement,render,renderDom};从'./diff'导入差异从'./patch'导入补丁letvirtualDom=createElement('ul',{class:'list'},[createElement('li',{class:'item'},['a']),createElement('li',{class:'item'},['a']),createElement('li',{class:'item'},['b'])]);让virtualDom2=createElement('ul',{class:'list-group'},[createElement('li',{class:'item'},['1']),createElement('li',{class:'item'},['a']),createElement('div',{class:'item'},['3'])]);letdom=render(virtualDom);//将虚拟DOM转换为真实DOM并渲染到页面上);总结从头到尾,相信很多朋友都会觉得DOM-Diff的整个过程非常清晰。具体步骤:使用JS对象模拟DOM(虚拟DOM)将这个虚拟DOM转换成真实的DOM并插入到页面中(render)如果有事件发生,虚拟DOM被修改,比较两个虚拟DOM的区别trees获取差异对象(diff)并将差异对象应用于真实的DOM树补丁(patch)如果这篇文章比有需要的小伙伴更有帮助,请帮忙点个红心加wave关注。star~本文项目仓库地址:https://github.com/tangmengcheng/dom-diff.git如果本文对你有帮助,请为本文点赞??????欢迎大家加入,学习前端一起,共同进步!