[VUE]VUE中的diff算法

前言

本人在一家中小公司工作了几年,一开始只负责前端,到后来用nodejs写服务以及负责一些团队基础设施等运维工作。由于做的事情太杂,最近想重新系统地整理一下前端的知识,因此写了这个专栏。我会尽量写一些业务相关的小技巧和前端知识中的重点内容,核心思想。

什么是虚拟dom?

虚拟dom(virtual DOM)是一个原生js对象,用来描述dom元素。

我们知道平常dom元素下会有许多属性,如(style,attributes,offsetHeight等)。浏览器用这些属性来渲染页面。那么如果我们自己建立属性来描述dom元素,是否可以用js原生对象的方式来构建出一棵存在缺又不必渲染在浏览器上的dom树?

下面是一个例子:

{
  "tag":"div",
   "className":"xxx",
   "on":[
     {
       "click":function(){}
     }
   ],
   "children":[...]   
}
复制代码

当然虚拟dom的组成是一种思想,他的结构不是固定的。不同团队,不同工具会根据自己的业务,制定的虚拟dom对象结构。

为什么要用虚拟dom?

可以肯定的是,无论用什么实现方式。使用虚拟dom的最终目的都只有一个。就是减少dom元素的操作,降低渲染成本。我们都知道操作dom的渲染成本是很高的,但出于一些交互,又不得不这么做。jquery在dom的操作上已经把写法优化得很好了。往后的前端开发者在思考的是,如何以最低的成本来操作dom?

虚拟dom就是这个解决方案,我们在内存中维护一棵dom树,这棵树的信息量足够让浏览器把页面完整渲染。每当我们需要修改页面时,先在虚拟dom上修改信息,再去对比修改前后的2棵dom树,找到不同的地方,在操作dom时,只修改那个地方即可。

diff算法?

从上述内容可以看出,要实现这套流程。最核心的地方是如何实现虚拟dom找出修改的部分,即2棵虚拟dom树的对比。

diff 算法是vue选择的解决方案。diff 算法本身是一种通过同层的树节点进行比较的高效算法,需要注意的是它不是vue特有或者原创的。

diff 算法有两个特点:

  • 比较只会在同层级进行, 不会跨层级比较
  • 在diff比较的过程中,循环从两边向中间比较

path函数

path函数主要是同级判断新旧2个节点,主要操作有以下:

  • 如果有新节点,没有旧节点。直接创建新节点内容。
  • 如果没有新节点,有旧节点,删除旧节点内容。
  • 如果2个节点都有,判断是否一致(调用sameVnode方法)。如果不一致就把旧的删调,创建新的。如果一致就用patchVnode方法处理。
function patch(oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) { // 没有新节点,销毁旧节点,触发destory钩子
        if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
        return
    }
    let isInitialPatch = false
    const insertedVnodeQueue = []
    
    if (isUndef(oldVnode)) {// 没有旧节点,直接用新节点生成dom元素
        isInitialPatch = true
        createElm(vnode, insertedVnodeQueue) 
    } else { // 新旧节点都存在
        const isRealElement = isDef(oldVnode.nodeType)
        if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // 判断旧节点和新节点自身一样,一致执行patchVnode
            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
        } else {
            // 否则直接销毁及旧节点,根据新节点生成dom元素
            if (isRealElement) {
                if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
                    oldVnode.removeAttribute(SSR_ATTR)
                    hydrating = true
                }
                if (isTrue(hydrating)) {
                    if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
                        invokeInsertHook(vnode, insertedVnodeQueue, true)
                        return oldVnode
                    }
                }
                oldVnode = emptyNodeAt(oldVnode)
            }
            return vnode.elm
        }
    }
}
复制代码

sameVnode函数

sameVnode方法的作用是判断2个节点是否一致,从源码可以看出他主要是根据节点的key值和tag等具体组件信息判断的。

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
复制代码

patchVnode函数

