Vue中采用了 虚拟DOM + Diff算法 减少了对DOM的操作次数,大大提高了性能,那么我们今天就来详细的讲一下Vue中这一部分的实现逻辑,希望可以帮助还不理解这部分的小伙伴理解这一部分,纯手打,希望各位小伙伴点个赞支持一下!


首先我们要明确的是,vnode代表本次修改后新生成的虚拟节点,oldVnode代表目前真实DOM结构所对应的虚拟节点。所以我们更新是以vnode为基准,通过oldVnode的结构去操作真实DOM,vnode和oldVnode都不会被改变,被改变的只有真实DOM结构。

patch

Vue在首次渲染和数据更新的时候,会去调用 _update 方法,而 _update 方法的核心就是去调用 vm.__patch__ 方法,那么我们就先从patch方法入手。patch方法中的逻辑比较复杂,这里我们只看主要流程部分,大致流程如下图:

function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
    if (isUndef(oldVnode)) {
      /*oldVnode未定义的时候,创建一个新的节点*/
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue, parentElm, refElm)
    } else {
      /*标记旧的VNode是否有nodeType*/
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        /*是同一个节点的时候直接修改现有的节点*/
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } else {
        if (isRealElement) {
          oldVnode = emptyNodeAt(oldVnode)
        }
        // 插入到视图中旧节点的旁边
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        if (isDef(parentElm)) {
          /*移除老节点*/
          removeVnodes(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          /*调用destroy钩子*/
          invokeDestroyHook(oldVnode)
        }
      }
    }

    return vnode.elm
}

这里有个 isRealElement 用来标识oldVnode是否有 nodeType,有nodeType就说明这是个真是的dom节点,要通过emptyNodeAt转化为vNode。

patch中用到了两个比较重要而方法,一个是createElm,另一个是patchVnode

createElm

createElm 的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中,主要流程如下:

function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
    /*创建一个组件节点*/
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode) // 创建dom节点
      setScope(vnode)

      if (__WEEX__) {
           // ...
      } else {
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        insert(parentElm, vnode.elm, refElm)
      }

    } else if (isTrue(vnode.isComment)) {
      vnode.elm = nodeOps.createComment(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    } else {
      vnode.elm = nodeOps.createTextNode(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    }
  }

createComponent 只有在vnode是组件的时候才会返回true,这部分以后再分析。

createChildren 实际上是遍历子虚拟节点,递归调用 createElm,遍历过程中会把 vnode.elm 作为父容器的 DOM 节点传入,因为是递归调用,子元素会优先调用 insert所以整个vnode树节点的插入顺序是先子后父

patchVnode

patchVnode的主要流程和主要代码如下:

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    /*两个VNode节点相同则直接返回*/
    if (oldVnode === vnode) {
      return
    }
    /*
      如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),
      并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),
      那么只需要替换elm以及componentInstance即可。
    */
    if (isTrue(vnode.isStatic) &&
        isTrue(oldVnode.isStatic) &&
        vnode.key === oldVnode.key &&
        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {
      vnode.elm = oldVnode.elm
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    const elm = vnode.elm = oldVnode.elm
    const oldCh = oldVnode.children
    const ch = vnode.children

    /*如果这个VNode节点没有text文本时*/
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        /*新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren*/
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        /*如果老节点没有子节点而新节点存在子节点,先清空elm的文本内容,然后为当前节点加入子节点*/
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        /*当新节点没有子节点而老节点有子节点的时候,则移除所有ele的子节点*/
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        /*当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除ele的文本*/
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      /*当新老节点text不一样时,直接替换这段文本*/
      nodeOps.setTextContent(elm, vnode.text)
    }

  }

其实也就几种情况:

  1. 新旧vnode都有子节点,对子节点进行diff。
  2. 新vnode有,旧vnode没有,新增。
  3. 旧vnode有,新vnode没有,删除。
  4. 如果有text属性,替换文本。

主要来看其中调用的 updateChildren 方法。

updateChildren

在整个 patchVnode 过程中,最复杂的就是 updateChildren 方法了,这个方法里进行了新旧虚拟节点子节点的对比。

如何对比新旧子节点列表呢?很简单,循环!循环新子节点列表,其中每一项再去旧子节点列表里循环查找,然后做处理。但通常情况下,并不是所有子节点的位置都会发生改变。

举个例子🌰,现在有一个列表,我们对它的操作大多数就是新增一项,删除一项或者改变其中的一项,大多数节点的位置是不变的,是可预测的,没必要每次都去循环查找,所以Vue中使用了一种更快捷的查找方式,大大提高了性能,简单来说就是头尾比较

  • 新头:新子节点列表中所有 未处理 的第一个节点
  • 旧头:旧子节点列表中所有 未处理 的第一个节点
  • 新尾:新子节点列表中所有 未处理 的最后一个节点
  • 旧尾:旧子节点列表中所有 未处理 的最后一个节点

Vue中用四个变量 newStartIdxnewEndIdxoldStartIdxoldEndIdx来标识新旧头部与尾部节点,

1. 新头与旧头

如果新头与旧头是同一节点,它们的位置也一样,所以只需更新节点即可,没有移动操作。

2. 新尾与旧尾

如果新尾与旧尾是同一节点,它们的位置也一样,所以只需更新节点即可,没有移动操作。

3. 新尾与旧头

如果新尾与旧头是同一节点,由于它们位置不一样,所以除了更新,还要进行移动操作。

首先我们要明确的是更新是以vnode为基准,oldVnode代表的就是真实DOM结构,所以我们要更新真实DOM其实就是去更新oldVnode。

这里我们要注意,一定是移动到所有 未处理节点 后面,因为新尾是新子节点列表里未处理 的最后一个。

4. 新头与旧尾

如果新头与旧尾是同一节点,由于它们位置不一样,所以除了更新,还要进行移动操作。
移动逻辑和上面的 新尾与旧头 大致相同,把旧尾移动到所有 未处理节点之前


如果经过这四次对比还是没有在 旧子节点列表 中找到相同的节点,那么先用oldVnode生成一个key为oldVnode的key,value为对应下标的哈希表 {key0: 0, key1: 1},然后用 新头(newStartVnode) 的key在哈希表里查找,如果找到对应的,判断他们是不是 sameVnode

  1. 如果是,那么就表示在 旧子节点列表 中找到了相同的节点,进行更新节点操作,最后还要将这个老节点赋值 undefined,避免后续有相同的key重复对比。
  2. 如果不是,那么就有可能是标签不一样了,或者input的type改变了,这时直接创建一个新的节点。

如果在哈希表里没找到对应的,就简单了,直接创建一个新的节点。最后遍历结束,会有两种情况:

  1. oldStartIdx > oldEndIdx,说明老节点遍历结束,那么剩余新节点就是要新增的,那么一个一个创建出来加入到真实Dom中。
  2. newStartIdx > newEndIdx,说明新节点遍历结束,那么剩余的老节点就是要删除的,删除即可。

逻辑理清楚了,是不是觉得也很简单呢!

结尾

03-05 13:58