当前位置: 首页 > 科技观察

《源码解析》如何实现一个虚拟DOM算法

时间:2023-03-13 08:20:02 科技观察

上篇文章《虚拟DOM如何进化为真实DOM》讲了如何将虚拟DOM树转化为真实的DOM并渲染到页面中。但是在渲染过程中,我们直接将新的虚拟DOM树转换为真实的DOM来替换旧的DOM结构。当真实DOM中的状态或内容发生变化时,重新渲染新的虚拟DOM树然后替换旧的会显得很无能为力。想象一种情况,当我们只修改整个DOM结构中的一个小数据甚至一个标点符号时,或者当数据量很大时,我们不得不将原来旧的DOM结构全部替换掉??,这样对计算机来说是一种浪费表现。因此,我们希望在更新时将新渲染的虚拟DOM树与旧的虚拟DOM树进行对比,记录下两棵树的不同之处。所记录的不同之处在于,我们需要对页面的真实DOM进行操作,然后将它们渲染到真实的DOM结构上,页面就会随之发生变化。这是通过这种方式实现的:看起来视图的整个结构都被渲染到了最新,但是当最终操作DOM结构时,只是改变了与原来结构不同的部分。即虚拟DOM的diff算法的主要思想是:1.将虚拟DOM结构转换为真实的DOM结构,并替换为旧的DOM(旧的第一次是undefined),以及将其渲染到页面中。2.当状态改变时,将新渲染的虚拟DOM树与原来旧的虚拟DOM树进行比较,比较后记录差异。3.将最终的差异部分转化为真正的DOM结构,渲染到页面上。在比较旧虚拟节点和新虚拟节点的过程中,会出现以下几种情况。下面以Vue为例,看看Vue2.0的Diff算法是如何实现的:比较两个元素的标签,如果标签不同就直接替换,例如:div变成pdiv->p<<<<<<前端简报

