2021-05-24 05:47:31
本文是对个人学习中对diff算法的整理记录。主要讲了vue2和vue3diff算法中核心部分的代码和实现流程。
为什要使用diff算法diff算法的使用要先从vue框架的设计说起,从范式角度来看,框架可以设计成命令式或声明式,权衡了性能和可维护性vue选择声明式的设计方案。
命令式和声明式命令式:代码本身描述的是“做事的过程”,在代码开发的时候,我们需要维护实现目标的整个过程,包括要手动完成DOM元素的创建、更新、删除等工作。如:Jquery就是典型的命令式框架.声明式:更加关注结果,代码展示的就是我们要的结果,看上去更加直观,至于做事儿的过程,并不需要我们关心.从上面可以看出声明式的可维护性高于命令式,心智负担也小于命令式,但性能比命令式要差。命令式代码的更新性能消耗=直接修改的性能消耗,声明式=直接修改+找出差异的性能消耗。那么我们只要能把找出差异的性能消耗最小化,就可以让声明式的消耗无限接近命令式。这个时候我们就要使用虚拟dom和diff算法了
什么是虚拟DOM和diff算法虚拟DOM就是用来表示真实dom的对象,vue通过模版编译生成虚拟DOM树,然后在通过渲染器渲染成真实DOM,当数据更新时,产生新的虚拟dom树,如果直接用新的虚拟DOM树生成真实DOM并不是最优的方法。为了进一步降低找出差异的性能的性能消耗,就要使用diff算法。Diff算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,实现精准地更新真实DOM。
注:下图示中的a,b,c为节点的key值,所有的移动插入删除操作都在真实dom,下文所讲是diff算法中的核心部分
vue2双端diff算法的实现vue2采用了双端diff算法。核心方法是updateChildren,通过新前与旧前、新后与旧后、新后与旧前、新前与旧后、暴力比对5种查找。新前:newChildren中所有未处理的第一个节点新后:newChildren中所有未处理的最后一个节点旧前:oldChildren中所有未处理的第一个节点旧后:oldChildren中所有未处理的最后一个节点
在具体介绍前我们还需要了解isSameVnode这个用来对比两个节点是否相同的方法
//判断两个vnode的标签和key是否相同如果相同就可以认为是同一节点就地复用functionisSameVnode(oldVnode,newVnode){returnoldVnode.tag===newVnode.tag&&oldVnode.key===newVnode.key;}新前与旧前新前与旧前对比,如果相同那么新,老的开始下标往后移动一格,上图中a的新老节点相同,位置移动b位置,此时新节点为f,两节点不同,进入新后与旧后比对
新后与旧后新后与旧后对比,如果相同那么新,老的结束下标往前移动一格,上图中g的新老节点相同,位置移动f位置,此时新节点为b,两节点不同,这时发现新后与旧后,新前与旧前都不满足,进入新后与旧后比对
新后与旧前新后与旧前对比,如果相同那么,把老的开始节点移动到老的结束节点后面,然后老的开始下标往后移动一格,新的结束下标往前移动一格。这时发现新的位置以上3种都不能满足,进入新前与旧后比对
新前与旧后新前与旧后对比,如果相同那么,把老的结束节点移动到老的开始节点前面,然后新的开始下标往后移一格,老的结束下标往前移动一格。
暴力比对(乱序)如果节点比对的时候上面4种方法都不适用时,此时我们只能用最暴力的方法,首先我们需要循环oldChildren生成一个key和index的映射表{'a':0,'b':1},然后我们用新的开始节点的key,去映射表中查找,如果找到就把该节点移动到最前面,且原来的位置用undefined占位,避免数组塌陷防止老节点移动走了之后破坏了初始的映射表位置,如果没有找到就直接把新节点插入
新节点有剩余有时候我们会添加新的数据,这时后上面循环结束后,newChildren还有剩余的节点还没有处理,我们需要循环这些节点,逐个插入。如上图所示。当oldStartIndex>oldEndIndex时,新的子节点还有c,d两个节点多余,需循环插入这2个节点。
老节点有剩余另一种情况就是当我们删除元素,这时当对比循环结束后,oldChildren还有剩余的节点还没有处理,我们需循环这些节点,逐个删除。如上图所示。当newStartIndex>newEndIndex时,老的子节点还有c,d两个节点多余,循环删除这2个节点
全部核心代码//diff算法核心采用双指针的方式对比新老vnode的儿子节点functionupdateChildren(el,oldChildren,newChildren){letoldStartIndex=0;//老儿子的开始下标letoldStartVnode=oldChildren[0];//老儿子的第一个节点letoldEndIndex=oldChildren.length-1;//老儿子的结束下标letoldEndVnode=oldChildren[oldEndIndex]//老儿子的最后一个节点letnewStartIndex=0;//新儿子的开始下标letnewStartVnode=newChildren[0];//新儿子的第一个节点letnewEndIndex=newChildren.length-1;//新儿子的结束下标letnewEndVnode=newChildren[newEndIndex]//新儿子的最后一个节点//根据key来创建老的儿子的index映射表,如{'a':0,'b':1}代表key为'a'的节点在第一个位置,'b'在第二个位置constmakeIndexBykey=(children)=>{returnchildren.reduce((memo,cur,index)=>{memo[cur.key]=indexreturnmemo},{})}constkeysMap=makeIndexBykey(oldChildren)//只有当新、老儿子的开始下标都小于等于结束下标时才循环,一方不满足就结束循环while(oldStartIndex<=oldEndIndex&&newStartIndex<=newEndIndex){//因为暴力对比过程把移动的vnode置为undefined如果不存在节点直接跳过if(!oldStartVnode){//开始位置向后+1oldStartVnode=oldChildren[++oldStartIndex]}elseif(!oldEndVnode){//结束位置向前-1oldEndVnode=oldChildren[--oldEndIndex]}if(isSameVnode(oldStartVnode,newStartVnode)){//新前和后前相同//递归比较儿子以及他们的子节点patch(oldStartVnode,newStartVnode)//新,老开始下标+1,对应的节点变为+1后的节点oldStartVnode=oldChildren[++oldStartIndex]newStartVnode=newChildren[++newStartIndex]}elseif(isSameVnode(oldEndVnode,newEndVnode)){//新后和旧后相同//递归比较儿子以及他们的子节点patch(oldEndVnode,newEndVnode)//新,老结束下标-1,对应的节点变为-1后的节点oldEndVnode=oldChildren[--oldEndIndex]newEndVnode=newChildren[--newEndIndex]}elseif(isSameVnode(oldStartVnode,newEndVnode)){//新后和旧前相同//递归比较儿子以及他们的子节点patch(oldStartVnode,newEndVnode)//开始节点的真实dom,移动到结束节点的下一个前点的前面el.insertBefore(oldStartVnode.el,oldEndVnode.el.nextSibling)//老的开始下标+1,对应的节点变为+1后的节点oldStartVnode=oldChildren[++oldStartIndex]//新的结束下标-1,对应的节点变为-1后的节点newEndVnode=newChildren[--newEndIndex]}elseif(isSameVnode(oldEndVnode,newStartVnode)){//新前和旧后相同//递归比较儿子以及他们的子节点patch(oldEndVnode,newStartVnode)//结束结束的真实dom,移动到开始节点的前面el.insertBefore(oldEndVnode.el,oldStartVnode.el)//老的结束下标-1,对应的节点变为-1后的节点oldEndVnode=oldChildren[--oldEndIndex]//新的开始下标+1,对应的节点变为+1后的节点newStartVnode=newChildren[++newStartIndex]}else{//上述四种情况都不满足那么需要暴力比对//用新的开始节点的key,去老的子节点生成的映射表中查找constmoveIndex=keysMap[newStartVnode.key]if(!moveIndex){//如果没有找到直接把新节点的真实dom,插入到旧的开始节点的真实dom前面el.insertBefore(createElm(newStartVnode),oldStartVnode.el)}else{//如果找到,取出该节点constmoveNode=oldChildren[moveIndex]//原来的位置用undefined占位避免数组塌陷防止老节点移动走了之后破坏了初始的映射表位置oldChildren[moveIndex]=undefined//把取出的节点的真实dom插入到开始节点的真实dom前面el.insertBefore(moveNode.el,oldStartVnode.el)patch(newStartVnode,moveNode)//比较}//新的开始下标+1,对应的节点变为+1后的节点newStartVnode=newChildren[++newStartIndex]}}//如果老节点循环完毕了但是新节点还有,如用户追加了一个,需要把剩余的节点插入if(newStartIndex<=newEndIndex){for(leti=newStartIndex;i<=newEndIndex;i++){//这是一个优化写法insertBefore的第一个参数是null等同于appendChild作用//看一下结束指针的下一个元素是否存在letanchor=newChildren[newEndIndex+1]==null?null:newChildren[newEndIndex+1].elel.insertBefore(createElm(newChildren[i]),anchor)}}//如果新节点循环完毕了但是老节点还有,如用户删除一个,需要把剩余的节点删除if(oldStartIndex<=oldEndIndex){for(leti=oldStartIndex;i<=oldEndIndex;i++){//该节点不是占位节点,才做删除操作if(oldChildren[i]!=null){el.removeChild(oldChildren[i].el)}}}#}vuu2双端diff算法流程图(放大查看)注:下边所讲diff算法是vuejs设计与开发书的版本,与源码版有些差别,但核心部分是一样的,可去mini-vue查看源码版
vue3使用了快速diff算法,核心方法是patchKeyedChildren,首先是借鉴了纯文本diff算法中的预处理思路,处理新旧两个组子节点中相同的前置节点和后置节点。处理完后,如果剩余节点无法简单的通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构建出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点。
相同前置节点处理前置节点的处理是定义了一个j变量,分别指向新,老两个组子节点,比较指向的新,老节点是否相同,如果相同指针+1,直到两个节点不同时结束前置节点的处理
相同后置节点处理后置节点的处理是定义了索引oldEnd指向旧的一组子节点的最后一个节点和索引newEnd指向新的一组子节点的最后一个节点。然后比较两个指向的新旧节点,如果相同指向-1,直到两个节点不同时结束后置节点的处理
剩余节点的处理当我们处理完相同的前置节点和后置节点后,如果还有剩余节点,就要对剩余节点进行处理,剩余节点分为3中情况,分别是只有新的一组的子节点有剩余,只有老的一组的子节点有剩余,新老两组的子节点都有剩余;
只有新的一组的子节点有剩余当条件满足j>oldEnd且j<=newEnd时,表示只有新的一组的子节点还有未处理的节点,我们需要循环j->newEnd中的节点进行插入
只有老的一组的子节点有剩余当条件满足j>newEnd且j<=oldEnd时,表示只有老的一组的子节点还有未处理的节点,我们需要循环j->oldEnd中的节点进行删除
新老两组的子节点都有剩余该状态下主要核心为3个部分:构建source数组用于存放新的一组子节点每个节点在老的一组中存在的原来位置(索引)首先是定义一个长度为剩余新的一组子节点的长度的数组source,初始值都为-1,还定义了一个变量patched用于记录,然后遍历新的一组子节点,构建key与index的映射表,最后遍历老的一组节点,去映射表中寻找,k=keyIndex[oldVnode.key];,如果找到就把对应的索引存入到source对应的位置中,没有找到说明该节点多余,直接删除。
判断是否需要移动节点,首页我们定义两个变量,moved用于记录是否需要移动的阀值,pos用与记录最后一个节点在新的里面的索引,最后用上述去映射寻找到值k与pos寻找,如果k<pos,说明不是生序需要移动,否则pos=k
利用最长递增子序列来优化移动逻辑,如果moved=true,首先通过最长递增子序列获取到生序列表存放的是索引,然后从后遍历新的一组节点,节点的索引与生序列表对比,如果对比上了说明不需要移动,否则需要移动。
vue2是全量进行diff,而vue3使用了静态标记,只对打标记的节点进行diff
vue2中的虚拟dom是进行全量的对比,在运行时会对所有节点生成一个虚拟节点树,当页面数据发生变更好,会遍历判断虚拟dom所有节点(包括一些不会变化的节点)有没有发生变化;vue3在diff算法中相比vue2增加了静态标记,在模版编译时,编译器会在动态标签末尾加上/Text/