当前位置: 首页 > 后端技术 > Node.js

解析vue2.0的diff算法

时间:2023-04-03 13:18:33 Node.js

转载请注明出处本文转载至我的博客目录前言虚拟dom分析diff总结前言vue2.0加入了虚拟dom,意思是靠拢react。vue的diff位于patch.js文件中,我的一个小框架aoy也是用的这个算法,是snabbdom派生出来的,复杂度为O(n)。了解diff过程可以让我们更有效地使用框架。本文力求以图形化的方式说明diff过程。virtualdom如果不了解virtualdom,就很难理解diff的过程。虚拟dom对应真实dom,真实节点使用document.CreateElement和document.CreateTextNode创建。我们可以做一个实验。打印出一个空元素的一级属性,可以看到标准允许元素实现的东西太多了。如果每次都重新生成新的元素,那是对性能的巨大浪费。varmydiv=document.createElement('div');for(varkinmydiv){console.log(k)}virtualdom是解决这个问题的思路,什么是virtualdom?通俗地说,就是用一个简单的对象来代替复杂的dom对象。举个简单的例子,我们在body中插入一个class为a的div。varmydiv=document.createElement('div');mydiv.className='a';document.body.appendChild(mydiv);对于这个div我们可以用一个简单的对象mydivVirtual来表示,它存储了一些对应dom的重要参数,在改变dom之前,会先对比对应的虚拟dom的数据,如果需要改变,则改变应用于真正的dom。//伪代码varmydivVirtual={tagName:'DIV',className:'a'};varnewmydivVirtual={tagName:'DIV',className:'b'}if(mydivVirtual.tagName!==newmydivVirtual.tagName||mydivVirtual.className!==newmydivVirtual.className){change(mydiv)}//会执行相应的modificationmydiv.className='b';//Finally

读到这里会有一个疑问,为什么不直接修改dom而是需要加一层虚拟dom呢?很多时候,手动优化dom确实比虚拟dom效率更高。对于比较简单的dom结构,手动优化是没有问题的,但是当页面结构比较大,比较复杂的时候,手动优化会耗费很多时间,而且可维护性也不高,也不能保证人人都有手动优化的能力。至此,虚拟dom方案应运而生。虚拟dom往往不是最优的操作,但它具有通用性,在效率和可维护性之间取得了平衡。virtualdom还有一个很大的意义就是提供一个中间层,js写ui,ios和Android负责渲染,就像reactnative一样。在一篇相当经典的文章React的diff算法中分析图。React的diff实际上类似于Vue的diff。所以这张图可以很好的说明这个过程。只会进行同级比较,不会跨级比较。举个形象的例子。

aoydiff

aoy

diff
我们可能希望将直接移到

后面,这是最优操作。但实际的diff操作是将

中的去掉,新建一个插在

后面。因为新加入的是2级,旧的是3级,属于不同级别的比较。源码分析文章中的代码位于aoy-diff,简化了很多代码,留下了核心部分。diff的过程就是调用patch函数,像patch一样修改真实的dom。functionpatch(oldVnode,vnode){if(sameVnode(oldVnode,vnode)){patchVnode(oldVnode,vnode)}else{constoEl=oldVnode.elletparentEle=api.parentNode(oEl)createEle(vnode)if(parentEle!==null){api.insertBefore(parentEle,vnode.el,api.nextSibling(oEl))api.removeChild(parentEle,oldVnode.el)oldVnode=null}}returnvnode}patch函数有两个参数,vnode和oldVnode,即新旧虚拟节点。在此之前,我们先了解一个完整的vnode有哪些属性。举一个简单的例子://

