对于真实dom和虚拟dom的个人理解:可以被浏览器直接渲染出来的节点称作是真实dom,不能被浏览器直接渲染,存在于内存中,具有描述节点属性的节点称为虚拟dom;在js中可以通过createElemen接口创建一个标签对象,然后通过为这个对象添加属性的方式来描述这个节点,这个节点就可以说是一个虚拟的dom。说白了虚拟dom就是一个存在于内存中的js对象
我们在修改页面dom数据的时候,改变了dom的样式或位置,就会引起浏览器的重绘乃至回流(重排),不管是重绘还是重排浏览器都需要重新渲染整个dom树,频繁的修改dom会大大消耗浏览器的性能。那么为了减轻浏览器的压力,提升用户体验,我们就要尽量少对页面真实dom的修改,虚拟dom也就应运而生。试想一下,如果有一个存在于内存中的虚拟dom,在我们需要修改dom数据的时候,只修改这个虚拟dom的属性,是不是就不会造成浏览器的回流和重绘,这就是减少对真实dom操作的一个突破口
使用虚拟dom的优点很明显:主要是为了减少页面的回流和重绘,提升浏览器渲染性能和用户体验;另外因为脱离了真实dom,拥有跨平台能力;如果非要说缺点:那就是在内存中多了一份dom树,在没有大量、频繁修改dom元素数据的页面下,使用虚拟dom性能反而会下降
我们可以试着自己写一个简单的虚拟dom,当然vue中有专门的createElm函数,如果感兴趣可以去了解一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
//虚拟dom对象 var vdomdata = { tag:'div', attrs:{ id:'myvdom', style:{ width:'500px', height:'500px', backgroundColor:'red' } }, text:'我是虚拟dom', children:[ { tag:'span', text:'我是虚拟dom的child', attrs:{ className:'vomchildren' }, children:[ { tag:'a', attrs:{ href:'https://www.baidu.com/' }, text:'我是虚拟dom的子节点的子节点,点击我进入百度' } ] }, { tag:'p', text:'我是个屁' } ] } //创建虚拟dom方法 function createElement({tag,attrs,text,children}){//es6参数对象解构 //创建目标标签 var cVdom = document.createElement(tag) //添加标签属性及样式 for(let key in attrs){ if (typeof attrs[key] !== 'string') { for(let stylekey in attrs[key]){ cVdom[key][stylekey] = attrs[key][stylekey] } }else{ cVdom[key] = attrs[key] } } //如果有text添加text文本 if (text) { var cText = document.createTextNode(text) cVdom.appendChild(cText) } //如果有子节点,创建子节点并添加给父元素 if (children&&children.length>0) { var parEle = cVdom//拿到父节点 children.forEach(item => {//循环每一个子节点 var cChildVdom = createElement(item)//执行创建虚拟dom方法 parEle.appendChild(cChildVdom)//在父节点添加节子节点 }) return parEle//有子节点 }else{ return cVdom//没有子节点返回创建的这个节点 } } //打印虚拟dom console.log(createElement(vdomdata)) |
有了虚拟dom之后,我们就可以将对真实dom的操作,转化为对虚拟dom进行操作,然后再映射给真实dom。这样就相应的较少了浏览器重绘的次数。那么我们又如何知道需要修改的地方呢?在页面初始化时候,VUE就会将真实dom转换成一份虚拟dom,存在js内存中;当我们改变了某个dom的数据时,就会生成一个新的newVnode;VUE将会对比这个dom对应的Vnode和新生成的newVnode,来决定那些节点复用哪些节点更新;这种对比新旧节点寻找差异的方法就是今天要学习的diff算法(这里说的diff算法是VUE中优化后的算法)
diff算法有两个特点:1.对比对象遵循同级比较,只会对新旧节点的同级节点进行比较,不会跨级比较;2.对比的方式遵循两端开始向中间聚拢
VUE的diff算法是通过比较Vnode和个newVnode两个虚拟节点找出存在差异,然后根据具体情况复用或更新原真实dom;其实vue的diff最核心的就是patch、patchVnode和updateChildren这三个函数,搞清楚了这三个函数都做了什么也就算是了解了VUE的diff算法核心
需要注意的是:VUE的diff算法是边比较,边更新;而不是比较完成之后,一次性反映给真实dom进行修改;至于为什么,个人理解:1.现代浏览器已经对操作dom做了优化;会把需要处理的dom放进一个队列,然后定时集中处理。VUE再进行归纳后集中处理似乎有点重复;2.边比较边更新有利于提升VUE的性能;如果是比较完成之后再一次性修改,那VUE不仅需要记录修改的内容,还要记录修改位置的父子关系,这样一来算法的复杂度就上来了,也更消耗VUE的性能
在比较两个节点存在的差异的时候,首先会通过patch函数先比较这两个节点(主要是为了比较新旧节点是不是相同节点),然后根据不同情况做不同处理:
以下是patch的部分源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
//sameVnode函数;主要是key值和标签必须相同 function sameVnode (a, b) { return ( a.key === b.key/*key值相同*/ && ( ( a.tag === b.tag/*标签名相同*/ && a.isComment === b.isComment/*注释相同*/ && isDef(a.data) === isDef(b.data)/*同时定义或者未定义data*/ && sameInputType(a, b)/*如果是input标签,type值必须相同*/ ) || ( isTrue(a.isAsyncPlaceholder) && a.asyncFactory === b.asyncFactory && isUndef(b.asyncFactory.error) ) ) ) } //patch函数 function patch (oldVnode, vnode, hydrating, removeOnly) { /*展示部分代码*/ var isInitialPatch = false; var insertedVnodeQueue = []; if (isUndef(oldVnode)) {//(1).当没有旧节点时(如页面开始初始化的时候) isInitialPatch = true; createElm(vnode, insertedVnodeQueue);//直接创建新节点 } else {//有旧节点 var isRealElement = isDef(oldVnode.nodeType);//使用dom.nodeType判断节点,是真实节点还是虚拟节点 if (!isRealElement && sameVnode(oldVnode, vnode)) {//(2).当新旧节点相同时 patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);//比较节点的子节点情况 } else {//(3).当新旧节点不同时 //直接创建新的节点 var oldElm = oldVnode.elm; var parentElm = nodeOps.parentNode(oldElm); createElm( vnode, insertedVnodeQueue, oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ); //然后销毁旧节点 if (isDef(parentElm)) { removeVnodes([oldVnode], 0, 0); } } } return vnode.elm } |
通过patch比较后,当Vnode和oldVnode是同一个节点的时候,就需要通过patchVnode函数(主要是判断子节点情况),根据子节点的不同情况做不同处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
function patchVnode (oldVnode,vnode,insertedVnodeQueue,ownerArray,index,removeOnly) { /*展示部分代码*/ if (oldVnode === vnode) {//如果新旧节点一样,直接返回,不做任何修改 return } var elm = vnode.elm = oldVnode.elm;//在对旧节点修改的时候实际上也得到了新的vnode var oldCh = oldVnode.children; var ch = vnode.children; if (isUndef(vnode.text)) {//当不是文本节点的时候 if (isDef(oldCh) && isDef(ch)) {//(1).当新旧节点都存在子节点的时候 if (oldCh !== ch) {//当新旧节点的子节点不是同一个对象的时候 updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);//比较子节点 } } else if (isDef(ch)) {//(2).当只有新节点存在子节点的时候 if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }//将旧节点的文本节点置为空 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);//在旧节点中添加子节点 } else if (isDef(oldCh)) {//(3).当只有旧节点存在子节点的时候 removeVnodes(oldCh, 0, oldCh.length - 1);//删除旧节点的子节点 } else if (isDef(oldVnode.text)) {//当只有旧节点存在文本节点的时候 nodeOps.setTextContent(elm, '');//将父节点的文本节点置为空 } } else if (oldVnode.text !== vnode.text) {//(4).当子节点是文本节点的时候,且旧节点文本与新节点文本不一样的时候 nodeOps.setTextContent(elm, vnode.text);//替换为新节点文本 } } |
判断了节点的子节点情况后,如果Vnode和oldVnode都存在子节点,那就使用updateChildren来比较新旧节点的子节点,这也是diff算法的核心,那么diff算法是如何智能复用相同节点,替换不同节点呢:
vue的diff是通过循环比较新旧节点的首尾节点是否为同一节点的来判断节点是否复用的。vue会把旧节点(Vnode)开始比较的节点叫做oldStartVnode,最后一个比较的节点叫做oldEndVnode,新节点(newVnode)也一样,开始比较的节点叫newStartVnode,最后比较的节点叫做newEndVnode;通过标记新旧节点的头尾节点的比较有四种比较方式:旧头 == 新头,旧尾 == 新尾,旧头 == 新尾,旧尾 == 新头; 通过四种比较得出差异,根据差异修改原dom
为了更清楚的了解节点的移动情况,分别对这四种比较方式,通过画图的方式来帮助理解一下
如果通过以上四种方式都没匹配到相同的节点,我们就需要判断整个旧节点的子节点有没有该节点了。如果节点有key值,那就通过对比Vnode子节点生成的key值对照表,来找到这个相同的节点。如果没有key值旧只能遍历旧节点的所有子节点然后比较有没有相同节点了,这样做是比较消耗性能的。然后vue会根据有没有找到相同节点做不同处理:
以上步骤四种比较和判断所有剩余节点是否有该节点是一个循环,根据具体情况,移动真实dom对应的节点,并通过oldStartIdx...自增或自减,移动比较的节点位置(oldStart...)。当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时就结束循环,并判断如果旧节点节点多余就删除多余节点,如果是新节点多出就在原dom上添加这些节点。以下是部分源码可做参考:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
//将key值生成一个映射表 function createKeyToOldIdx (children, beginIdx, endIdx) { var i, key; var map = {}; for (i = beginIdx; i <= endIdx; ++i) { key = children[i].key; if (isDef(key)) { map[key] = i; } } return map } //updateChildren function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { /*展示部分代码*/ var oldStartIdx = 0; var newStartIdx = 0; var oldEndIdx = oldCh.length - 1; var oldStartVnode = oldCh[0]; var oldEndVnode = oldCh[oldEndIdx]; var newEndIdx = newCh.length - 1; var newStartVnode = newCh[0]; var newEndVnode = newCh[newEndIdx]; var oldKeyToIdx, idxInOld, vnodeToMove, refElm; //当oldStartIdx和newStartIdx始终小于等于oldEndIdx和newEndIdx的时候一直循环更新 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) {//当找不到oldStartVnode时 oldStartVnode = oldCh[++oldStartIdx];//向后查找子节点 } else if (isUndef(oldEndVnode)) {//当找不到oldEndVnode的时候 oldEndVnode = oldCh[--oldEndIdx];//向前查找子节点 } else if (sameVnode(oldStartVnode, newStartVnode)) {//1.当 旧头 == 新头 的时候 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);//使用patchVnode比较该节点 //新头和旧头同时向后移 oldStartVnode = oldCh[++oldStartIdx]; newStartVnode = newCh[++newStartIdx]; } else if (sameVnode(oldEndVnode, newEndVnode)) {//2.当 旧尾 == 新尾 的时候 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx); //新尾和旧尾同时向前移 oldEndVnode = oldCh[--oldEndIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldStartVnode, newEndVnode)) {//3.当 旧头 == 新尾 的时候 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx); canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));//将旧头移动到旧尾节点后边 //旧头后移,新尾前移 oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldEndVnode, newStartVnode)) {//4.当 旧尾 == 新头 的时候 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx); canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);//将旧尾移动到旧头前面 //旧尾前移,新头后移 oldEndVnode = oldCh[--oldEndIdx]; newStartVnode = newCh[++newStartIdx]; } else {//四种方式无法匹配到相同节点时 if (isUndef(oldKeyToIdx)) {//没有key值映射表的时候,根据旧节点生成一个映射表 oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); } //判断是否有key值,有key值直接去key值映射表里查找,没有key值去旧节点里查找该元素 idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx); if (isUndef(idxInOld)) {//如果没有找到该元素则直接创建这个新节点 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx); } else {//如果找到该节点 vnodeToMove = oldCh[idxInOld]; if (sameVnode(vnodeToMove, newStartVnode)) {//该节点和newStar是同一个节点时 patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx); oldCh[idxInOld] = undefined;//将原dom设为undefined,防止缺省造成塌方 canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);//将该节点插入到oldStar前面 } else {//该节点和newStar不是同一个节点时(如key值相同但是其他不同) createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);//直接创建新节点 } } newStartVnode = newCh[++newStartIdx];//该节点匹配完成,继续下一个节点 } } if (oldStartIdx > oldEndIdx) {//当oldStar大于oldEnd时,将新节点多出的子节点添加到旧节点中 refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm; addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } else if (newStartIdx > newEndIdx) {//当newStar大于newEnd时,删除旧节点多余的子节点 removeVnodes(oldCh, oldStartIdx, oldEndIdx); } } |
到这里VUE.2X的diff原理基本算是梳理完了;使用Xmind重新梳理了一个总体流程,用来帮助自己理解、复习diff的整个过程
VUE中key值的作用可以从三个方面来说:节省性能方面、防止出错方面还有强制更新方面
由上述diff的过程可以看出,如果节点设置有key值,那么我们在匹配相同节点进行复用的时候,就可以通过对比生成的key值哈希对照表,来找到对应的节点进行复用。如果没有key值,VUE就会拿当前节点和旧节点的子节点一一对比,来找有没有可以复用的相同节点,这会大大的消耗性能的。所以有key值的时候,在比较节点是否复用的时候,可以节省性能
但是官方文档又说:"不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。而使用 key 时,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素";也就是说不使用key的时候VUE会尽最大可能复用已有的元素,而当有key值的时候,因为多了一个key值是否相同的条件,就不能最大限度的复用已有元素。节点复用的性能当然比重建节点性能要高,如此看来有key值得时候反而降低了性能,但是仅凭一个tag就决定复用标签的话,很容易就会出错;
总体来说就是不用key会最大限度复用节点,性能最好,但是一不小心就会出错;用了key可以节约在比较是否复用上浪费的性能,而且也能更加准确的判断节点是否复用
VUE中因为没有设置key值,而出的错,基本上都是因为VUE的'就地复用原则';那么到底什么是'就地复用原则'呢,在diff代码中看就是通过sameVnode函数决定节点复用的
'就地复用'字面意思就是,现场重复利用不再进行更新;官方给出的解释是:Vue在使用v-for渲染的列表时。如果数据项的顺序被改变,Vue将不会移动DOM元素来匹配数据项的顺序,而是就地更新每个元素,并且确保它们在每个索引位置正确渲染。 这种方式只适用于列表渲染不依赖子组件状态,或临时DOM状态变化。单看官方给出的解释,还是很难搞清楚什么是'就地复用策略';下面根据一个例子好好理解一下
上图可以看出,在采用就地复用的时候,虽然我们是将第一项移动到第二项,但是input里的值,似乎并没有跟着移动,而是留在了原处,成了原第二项的input的值;之所以会出现这种"错误",就是因为VUE算法中的'就地复用策略';我们可以通过结合这个例子,还原一下VUE的diff过程(以例子中的第一项为例):
当我们改变了列表的数据的时候,VUE生成新的节点和旧节点进行path:发现是新旧节点是相同节点(问题就出在这里);转而通过patchVnode比较该节点的子节点情况:发现新旧节点都存在子节点;然后又通过updateChildren来比较每个子节点:通过比较新旧节点的子节点发现input和button都是相同节点,不做任何处理保留下来,name节点发生改变,先将新name插入到对应位置,对比循环结束删除多余的旧name,此时'乐乐'就变成了'利利';所以这个节点的子节点就是name由'乐乐'变成'利利',但是input和button并没有改变还是原来的节点。
从以上的过程来看没有什么问题,但是执行下来就出错了,就是因为在比较新旧节点的时候sameVnode只比较了tag(标签名)和其他一些不太要紧的项,导致VUE认为新旧节点是相同节点,继续向下比较,以至于input等原节点被重复使用没有更新,最终出错;相反,在不采用就地复用的时候(其实就是为列表项添加了key值),新旧节点比较时,发现新节点的key值和旧节点key值不同,所以就认定新旧节点不是同一节点,然后就生成新节点替代旧节点,旧节点被删除,最终整个节点被更新;以下是上例的代码可做参考:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
<div id="app"> <!-- 采用就地复用的时候 --> <h5>采用就地复用的时候</h5> <div v-for='(item,index) in meg'> {{item.name}} <input type="text"/> <button :id='index' @click='movedown(index)' v-if='index < (meg.length - 1)'>下移</button> </div> <!-- 不采用就地复用的时候 (为每个元素添加key值)--> <h5>不采用就地复用的时候</h5> <div v-for='(item,index) in meg' :key='item.id'> {{item.name}} <input type="text"/> <button @click='movedown(index)' v-if='index < (meg.length - 1)'>下移</button> </div> </div> <script> new Vue({ el:'#app', data:{ meg:[ {id:'1',name:'乐乐'}, {id:'2',name:'利利'}, {id:'3',name:'蕾蕾'} ] }, methods:{ movedown(index){//注意在修改数组的时候除了push、pop、shift、unshift、splice、sort、rever这些方法外都不能触发VUE的响应 var clonemeg = this.meg.slice() var clickdata = this.meg[index] clonemeg[index] = clonemeg[index + 1] clonemeg[index + 1] = clickdata this.meg = clonemeg } } }) </script> |
所以使用v-for渲染列表,列表项发生改变时就容易出错,所以官方建议使用v-for渲染列表时,必须写上key值;key值也不是随便设置的,key值的设置需具备唯一标识性,并不建议使用列表项的index作为key值;在使用index作为key值时,改变数据生成新节点时,新节点的key值仍是列表项的第几项,和旧节点的key值是一样的,这样一来新旧节点又是相同节点,VUE又开始复用可以复用的节点了,就跟没有设置key值是一样的;当然使用index作为key值,不排除在某些场景下不会出错,而且性能好也方便,但是为了程序的持久性和安全性考虑,还是不建议使用
当节点设置key值,改变节点数据的时候,VUE不再向下进行patch比较,而是直接更新节点;利用这么一个特新,我们可以做到让节点或者组件强制更新;例如官方给出的一个例子:
1 2 3 |
<transition> <span :key='text'>{{text}}</span> </transition> |
上例中我们将text绑定为key值,当改变text值时,就会触发span标签强制更新,进而触发transition动画,动画就动起来了;
在发生需要我们手动更新的时候,一般是数据并不在响应系统中,或者数据改变之后节点(组件)没有更新;对于第一种情况:我们可以事先把数据名写在data中,以便VUE将数据添加到响应系统中,或者使用vm.$set(obj,key,value)方法将数据添加到响应系统;对于第二种情况我们可以:1、使用v-if渲染满足条件,执行渲染(并不推荐,浪费性能);2、使用vue提供的forceUpdate(较为推荐);3、组件设置key值,当改变key值得时候就会自动触发更新,key值不变则不更新(最好的办法)
到这里VUE的diff算法和key值的作用算是搞清楚了,但是VUE3.0已经发布了,相对于VUE2.X算法上也做了很大的优化和更新,那么成热打铁来了解一下VUE3.0更新后的diff算法吧