找回密码
 会员注册
查看: 12|回复: 0

图解Diff算法——Vue篇

[复制链接]

4

主题

0

回帖

13

积分

新手上路

积分
13
发表于 2024-9-30 19:19:24 | 显示全部楼层 |阅读模式
一、虚拟dom1. 虚拟 dom 是什么?虚拟dom是一个对象,一个用js来模拟真实dom的对象;//真实的dom结构111222333那么上述dom结构,在虚拟dom中是如何进行展示的呢?//旧的虚拟dom结构constoldVDom={tagName:'ul',//标签名props:{//标签属性id:'list'},children:[//标签子节点{tagName:'li',props:{class:'item1'},children:['111']},{tagName:'li',props:{class:'item2'},children:['222']},{tagName:'li',props:{class:'item3'},children:['333']},]}此时我修改一下真实的dom结构后:111222three-three之后会生成新的虚拟dom://新的虚拟dom结构constnewVDom={tagName:'ul',//标签名props:{//标签属性id:'list'},children:[//标签子节点//在diff中,会通过patch发现此处两个节点没有变化,并将其复用{tagName:'li',props:{class:'item1'},children:['111']},{tagName:'li',props:{class:'item2'},children:['222']},//在diff的过程中,会通过patch来找出此处发生了更改,并将其替换{tagName:'li',props:{class:'item3'},children:['three-three']},]}此时看到的两个dom结构就是我们常说的 新旧虚拟dom2. 为什么要有虚拟 dom ?解决了什么问题?在虚拟dom出现之前,我们都是jQuery一把梭(不多说了jQuery yyds)。这里先来了解一下浏览器的渲染原理:由图可以发现触发一次重排的代价还是比较大的;如果频繁触发浏览器的重排,无疑会造成很大的性能成本。我们都知道,在每一次事件循环后浏览器会有一个UI的渲染过程,那么在一次事件循环内触发的所有dom操作都会被当作为异步任务被放进异步任务队列中等待被处理。那么此例子只是更改了一次dom结构,如果更改100+次呢?虽然浏览器做了优化,在一段时间内频繁触发的dom不会被立即执行,浏览器会积攒变动以最高60HZ的频率更新视图;但是难免还是会造成一定次数的重排。这时候,虚拟dom就派上了用场:不管更改多少次,多少个地方的结构,都会映射到新的虚拟dom结构中去,然后进行diff的对比,最终渲染成真实的dom,在这一次render中只会操作一次真实的dom结构,所以只会造成一次重排。同时,采用JS对象去模拟DOM结构的好处是,页面的更新完全可以映射到JS对象中去处理,而操作内存中的JS对象速度也会更快。所以才有了虚拟dom的出现,可以看下图虚拟dom工作原理:先根据初始的dom结构,生成一个 旧的虚拟dom:oldVDom;再根据修改后的dom结构,生成 一个新的虚拟dom:newVDom;然后通过diff算法来对比新旧虚拟DOM,从而找出需要替换的节点,然后将其渲染为真实的dom结构;虚拟dom的缺点?看了上述虚拟dom的优点,我们来聊聊使用它的一些代价:首屏加载时间更长由于我们需要根据当前的节点,来生成对应的虚拟dom,我们都知道虚拟dom是一个JS对象,所以在项目初始化的时候去生成对应的虚拟节点也是一笔时间上的开销;因此项目的首次加载可能耗费更多时间极端场景下性能不是最优解栗子:如果当前页面的节点基本全都改变了,那我们去做了一次diff的patch过程相当于做了无效操作;二、Diff算法了解了虚拟dom结构之后,我们都清楚了diff的触发时机是在新旧VDom进行对比的时候。tips:既然所有的更改都被映射到了新的VDom上,那么为何不直接将新的VDom渲染成真实的dom呢?answer:如果直接渲染的话,会默认把所有节点都更新一遍,造成不必要的节点更新;而经过了diff的比较后可以精确的找出那些节点需要更新,从而实现按需更新的理念,节省性能;那么Diff算法的比较规则有哪些呢?同层比较为什么要同层比较?如果不同层比较的话,全部的对比完一整个dom结构,时间复杂度是 O(n^3) ; 时间成本太大了;所以改用同层比较这样的方法去牺牲了精度而提高了时间效率。可以看到图中每一层的节点,都是同层在进行对比,这样的好处就是,不会每一层的对比都是相对独立的,不会影响到下一层的对比;同时同层对比的时间复杂度也是 O(n);同时也是遵循一个深度优先的原则;diff的过程是一个深度优先遍历节点,然后将该节点与newVDom中的同层节点进行对比,如果有差异,则记录该节点到JS对象中。在同层对比的过程中有这样几种情况: ppp111222333div//1.节点类型发生了改变 ppp //2.节点类型一样,属性发生变化111222//3.节点被删除//333//4.新增节点444//4.文本变化属性变化1. 节点类型变了节点p标签 变成了h3标签,此时diff的过程中p节点会被直接销毁,然后挂载新的节点 h3,同时p标签的子节点也会被全部销毁;虽然可能造成一些不必要的销毁,但是为了实现同层比较的方法节省时间成本只能这样做咯;同时这样也告诫我们在写代码的时候,可以规避一些不必要的父节点的类型替换,比如将p标签换成了div等。2. 节点类型一样,属性或者属性值发生变化此时不会触发节点的卸载和挂载,只会触发当前节点的更新3. 删除/新增/改变 节点这时候就需要找出这些节点并在newVDom中进行插入/删除,这个过程请看下面vue和react是如何利用key值来处理的吧!4. 文本变化只会触发文本的改变三、vue中的diff算法在了解了虚拟dom和diff算法的相关内容后,我们来看看各大框架中是如何做处理的吧!vue2--双端比较这里你需要提前了解vue2内部的响应式原理是如何运作的,推荐文章:vue2的响应式原理[1]。那么,当触发了vue的数据的响应式后,其内部的一系列变化又是如何呢?首先,数据改变会触发 setter,然后调用 Dep.notify(), 并且通过Dep.notify去通知所有订阅者Watcher, 订阅者们就会调用patch方法, 给真实 DOM 打补丁,更新相应的视图。接下来我们来分析几个核心函数吧:patch ()diff的入口函数;functionpatch(oldVnode,newVnode){//传入新、旧节点//比较是否为一个类型的节点if(sameVnode(oldVnode,newVnode)){//是:继续进行深层比较patchVnode(oldVnode,newVnode)}else{//否constoldEl=oldVnode.el//旧虚拟节点的真实DOM节点constparentEle=api.parentNode(oldEl)//获取父节点createEle(newVnode)//创建新虚拟节点对应的真实DOM节点if(parentEle!==null){api.insertBefore(parentEle,newVnode.el,api.nextSibling(oldEl))//将新元素添加进父元素api.removeChild(parentEle,oldVnode.el)//移除以前的旧元素节点//设置null,释放内存oldVnode=null}}returnnewVnode}sameVNode ()主要用来判断两个节点是否完全相同,那么满足什么条件才能判断两个节点完全相同呢?functionsameVnode(oldVnode,newVnode){return(oldVnode.key===newVnode.key&//key值是否一样oldVnode.tagName===newVnode.tagName&//标签名是否一样oldVnode.isComment===newVnode.isComment&//是否都为注释节点isDef(oldVnode.data)===isDef(newVnode.data)&//是否都定义了datasameInputType(oldVnode,newVnode)//当标签为input时,type必须是否相同)}patchVNode ()此阶段我们已经找到了需要去对比的节点,那么该方法主要做了什么呢?拿到真实的dom节点el(即oldVnode)判断当前newVnode和oldVnode是否指向同一个对象,如果是则直接return如果是文本节点,且文本有变化,则直接调用api 将文本替换;若文本没有变化,则继续对比新旧节点的子节点children如果oldVnode有子节点而newVnode没有,则删除el的子节点如果oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化之后添加到el如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要---diff的核心functionpatchVnode(oldVnode,newVnode){constel=newVnode.el=oldVnode.el//获取真实DOM对象//获取新旧虚拟节点的子节点数组constoldCh=oldVnode.children,newCh=newVnode.children//如果新旧虚拟节点是同一个对象,则终止if(oldVnode===newVnode)return//如果新旧虚拟节点是文本节点,且文本不一样if(oldVnode.text!==null&newVnode.text!==null&oldVnode.text!==newVnode.text){//则直接将真实DOM中文本更新为新虚拟节点的文本api.setTextContent(el,newVnode.text)}else{if(oldCh&newCh&oldCh!==newCh){//新旧虚拟节点都有子节点,且子节点不一样//对比子节点,并更新/* diff核心!!*/updateChildren(el,oldCh,newCh)}elseif(newCh){//新虚拟节点有子节点,旧虚拟节点没有//创建新虚拟节点的子节点,并更新到真实DOM上去createEle(newVnode)}elseif(oldCh){//旧虚拟节点有子节点,新虚拟节点没有//直接删除真实DOM里对应的子节点api.removeChild(el)}}}updateChildren ()此方法就是diff算法的核心部分,当发现新旧虚拟节点的的子节点都存在时候,我们就需要通过一些方法来判断哪些节点是需要移动的,哪些节点是可以直接复用的,来提高我们整个diff的效率;下面就通过一些图解来讲解吧:主要是通过 首尾指针法 : 通过在新旧子节点的首尾定义四个指针,然后不断的对比找到可复用的节点,同时判断需要移动的节点。abcb修改数据后//a,b,c,d->d,b,a,cdbac1、理想情况下经过四次比较可以找到替换的节点,可以看到图中第四次找到了可替换的节点;可以看到在 oldCh 和 newCh 的首尾定义了四个指针:1、oldS和 newS使用sameVnode方法进行比较,sameVnode(oldS, newS) ;如果相同,则 oldS++,newS++2、oldE 和newE使用sameVnode方法进行比较,sameVnode(oldE, newE);如果相同,则 oldE--,newS --3、oldS和 newE使用sameVnode方法进行比较,sameVnode(oldS, newE);如果相同,则 oldS ++,newS --4、oldE 和 newS使用sameVnode方法进行比较,sameVnode(oldE, newS);如果相同,则 oldE --,newS ++这是一个不断向内部收缩的过程,直到对比完所有的节点;functionvue2Diff(prevChildren,nextChildren,parent){//在新旧首尾,分别定义四个指针letoldStartIndex=0,oldEndIndex=prevChildren.length-1newStartIndex=0,newEndIndex=nextChildren.length-1;letoldStartNode=prevChildren[oldStartIndex],oldEndNode=prevChildren[oldEndIndex],newStartNode=nextChildren[newStartIndex],newEndNode=nextChildren[newEndIndex];//不断向内收缩while(oldStartIndex index表,然后用新vnode的key去找出在旧节点中可以复用的位置;可以看下图的处理。拿新列表的第一个节点去旧列表中找与其key相同的节点。那么我们就以 newCh 的首节点的key值,去到 oldCh 的 key - index 的映射表中,去根据key值找到对应的节点,同时将 b 节点移动到首部去,因为在新列表中 b 就属于首部,所以在oldCh中也需要移动到首部 ;同时,还需要将 oldCh 中的 b 节点设为 undefined , 因为已经复用过了,就可以跳过比较了。这个非理想的状态下的对比时间复杂度为 O(n^2):functionvue2Diff(prevChildren,nextChildren,parent){//...while(oldStartIndexchild.key===newKey);if(oldIndex>-1){letoldNode=prevChildren[oldIndex];patch(oldNode,newStartNode,parent)parent.insertBefore(oldNode.el,oldStartNode.el)//复用后,设置为undefinedprevChildren[oldIndex]=undefined}newStartNode=nextChildren[++newStartIndex]}}}vue3--最长递增子序列那么相比vue2中的双端对比,在vue3中的diff算法,又做了哪些优化呢?以下面的例子来看:1. 从头对比找到有相同的节点 patch ,发现不同,立即跳出。2. 如果第一步没有patch完,立即,从后往前开始patch ,如果发现不同立即跳出循环。3. 如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理(这种情况说明已经patch完相同的vnode)。4. 对于老的节点大于新的节点的情况 , 对于超出的节点全部卸载(这种情况说明已经patch完相同的vnode)。5. 不确定的元素(这种情况说明没有patch完相同的vnode) 与 3 ,4对立关系。前面的逻辑跟vue2还是比较像,逐渐向中间收缩,那么关键点就在判断哪些节点是需要变动的。在经历上述操作后,会出现以下节点需要判断(即图中圈起来的节点):首先,我们以新节点的数量创建一个 source 数组,并用 -1 填满;这个source数组就是用来做新旧节点的对应关系的,我们将新节点在旧列表的位置存储在该数组中,我们再根据source计算出它的最长递增子序列用于移动DOM节点。其次,我们先建立一个对象存储当前新列表中的节点与index的关系:constnewVNodeMap={c:'1',d:'2',b:'3',i:'4'}然后再去旧列表中去找相同的节点,并记录其index的位置。在找节点时,如果旧节点在新列表中没有的话,直接删除就好。除此之外,我们还需要一个数量表示记录我们已经patch过的节点,如果数量已经与新列表剩余的节点数量一样,那么剩下的旧节点我们就直接删除了就可以了。Dom如何移动?首先,我们需要定义一个Lis数组来存储source中的最长连续递增子序列的下标:- 然后从后往前遍历 source 数组;这个过程中会发生三种情况:当前数值为 -1 ,也就说明该节点是新增的,我们直接将其插入到队尾就好了,同时 i--。当前的索引和 Lis 中的值一致,即 i == Lis[j] ,同时 i --, j --。当前的索引不是 Lis 中的值,那么该节点就需要进行移动,我们只需要将该节点插入到队尾就可以了,因为队尾是排好序的。tips:没看懂这三种情况?不要慌:我们来一步一步拆解:首先,i = 3,即上图中,值为 -1 为第一种情况,节点需要新增,i--;i = 2,索引为 2 != Lis[j] ****为第三种情况,节点需要移动,直接在旧列表中,将b节点插入到尾部位置,i --i = 1,此时索引 i == Lis[j] 为第二种情况,我们的节点不需要移动;i = 0,此时索引 i == Lis[j] 为第二种情况,我们的节点也不需要移动;至此 vue3的diff的对比过程就已经完成了,相比于2中的首尾指针法,在这种非理想情况下的节点对比采用了最长递增子序列的算法思想来做处理;这三种情况对应在源码中 :functionvue3Diff(prevChildren,nextChildren,parent){//...if(move){//需要移动constseq=lis(source);//[0,1]letj=seq.length-1;//最长子序列的指针//从后向前遍历for(leti = nextLeft - 1;i >=0; i--){letpos=nextStart+i,//对应新列表的indexnextNode=nextChildren[pos],//找到vnodenextPos=pos+1,//下一个节点的位置,用于移动DOMrefNode=nextPos>=nextChildren.lengthnull:nextChildren[nextPos].el,//DOM节点cur=source;//当前source的值,用来判断节点是否需要移动if(cur===-1){//情况1,该节点是全新节点mount(nextNode,parent,refNode)}elseif(cur===seq[j]){//情况2,是递增子序列,该节点不需要移动//让j指向下一个j--}else{//情况3,不是递增子序列,该节点需要移动parent.insetBefore(nextNode.el,refNode)}}}else{//不需要移动for(leti = nextLeft - 1;i >=0; i--){letcur=source;//当前source的值,用来判断节点是否需要移动if(cur===-1){letpos=nextStart+i,//对应新列表的indexnextNode=nextChildren[pos],//找到vnodenextPos=pos+1,//下一个节点的位置,用于移动DOMrefNode=nextPos>=nextChildren.lengthnull:nextChildren[nextPos].el,//DOM节点mount(nextNode,parent,refNode)}}}你可能会问,你这边递增的子序列需要连续吗,那么这里给你将例子稍微变动一下:这时候你会发现连续递增的节点是 c, d, e 他们不是紧密连续的,但是在整个list中却是保持index递增的,也不需要移动。思考题参考上面的图解,结合源码,看看下面例子中的虚拟dom节点是怎么移动的。时间复杂度的优化这里我们只需要找出source中的最长连续递增子序列 就ok了:最长连续递增子序列直接放一道leetcode吧:最长递增子序列[2]举个例子:[10,5,6,7,4,1,2,8,9]那么在该此例子中,连续递增的子序列是 [5,6,7,8,9], 所以返回的个数是5;可以参考该算法的基础实现:constarr=[10,5,6,7,4,1,2,8,9]functionlis(arr){letlen=arr.length,dp=newArray(len).fill(1);//用于保存长度//i=0=>O(n^2);i!=0=>O(nlogn)for(leti=len-1;i>=0;i--){letcur=arrfor(letj=i+1;janew//新增bacbc按理来说,我们应该会复用里面的 a、b、c 三个节点对吧;看这个例子,我们直接unshift() 插入列表一个新元素,这时候index发生了变化!!即key也会发生变化!!但是我们知道:按照Vue中的比较思路,这样的话,我们就无法复用哪些本来可以复用的节点,导致该节点被重新渲染一次,造成vue组件内一些列的更新,如果列表一旦很大,开销成本巨大!只要此时你的列表是一个动态的列表:而且使用了index作为key值,当你新增或者删除列表时候,key的排序总是以0、1、2、3...去排序的,而这样也会导致列表元素的key值在不断变化;导致 Vue 不能准确的找到可复用的节点,而是去直接做了patch操作,造成很多额外的工作。解决办法--唯一值这也是我们为什么要用一个唯一的值去作为列表的key值的原因了!所以我们一般可以用id/唯一值作为key,这是规范问题,所以大家以后再看到项目中有index作为key的情况,请让他去学习diff算法吧哈哈哈!所以在学习了diff之后要警示我们:1、key值要选择一个唯一值,通常用id来做key2、不要做一些无谓的dom结构修改或者跨层级去操作一些dom总结我们学习了虚拟dom和diff算法的基本概念,了解了为什么要存在虚拟dom和diff算法;同时我们也了解了vue2和vue3中关于diff算法的处理过程,这可以更好的帮助我们理解vue的更新机制原理,同时也了解到了为什么diff可以如此高效的提升性能;最后,我一直认为学习原理是为了让我们更好的和更高效的去运用框架,可以避免一些不必要的bug问题,同时也是提升自我的一种方式途径!另外,特别感谢本文审校的同学(人名不分先后):米泽,李世奇,胡元港参考资料[1]vue2的响应式原理: https://juejin.cn/post/6844903981957791757[2]最长递增子序列: https://leetcode-cn.com/problems/longest-increasing-subsequence/[3]vue3 diff 源码: https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts[4]为什么不能用index作为key值-diff算法详解: https://juejin.cn/post/6844904113587634184#heading-13[5]vue的diff算法详解: https://juejin.cn/post/6844903607913938951[6]react、vue2和vue3---diff算法: https://juejin.cn/post/6919376064833667080#heading-1
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-14 03:48 , Processed in 0.534383 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表