站长资讯网
最全最丰富的资讯网站

深入理解vue2中的VNode和diff算法

深入理解vue2中的VNode和diff算法

node.js极速入门课程:进入学习

虚拟dom和diff算法是vue学习过程中的一个难点,也是面试中必须掌握的一个知识点。这两者相辅相成,是vue框架的核心。今天我们再来总结下vue2中的虚拟dom 和 diff算法。(学习视频分享:vue视频教程)

什么是 VNode

我们知道,render function 会被转化成 VNode。VNode 其实就是一棵以 JavaScript 对象作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。

比如有如下template

<template>   <span class="demo" v-show="isShow"> This is a span. </span>  </template>
登录后复制

它换成 VNode 以后大概就是下面这个样子

{   tag: "span",   data: {     /* 指令集合数组 */     directives: [       {         /* v-show指令 */         rawName: "v-show",         expression: "isShow",         name: "show",         value: true,       },     ],     /* 静态class */     staticClass: "demo",   },   text: undefined,   children: [     /* 子节点是一个文本VNode节点 */     {       tag: undefined,       data: undefined,       text: "This is a span.",       children: undefined,     },   ], };
登录后复制

总的来说,VNode 就是一个 JavaScript 对象。这个JavaScript 对象能完整地表示出真实DOM。

为什么vue要使用 VNode

笔者认为有两点原因

  • 由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。

  • 减少操作DOM,任何页面的变化,都只使用VNode进行操作对比,只需要在最后一次进行挂载更新DOM,避免了频繁操作DOM,减少了浏览器的回流和重绘从而提高页面性能。

diff算法

下面我们来看看组件更新所涉及到的diff算法。

前面我们讲依赖收集的时候有说到,渲染watcher传递给Watcher的get方法其实是updateComponent方法。

updateComponent = () => {   vm._update(vm._render(), hydrating) }  new Watcher(vm, updateComponent, noop, {   before () {     if (vm._isMounted) {       callHook(vm, 'beforeUpdate')     }   } }, true /* isRenderWatcher */)
登录后复制

所以组件在响应式数据发生变化的时候会再次触发该方法,接下来我们来详细分析一下updateComponent里面的_update方法。

_update

在_update方法中做了初始渲染和更新的区分,虽然都是调用__patch__方法,但是传递的参数不一样。

// src/core/instance/lifecycle.js  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {   const vm: Component = this   const prevEl = vm.$el   const prevVnode = vm._vnode   vm._vnode = vnode   // 初次渲染没有 prevVnode,组件更新才会有   if (!prevVnode) {     // 初次渲染     vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)   } else {     // 更新     vm.$el = vm.__patch__(prevVnode, vnode)   }      // ... }
登录后复制

下面我们再来看看__patch__方法

__patch__

patch方法接收四个参数,由于初始渲染的时候oldVnode为vm.$el是null,所以初始渲染是没有oldVnode。

// src/core/vdom/patch.js  return function patch (oldVnode, vnode, hydrating, removeOnly) {   // 新节点不存在,只有oldVnode就直接销毁,然后返回   if (isUndef(vnode)) {     if (isDef(oldVnode)) invokeDestroyHook(oldVnode)     return   }    let isInitialPatch = false   const insertedVnodeQueue = []   // 没有老节点,直接创建,也就是初始渲染   if (isUndef(oldVnode)) {     isInitialPatch = true     createElm(vnode, insertedVnodeQueue)   } else {     const isRealElement = isDef(oldVnode.nodeType)     // 不是真实dom,并且是相同节点走patch     if (!isRealElement && sameVnode(oldVnode, vnode)) {       // 这里才会涉及到diff算法       patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)     } else {       if (isRealElement) {         // ...       }        // replacing existing element       const oldElm = oldVnode.elm       const parentElm = nodeOps.parentNode(oldElm)        // 1.创建一个新节点       createElm(         vnode,         insertedVnodeQueue,         // extremely rare edge case: do not insert if old element is in a         // leaving transition. Only happens when combining transition +         // keep-alive + HOCs. (#4590)         oldElm._leaveCb ? null : parentElm,         nodeOps.nextSibling(oldElm)       )        // 2.更新父节点占位符       if (isDef(vnode.parent)) {         let ancestor = vnode.parent         const patchable = isPatchable(vnode)         while (ancestor) {           for (let i = 0; i < cbs.destroy.length; ++i) {             cbs.destroy[i](ancestor)           }           ancestor.elm = vnode.elm           if (patchable) {             for (let i = 0; i < cbs.create.length; ++i) {               cbs.create[i](emptyNode, ancestor)             }                          const insert = ancestor.data.hook.insert             if (insert.merged) {               // start at index 1 to avoid re-invoking component mounted hook               for (let i = 1; i < insert.fns.length; i++) {                 insert.fns[i]()               }             }           } else {             registerRef(ancestor)           }           ancestor = ancestor.parent         }       }        // 3.删除老节点       if (isDef(parentElm)) {         removeVnodes([oldVnode], 0, 0)       } else if (isDef(oldVnode.tag)) {         invokeDestroyHook(oldVnode)       }     }   }        //触发插入钩子   invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)   return vnode.elm }
登录后复制