underbody对应于oldVnodeis{el:div//对真实节点的引用,在本例中为document.querySelector('#id.classA')tagName:'DIV',//节点的标签sel:'div#v.classA'//节点的选择Data:null,//存储节点属性的对象,对应于el[prop]节点的属性,比如onclick,stylechildren:[],//一个存放子节点的数组,每个子节点也是一个vnode结构体text:null,//如果是文本节点,则对应文本节点的textContent,否则为null}需要注意的是,el属性指的是这个虚拟dom对应的真实dom,patch的vnode参数的el最初为null,因为它仍然补丁之前存在没有对应的真实dom。来到patch的第一部分,if(sameVnode(oldVnode,vnode)){patchVnode(oldVnode,vnode)}sameVnode函数是看两个节点是否值得比较,代码很简单:functionsameVnode(oldVnode),vnode){returnvnode.key===oldVnode.key&&vnode.sel===oldVnode.sel}两个vnode的key和sel相同比较,比如p和span,div.classA和div.classB被认为是不同的结构而不是比较它们。如果值得比较,就会执行patchVnode(oldVnode,vnode),后面会详细介绍patchVnode函数。当节点不值得比较时,在else中输入else{constoEl=oldVnode.elletparentEle=api.parentNode(oEl)createEle(vnode)if(parentEle!==null){api.insertBefore(parentEle,vnode.el,api.nextSibling(oEl))api.removeChild(parentEle,oldVnode.el)oldVnode=null}}过程如下:获取oldvnode.el的父节点,parentEle才是真正的domcreateEle(vnode)会创建它的vnode的真实dom,以便vnode.el=realdomparentEle插入新的dom并删除旧的dom。当不值得比较时,新节点直接替换旧节点。Finallyreturnvnodepatch最终会返回vnode。vnode和打补丁之前有什么区别?没错,就是vnode.el。唯一的变化是之前的vnode.el=null,现在指向对应的真实dom。varoldVnode=patch(oldVnode,vnode)至此,一个patch过程就完成了。patchVnode当两个节点值得比较时,patchVnode函数将被调用=vnode)returnif(oldVnode.text!==null&&vnode.text!==null&&oldVnode.text!==vnode.text){api.setTextContent(el,vnode.text)}else{updateEle(el,vnode,oldVnode)if(oldCh&&ch&&oldCh!==ch){updateChildren(el,oldCh,ch)}elseif(ch){createEle(vnode)//创建el的孩子dom}elseif(oldCh){api.removeChildren(el)}}}constel=vnode.el=oldVnode.el这是很重要的一步,让vnode.el引用当前真实的dom,修改el时,vnode.el会同步变化。节点比较if(oldVnode===vnode)有5种情况,它们的引用是一致的,可以认为没有变化。if(oldVnode.text!==null&&vnode.text!==null&&oldVnode.text!==vnode.text),需要修改文本节点的比较,会调用Node.textContent=vnode.text.if(oldCh&&ch&&oldCh!==ch),两个节点都有子节点,而且是不同的,所以我们会调用updateChildren函数来比较子节点,这个是diff的核心,后面会讲到。elseif(ch),只有新节点有子节点,调用createEle(vnode),vnode.el已经引用了旧dom节点,createEle函数会将子节点添加到旧dom节点。elseif(oldCh),新节点没有子节点,老节点有子节点,直接删除老节点。updateChildrenupdateChildren(parentElm,oldCh,newCh){letoldStartIdx=0,newStartIdx=0letoldEndIdx=oldCh.length-1letoldStartVnode=oldCh[0]letoldEndVnode=oldCh[oldEndIdx]letnewEndIdx=newCh.length-1letnewStartVnode=newCh[0]letnewEndVnode=newCh[newEndIdx]letoldKeyToIdxletidxInOldletelmToMoveletbeforewhile(oldStartIdx<=oldEndIdx&&newStartIdx<=newEndIdx){if(oldStartVnode==null){//对vnode.key的比较,会把oldVnode=nulloldStartVnode=oldCh[++oldStartIdx]}elseif(oldEndVnode==null){oldEndVnode=oldCh[--oldEndIdx]}elseif(newStartVnode==null){newStartVnode=newCh[++newStartIdx]}elseif(newEndVnode==null){newEndVnode=newCh[--newEndIdx]}elseif(sameVnode(oldStartVnode,newStartVnode)){patchVnode(oldStartVnode,newStartVnode)oldStartVnode=oldCh[++oldStartIdx]newStartVnode=newCh[++newStartIdx]}elseif(sameVnode(oldEndVnode,newEndVnode)){patchVnode(oldEndVnode,newEndVnode)oldEndVnode=oldCh[--oldEndIdx]newEndVnode=newCh[--newEndIdx]}elseif(sameVnode(oldStartVnode,newEndVnode)){patchVnode(oldStartVnode,newEndVnode)api.insertBefore(parentElm,oldStartVnode.el,api.nextSibling(oldEndVnode.el))oldStartVnode=oldCh[++oldStartIdx]newEndVnode=newCh[--newEndIdx]}elseif(sameVnode(oldEndVnode,newStartVnode)){patchVnode(oldEndVnode,newStartVnode)api.insertBefore(parentElm,oldEndVnode.el,oldStartVnode.el)oldEndVnode=oldCh[--oldEndIdx]newStartVnode=newCh[++newStartIdx]}其他{//使用密钥时的比较if(oldKeyToIdx===undefined){oldKeyToIdx=createKeyToOldIdx(oldCh,oldStartIdx,oldEndIdx)//有密钥生成索引表}idxInOld=oldKeyToIdx[newStartVnode.key]if(!idxInOld){api.insertBefore(parentElm,createEle(newStartVnode).el,oldStartVnode.el)newStartVnode=newCh[++newStartIdx]}else{elmToMove=oldCh[idxInOld]if(elmToMove.sel!==newStartVnode.sel){api.insertBefore(parentElm,createEle(newStartVnode).el,oldStartVnode.el)}else{patchVnode(elmToMove,newStartVnode)oldCh[idxInOld]=nullapi.insertBefore(parentElm,elmToMove.el,oldStartVnode.el)}newStartVnode=newCh[++newS]tartIdx]}}}if(oldStartIdx>oldEndIdx){before=newCh[newEndIdx+1]==null?null:newCh[newEndIdx+1].eladdVnodes(parentElm,before,newCh,newStartIdx,newEndIdx)}elseif(newStartIdx>newEndIdx){removeVnodes(parentElm,oldCh,oldStartIdx,oldEndIdx)}}代码非常密集。为了形象的描述这个过程,可以看这张图。其过程可以概括为:oldCh和newCh各有两个头尾变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果四个比较都不匹配,如果设置了key,则使用key进行比较。在比较过程中,变量会向中间倾斜。一旦StartIdx>EndIdx表示至少遍历了oldCh和newCh之一,就结束Compare。具体diff分析设置key和不设置key的区别:不设置key,newCh和oldCh只会比较头尾端。设置key后,除了进行头尾比较外,key生成的对象会在oldKeyToIdx中找到匹配的节点,所以为节点设置key可以更高效的利用dom。diff遍历过程中,只要对dom进行操作,就会调用api.insertBefore,api.insertBefore只是对原生insertBefore的简单封装。有两个对比,一个有vnode.key,一个没有。但是这两个比较在真实的DOM上以相同的方式运行。对于sameVnode(oldStartVnode,newStartVnode)和sameVnode(oldEndVnode,newEndVnode)为真的情况,不需要移动dom。总结遍历过程,dom操作分为三种:当oldStartVnode和newEndVnode值得比较时,说明oldStartVnode.el已经落后于oldEndVnode.el。图中假设startIdx遍历到1,在oldEndVnode和newStartVnode值得比较的时候,oldEndVnode.el跑到了oldStartVnode.el的前面,准确的说应该是oldEndVnode.el需要移动到oldStartVnode.el前面。newCh中的节点不在oldCh中,将新节点插入oldStartVnode.el前面。最后有两种情况:oldStartIdx>oldEndIdx,可以认为oldCh已经完成先遍历,当然也有可能此时newCh刚遍历完,都归到这一类,此时newStartIdx和newEndIdx之间的vnode是新添加的,调用addVnodes将它们都插入到后面before.before经常为null,addVnodes调用insertBefore操作dom节点,看下insertBefore的文档:parentElement.insertBefore(newElement,referenceElement)如果referenceElement为null,newElement会插入到子节点的末尾.如果newElement已经在DOM树中,newElement将首先从DOM树中移除。所以before为null,newElement会插入到子节点的末尾。newStartIdx>newEndIdx,可以认为newCh先遍历过了。此时oldStartIdx和oldEndIdx之间的vnode在新的子节点中已经不存在了。调用removeVnodes将它们从dom中删除。下面是一个例子,绘制了一个完整的diff过程,dom变化的每一步都用不同颜色的线标出。A、b、c、d、e被假定为4个不同的元素。当我们不设置key的时候,b并没有被复用,而是直接新建了一个,把旧的删掉。当我们向4个元素添加唯一键时,b会被重用。在这个例子中,如果我们使用手动优化,只需要3步就可以实现。综上所述,尽量不要跨层修改dom设置key,最大限度的利用节点。不要盲目相信diff的效率。您可以在必要时手动优化它。