=========
前端简报
>>>>>>>>>判断虚拟节点的标签属性是否相等,如果不是,则将新的虚拟DOM树转换为真实的DOM结构,并替换原来的节点}效果图:比较两个元素的文字,在标签相同的情况下比较文字是否相同。如果文字不同,直接替换文字内容。<<<<<<Frontend
=========
Briefing
>>>>>>>>>两个节点的标签都是div,因此,比较孩子的虚拟DOM树是否相同。如果孩子的标签未定义,则表示它是一个文本节点。这个时候比较一下这篇文章的文字是否一致。if(!oldVnode.tag){//文本比较if(oldVnode.text!=vnode.text){return(oldVnode.el.textContent=vnode.text);}}效果图:比较标签属性如果两个标签是相同,然后比较标签的属性。更新属性时,新旧属性对比会出现如下几种情况:1.属性对比如果旧虚拟节点有,新虚拟节点没有,则需要删除上的属性旧的虚拟节点。letnewProps=vnode.data||{};//新属性letel=vnode.el;//旧的有新的,不需要删除属性for(letkeyinoldProps){if(!newProps[key]){el.removeAttribute(key);//移除真正的dom属性}}反之,如果旧的虚拟节点没有,而新的虚拟节点有,则直接设置新的属性。//如果新的存在,然后直接用新的更新它。可以是for(letkeyinnewProps){el.setAttribute(key,newProps[key]);}对应的源码地址:src\platforms\web\runtime\modules\attrs.js2.样式处理如果有新的样式在旧的样式,没有然后删除旧的样式。-style={color:red}+style={background:red}letnewStyle=newProps.style||{};letoldStyle=oldProps.style||{};//部分旧样式不删除旧Stylefor(letkeyinoldStyle){if(!newStyle[key]){el.style[key]="";}}相反,如果旧样式不存在,新样式存在,则直接更新新样式for(letkeyinnewProps){if(key=="style"){for(letstyleNameinnewProps.style){el.style[styleName]=newProps.style[styleName];}}}对应源码地址:src\platforms\web\runtime\modules\style.js3、类名处理类名处理,我们使用新节点的类名-class="titleant-title"+class="titleant-mian-title"for(letkeyinnewProps){if(key=="class"){el.className=newProps.class;}对应的源码地址src\platforms\web\runtime\modules\class.js在比较sons的过程中可以分为以下几种情况:1.旧的节点有儿子,新节点没有儿子,删除旧节点的儿子if(isDef(oldCh)){removeVnodes(oldCh,0,oldCh.length-1)}==============================================if(oldChildren.length>0){el.innerHTML="";}2.老节点没有sons,新节点有son遍历children,转化为realDOM结构并将它们添加到页面中if(isDef(ch)){if(isDef(oldVnode.text))nodeOps.setTextContent(elm,'')addVnodes(elm,null,ch,0,ch.length-1,insertedVnodeQueue)}====================================================================如果(newChildren.length>0){for(leti=0;ioldEndIndex){for(leti=newStartIndex;i<=newEndIndex;i++){letele=newChildren[newEndIndex+1]==null?null:newChildren[newEndIndex+1].el;parent.insertBefore(createElm(newChildren[i]),ele);}}如果旧节点是冗余的,说明这些节点是不需要的,可以删除。如果在删除过程中出现null,说明该节点已经处理完毕,跳过Can。if(newStartIdx>newEndIdx){for(leti=oldStartIndex;i<=oldEndIndex;i++){letchild=oldChildren[i];if(child!=undefined){parent.removeChild(child.el);}}}如果多余节点在左边,新旧节点的末端节点的下标都减1--oldEndIdx]newEndVnode=newCh[--newEndIdx]}倒序如果新旧节点倒序,比较旧节点的起始节点和新节点的结束节点或者比较旧节点和结束节点新节点的起始节点。如果旧节点的起始节点和新节点的结束节点是同一个节点,则在旧结束节点的下一个节点之前插入旧起始节点,然后将节点对应的下标向右移动,左边分别获取对应的值继续迭代。if(sameVnode(oldStartVnode,newEndVnode)){//VnodemovedrightpatchVnode(oldStartVnode,newEndVnode,insertedVnodeQueue,newCh,newEndIdx)canMove&&nodeOps.insertBefore(parentElm,oldStartVnode.elm,nodeOps.nextSibling(oldEndVnode.elm))oldStartVnode=oldCh[++oldStart]newEndVnode=newCh[--newEndIdx]}如果旧节点的结束节点和新节点的开始节点是同一个节点,则将旧节点的结束节点插入到旧节点开始节点的前面,然后分别向左和向右移动节点对应的下标,得到对应的值继续遍历。if(sameVnode(oldEndVnode,newStartVnode)){//VnodemovedleftpatchVnode(oldEndVnode,newStartVnode,insertedVnodeQueue,newCh,newStartIdx)canMove&&nodeOps.insertBefore(parentElm,oldEndVnode.elm,oldStartVnode.elm)oldEndVnode=oldCh[--oldEndIdx]newCh[newStartVnode=++newStartIdx]}无关排列如果比较过程中儿子之间没有关系,则从新节点的起始节点开始,依次与旧节点的所有节点比较,如果不相同,则创建一个新节点和插入到旧节点的起始节点之前,如果在循环过程中发现相同的元素,则直接重用旧元素,并在旧节点的起始节点之前插入与新节点相同的旧节点节点,为了防止数组崩溃,将被移除的旧节点的位置设置为undefined,最后删除所有多余的旧节点。设置缓存组使用老节点的key和下标做映射表。新节点的key在旧映射表中过滤。如果不过滤,则不重用,直接新建一个节点,插入到旧节点的起始节点之前。.functioncreateKeyToOldIdx(children){leti,keyconstmap={}children.forEach((item,index)=>{if(isDef(item.key)){map[item.key]=index;//{a:0,b:1,c:2,d:3,e:4,f:5,g:6}}returnmap}如果在老节点找到,则将老节点移动到起始节点之前的老节点letmap=createKeyToOldIdx(oldChildren);//son之间没有关系letmoveIndex=map[newStartVnode.key];//获取开头的虚拟节点的key,在old中查找if(moveIndex==undefined){parent.insertBefore(createElm(newStartVnode),oldStartVnode.el);}else{letmoveVNode=oldChildren[moveIndex];//这个旧的虚拟节点需要移动oldChildren[moveIndex]=null;parent.insertBefore(moveVNode.el,oldStartVnode.el);patch(moveVNode,newStartVnode)//比较属性和子}newStartVnode=newChildren[++newStartIndex]//用新的继续往里找旧的,移动过程中,起始指针和结束指针可能指向null.如果指向null,那么就不能进行比较,可以直接跳过并指向下一个元素。if(isUndef(oldStartVnode)){oldStartVnode=oldCh[++oldStartIdx]//Vnodehasbeenmovedleft}elseif(isUndef(oldEndVnode)){oldEndVnode=oldCh[--oldEndIdx]}源码地址:src/core/vdom/patch.js为什么要做你想用钥匙吗?长得丑的人不多。首先,看图片。有钥匙和没有钥匙。如果有key,则重用key为A、B、C、D的四个节点。结果是新创建的E节点插在C节点前面完成渲染。如果没有key,则创建E、C、D三个节点,降低了复用率,性能肯定没有key高。为什么索引不能用作键?在正常的开发过程中,如果只是通过页面的静态渲染,可以使用索引作为key。如果页面上有复杂的逻辑变化,那么使用索引作为key就相当于没有key。ACBBCA如上代码所示,改变下标0和2的A和C的位置后,需要重新创建节点A和C。此时C的下标为0,A的下标为2。而如果用id或者唯一标识作为key,相当于移动了A和C元素的位置。翻译比创建节点更高效。使用索引作为键时,会出现意想不到的问题。如果我们删除B节点,我们一开始设置的值为B,现在值变成了C。总结一下Vue2.0的diff算法的pathVode方法的基本思想可以概括如下:1.判断是否oldVode和newVode是同一个对象,如果是则直接返回。2.就是把真正的DOM定义为el。3.如果oldVode和newVode都有文本节点且不相等,则将old的文本节点设置为newVode的文本节点。4.如果oldVode有子节点而newVode没有,则删除子节点。5.如果oldVode没有子节点newVode有。然后将子节点转化为真正的DOM,添加到el中。6.如果都有子节点,则执行updateChildren函数,比较子节点以上就是Diff算法的全部过程,对整个Vue渲染过程的性能起着至关重要的作用。