patch方法大概流程如下:

  • 没有新节点只有老节点直接删除老节点。

  • 只有新节点没有老节点直接添加新节点。

  • 既有新节点又有老节点则判断是不是相同节点,相同则进入pathVnode。patchVnode我们后面会重点分析。

  • 既有新节点又有老节点则判断是不是相同节点,不相同则直接删除老节点添加新节点。

我们再来看看它是怎么判断是同一个节点的。

// src/core/vdom/patch.js  function sameVnode (a, b) {   return (     a.key === b.key &&     a.asyncFactory === b.asyncFactory && (       (         a.tag === b.tag &&         a.isComment === b.isComment &&         isDef(a.data) === isDef(b.data) &&         sameInputType(a, b)       ) || (         isTrue(a.isAsyncPlaceholder) &&         isUndef(b.asyncFactory.error)       )     )   ) }  function sameInputType (a, b) {   if (a.tag !== 'input') return true   let i   const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type   const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type   return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB) }
登录后复制

判断两个VNode节点是否是同一个节点,需要同时满足以下条件

  • key相同

  • 都有异步组件工厂函数

  • tag(当前节点的标签名)相同

  • isComment是否同为注释节点

  • 是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型)

  • 当标签是<input>的时候,type必须相同

当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同。这时候这两个VNode则算sameVnode,可以直接进行patchVnode操作。

patchVnode

下面我们再来详细分析下patchVnode方法。

// src/core/vdom/patch.js  function patchVnode (   oldVnode,   vnode,   insertedVnodeQueue,   ownerArray,   index,   removeOnly ) {   // 两个vnode相同则直接返回   if (oldVnode === vnode) {     return   }    if (isDef(vnode.elm) && isDef(ownerArray)) {     // clone reused vnode     vnode = ownerArray[index] = cloneVNode(vnode)   }    const elm = vnode.elm = oldVnode.elm    if (isTrue(oldVnode.isAsyncPlaceholder)) {     if (isDef(vnode.asyncFactory.resolved)) {       hydrate(oldVnode.elm, vnode, insertedVnodeQueue)     } else {       vnode.isAsyncPlaceholder = true     }     return   }    /*     如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),     并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),     那么只需要替换componentInstance即可。   */   if (isTrue(vnode.isStatic) &&     isTrue(oldVnode.isStatic) &&     vnode.key === oldVnode.key &&     (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))   ) {     vnode.componentInstance = oldVnode.componentInstance     return   }    let i   const data = vnode.data   /*调用prepatch钩子*/   if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {     i(oldVnode, vnode)   }    // 获取新老虚拟节点的子节点   const oldCh = oldVnode.children   const ch = vnode.children   if (isDef(data) && isPatchable(vnode)) {     for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)     if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)   }      // 新节点不是文本节点   if (isUndef(vnode.text)) {     /*新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren*/     if (isDef(oldCh) && isDef(ch)) {       if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)     /*如果只有新节点有子节点,先清空elm文本内容,然后为当前DOM节点加入子节点。*/     } else if (isDef(ch)) {       if (process.env.NODE_ENV !== 'production') {         checkDuplicateKeys(ch)       }       if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')       addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)     /*如果只有老节点有子节点,则移除elm所有子节点*/     } else if (isDef(oldCh)) {       removeVnodes(oldCh, 0, oldCh.length - 1)     /*当新老节点都无子节点的时候,因为这个逻辑中新节点text不存在,所以直接去除ele的文本*/     } else if (isDef(oldVnode.text)) {       nodeOps.setTextContent(elm, '')     }   // 新节点是文本节点,如果文本不一样就设置新的文本     } else if (oldVnode.text !== vnode.text) {     nodeOps.setTextContent(elm, vnode.text)   }   /*调用postpatch钩子*/   if (isDef(data)) {     if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)   } }
登录后复制