patchVnode是在sameVnode方法判断2个节点一致时,执行的处理2个节点的方法。

  • 如果2个节点完全一致,不作处理
  • 如果2个节点不完全一致,用updateChildren处理。
  • 如果老节点有子节点,新节点没有,则删除新节点。
  • 如果新节点有内容,老节点没有,则把新节点内容增加到老节点下。
  • 新vnode和老vnode是文本节点或注释节点,但内容不一样,直接替换。
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // 如果新旧节点一致,什么都不做
    if (oldVnode === vnode) {
      return
    }

    // 让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化
    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
    }
    // 如果新旧都是静态节点,并且具有相同的key
    // 当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上
    // 也不用再有其他操作
    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
    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)
    }
    // 如果vnode不是文本节点或者注释节点
    if (isUndef(vnode.text)) {
      // 并且都有子节点
      if (isDef(oldCh) && isDef(ch)) {
        // 并且子节点不完全一致,则调用updateChildren
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)

        // 如果只有新的vnode有子节点
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        // elm已经引用了老的dom节点,在老的dom节点上添加子节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)

        // 如果新vnode没有子节点,而vnode有子节点,直接删除老的oldCh
      } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)

        // 如果老节点是文本节点
      } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }

      // 如果新vnode和老vnode是文本节点或注释节点
      // 但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以
    } else if (oldVnode.text !== vnode.text) {
      nodeOps.setTextContent(elm, vnode.text)
    }
    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }
复制代码

updateChildren函数

updateChildren处理的是某节点下的children,他们是一组同级的扁平数组。

用双指针的方法,定义新头newStartIdx,新尾newEndIdx,旧头oldStartIdx,旧尾oldEndIdx,2组4个的索引。循环遍历处理以下几种情况:

  • 新头与旧头一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newStartIdx和oldStartIdx往右移动。

  • 新头与旧尾一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newStartIdx往右移动和newEndIdx往左移动。

  • 新尾与旧头一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newEndIdx往左移动和oldStartIdx往右移动。

  • 新尾与旧尾一致,则跟上方patch函数处理2个一致的节点一样,用patchVnode方法处理。并把newEndIdx往左移动和oldEndIdx往左移动。

  • 如果以上情况都不符合,则暴力遍历处理。在旧节点组里找有没有当前新节点组里newStartIdx指向的内容。如果没有就直接新建,如果有就patchVnode处理。

  • 最后当新旧节点组2组其中之一遍历结束后,循环停止。这时如果旧节点组还有内容,就把旧的删除,如果新节点组还有内容就把新的添加。

  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

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }
		// 当新旧节点组2组其中之一遍历结束后,循环停止
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 如果oldStartVnode不存在,对应的oldStartIdx往右移动
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        // oldEndVnode,对应的oldEndIdx往右移动
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // oldStartVnode与newStartVnode相同,用patchVnode处理,并移动指针。
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // oldEndVnode与newEndVnode相同,用patchVnode处理,并移动指针。
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // oldStartVnode与newEndVnode相同,用patchVnode处理,并移动指针。
        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)) { // Vnode moved left
        // oldEndVnode与newStartVnode相同,用patchVnode处理,并移动指针。
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 如果上面的情况都不符合,则暴力遍历
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          // 如果新的节点在旧的组里没有就新建。
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 如果新的节点在旧的组里有就判断是不是一致,一致就继续patchVnode。如果不是就把新的新建
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // 到这里while循环已经结束了,这时如果旧节点组还有内容,就把旧的删除,如果新节点组还有内容就把新的添加。
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }
复制代码

图例详解

假设下面的要对比的2组节点组,分别定义新头newStartIdx,新尾newEndIdx,旧头oldStartIdx,旧尾oldEndIdx。4个索引

第一次遍历发现newStartIdx与oldStartIdx相同,用 patchVnode处理。并让2个索引向前移动。

第二次遍历发现oldEndIdx与oldStartIdx相同,用 patchVnode处理。并让oldEndIdx索引向后移动看,让oldStartId向前。

第三次遍历发现oldStartIdx与newEndIdx相同,用 patchVnode处理。并让oldStartIdx索引向前移动看,让newEndIdx向后。

第四次遍历发现4种情况都不符合,开始暴力遍历,在旧节点组中仍找不到节点,直接新建节点。并让newStartIdx向前。

第五次遍历情况同上。

五次遍历结束后,新节点组已经空了,旧节点组还有内容。直接删除。最后diff结束。

总结

diff算法是vue的核心内容之一,在借鉴时,要留意:

  • dom的渲染应该是最少幅度的,先用虚拟dom,挑选出需要渲染dom的部分再渲染。
  • 在diff时一切新的内容应该以新的node为主。
  • 在算法中应该注意特殊情况的处理。把各种情况考虑清楚。

おすすめ

転載: juejin.im/post/7033716856460574756
おすすめ