patchVnode方法大概流程如下:

1.新老节点相同就直接返回。

2.如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换componentInstance即可。

3.新节点不是文本节点,新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren是diff算法的核心,后面我们会重点说。

4.新节点不是文本节点,如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。

5.新节点不是文本节点,当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。

6.新节点不是文本节点,并且新老节点都无子节点的时候,只需要将老节点文本清空。

7.新节点是文本节点,并且新老节点文本不一样,则进行文本的替换。

updateChildren(diff算法核心)

updateChildren是diff算法的核心,下面我们来重点分析。

深入理解vue2中的VNode和diff算法

这两张图代表旧的VNode与新VNode进行patch的过程,他们只是在同层级的VNode之间进行比较得到变化(相同颜色的方块代表互相进行比较的VNode节点),然后修改变化的视图,所以十分高效。所以Diff算法是:深度优先算法。 时间复杂度:O(n)

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {   let oldStartIdx = 0   let newStartIdx = 0   let oldEndIdx = oldCh.length - 1   let oldStartVnode = oldCh[0]   let oldEndVnode = oldCh[oldEndIdx]   let newEndIdx = newCh.length - 1   let newStartVnode = newCh[0]   let newEndVnode = newCh[newEndIdx]   let oldKeyToIdx, idxInOld, vnodeToMove, refElm    const canMove = !removeOnly    if (process.env.NODE_ENV !== 'production') {     checkDuplicateKeys(newCh)   }    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {     if (isUndef(oldStartVnode)) {       oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left     } else if (isUndef(oldEndVnode)) {       oldEndVnode = oldCh[--oldEndIdx]     // 老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdx 与 newStartIdx 向后移动一位。     } else if (sameVnode(oldStartVnode, newStartVnode)) {       patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)       oldStartVnode = oldCh[++oldStartIdx]       newStartVnode = newCh[++newStartIdx]     // 两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作。并将 oldEndVnode 与 newEndVnode 向前移动一位。     } else if (sameVnode(oldEndVnode, newEndVnode)) {       patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)       oldEndVnode = oldCh[--oldEndIdx]       newEndVnode = newCh[--newEndIdx]     // oldStartVnode 与 newEndVnode 符合 sameVnode 的时候,     // 将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。     // 然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。     } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right       patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)       canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))       oldStartVnode = oldCh[++oldStartIdx]       newEndVnode = newCh[--newEndIdx]     // oldEndVnode 与 newStartVnode 符合 sameVnode 时,     // 将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。     // oldEndIdx 向前移动一位,newStartIdx 向后移动一位。     } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left       patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)       canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)       oldEndVnode = oldCh[--oldEndIdx]       newStartVnode = newCh[++newStartIdx]     } else {       // createKeyToOldIdx 的作用是产生 key 与 index 索引对应的一个 map 表       if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)       idxInOld = isDef(newStartVnode.key)         ? oldKeyToIdx[newStartVnode.key]         : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)       // 如果没有找到相同的节点,则通过 createElm 创建一个新节点,并将 newStartIdx 向后移动一位。       if (isUndef(idxInOld)) { // New element         createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)       } else {         vnodeToMove = oldCh[idxInOld]         // 如果找到了节点,同时它符合 sameVnode,则将这两个节点进行 patchVnode,将该位置的老节点赋值 undefined         // 同时将 newStartVnode.elm 插入到 oldStartVnode.elm 的前面         if (sameVnode(vnodeToMove, newStartVnode)) {           patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)           oldCh[idxInOld] = undefined           canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)         } else {           // 如果不符合 sameVnode,只能创建一个新节点插入到 parentElm 的子节点中,newStartIdx 往后移动一位。           createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)         }       }       newStartVnode = newCh[++newStartIdx]     }   }   // 当 while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,   // 需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。   if (oldStartIdx > oldEndIdx) {     refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm     addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)   // 如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,   // 将这些无用的老节点通过 removeVnodes 批量删除即可。   } else if (newStartIdx > newEndIdx) {     removeVnodes(oldCh, oldStartIdx, oldEndIdx)   } }
登录后复制

vue2的diff算法采用的是双端比较,所谓双端比较就是新列表和旧列表两个列表的头与尾互相对比,在对比的过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。

首尾对比的四种情况

我们首先来看看首尾对比的四种情况。

  • 使用旧列表的头一个节点oldStartNode与新列表的头一个节点newStartNode对比

  • 使用旧列表的最后一个节点oldEndNode与新列表的最后一个节点newEndNode对比

  • 使用旧列表的头一个节点oldStartNode与新列表的最后一个节点newEndNode对比

  • 使用旧列表的最后一个节点oldEndNode与新列表的头一个节点newStartNode对比

首先是 oldStartVnode 与 newStartVnode 符合 sameVnode 时,说明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdx 与 newStartIdx 向后移动一位。

深入理解vue2中的VNode和diff算法

其次是 oldEndVnode 与 newEndVnode 符合 sameVnode,也就是两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作并将 oldEndVnode 与 newEndVnode 向前移动一位。

深入理解vue2中的VNode和diff算法

接下来是两种交叉的情况。

先是 oldStartVnode 与 newEndVnode 符合 sameVnode 的时候,也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。

深入理解vue2中的VNode和diff算法

同理,oldEndVnode 与 newStartVnode 符合 sameVnode 时,也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。同样的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。

深入理解vue2中的VNode和diff算法

最后是当以上情况都不符合的时候,这种情况怎么处理呢?

查找对比

那就是查找对比。

首先通过createKeyToOldIdx方法生成oldVnode的key 与 index 索引对应的一个 map 表。

然后我们根据newStartVnode.key,可以快速地从 oldKeyToIdx(createKeyToOldIdx 的返回值)中获取相同 key 的节点的索引 idxInOld,然后找到相同的节点。

这里又分三种情况

  • 如果没有找到相同的节点,则通过 createElm 创建一个新节点,并将 newStartIdx 向后移动一位。

  • 如果找到了节点,同时它符合 sameVnode,则将这两个节点进行 patchVnode,将该位置的老节点赋值 undefined(之后如果还有新节点与该节点key相同可以检测出来提示已有重复的 key ),同时将 newStartVnode.elm 插入到 oldStartVnode.elm 的前面。同理,newStartIdx 往后移动一位。

深入理解vue2中的VNode和diff算法

  • 如果不符合 sameVnode,只能创建一个新节点插入到 parentElm 的子节点中,newStartIdx 往后移动一位。

深入理解vue2中的VNode和diff算法

添加、删除节点

最后一步就很容易啦,当 while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。

深入理解vue2中的VNode和diff算法

同理,如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes 批量删除即可。

深入理解vue2中的VNode和diff算法

总结

Diff算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM,进而提高效率和性能。

精准主要体现在,diff 算法首先就是找到可复用的节点,然后移动到正确的位置。当元素没有找到的话再来创建新节点。

扩展

vue中为什么需要使用key,它的作用是什么?

key 是 Vue 中 vnode 的唯一标记,通过这个 key,diff 操作可以更准确、更快速。

  1. 更准确:因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确。
  2. 更快速:利用 key 的唯一性生成 map 对象来获取对应节点,比遍历方式更快。

为什么不推荐使用index作为key

当我们的列表只涉及到 展示,不涉及到排序、删除、添加的时候使用index作为key是没什么问题的。因为此时的index在每个元素上是唯一的。

但是如果涉及到排序、删除、添加的时候就不能再使用index作为key了,因为每个元素key不再唯一了。不唯一的key,对diff算法没有任何帮助,写和没写是一样的。

(学习视频分享:web前端开发、编程基础视频)

赞(0)
分享到: 更多 (0)

网站地图   沪ICP备18035694号-2    沪公网安备31011702889846号