diff算法

118 阅读8分钟

diff算法

虚拟DOM是什么

虚拟DOM是表示真实DOM的JS对象

真实DOM

<!-- 真实DOM -->
<div class="container">
  <p class="item">虚拟DOM</p>
  <strong class="item">JS对象</strong>
</div>

虚拟DOM:真实DOM的JS对象

/*
      Vnode 为上边真实DOM的JS对象。
      里面包含的字段有标签名,以及标签属性及子标签的名称、属性和文本节点。
*/
let Vnode = {
      tagName: 'div',
      props: {
        'class': 'container'
      },
      children: [
        {
          tagName: 'p',
          props: {
            'class': 'item'
          },
          text: '虚拟DOM'
        },
        {
          tagName: 'strong',
          props: {
            'class': 'item'
          },
          text: 'JS对象'
        }
      ]
    }

什么是diff算法

diff算法的目的就是找出(两个虚拟DOM)差异,使最小化的更新视图。本质上就是比较两个JS对象的差异

整体流程

当数据改变的时候,就会触发内部的setter方法,进一步触发dep.notify方法,然后通知到各数据使用方,执行patch方法。patch方法接收到两个参数新旧虚拟节点,首先在内部需要判断一下,是不是同类标签,如果不是同类标签,就没有比对的必要直接替换就可以了;如果是同类标签的话,那就需要进一步执行patchVnode方法,在这个方法内部,也是首先需要判断一下新旧虚拟节点是否相等,如果相等的话那就没有比对的必要了,直接return,如果不相等,那就需要分情况来比对,比对的原则就是以新虚拟节点的结果为准:

  1. 第一种情况是,旧虚拟节点和新虚拟节点都有文本节点,直接用新的文本替换旧文本
  2. 第二种情况是,旧虚拟节点没有子节点,新虚拟节点有子节点,直接添加新的子节点
  3. 第三种情况是,旧虚拟节点有子节点,新虚拟节点没有子节点,直接删除旧的子节点
  4. 第四种情况是,旧新虚拟节点都有子节点,这种情况,我们就需要比对他们的子节点,通过updateChildren方法,专门来比对他们的子节点。

updateChildren方法

  • 内部规定了只在同级比对,减少比对次数,最大化的提高比对性能。
  • 首尾指针法

首尾指针法

不管是新旧虚拟节点,都有首尾两个元素,对应的是startend。旧虚拟节点的start和新虚拟节点start,做比对,如果没有比对成功,旧虚拟节点的start和新虚拟节点的end,做比对,如果依旧没有比对成功,旧虚拟节点的end和新虚拟节点的start,做比对,如果依旧没有成功,旧虚拟节点的end和新虚拟节点的end做比对。

  • 依次比较,当比较成功后退出当前比较
  • 渲染结果以newVnode为准
  • 每次比较成功的start和end点向中间靠拢
  • 当新旧节点中有一个start点跑到end点右侧时终止比对
  • 如果都匹配不到,则旧虚拟DOM key值去比对新虚拟DOM key值,如果key值相同则复用,并移动到新虚拟DOM的位置。

整体流程

patch(vnode, oldVnode)

  1. 新虚拟节点不存在,旧虚拟节点存在,直接删除旧虚拟节点(调用旧虚拟节点的destroy钩子函数)
  2. 新虚拟节点存在,旧虚拟节点不存在,通过createElm创建DOM元素,并将其挂载到文档。
  3. 如果新旧虚拟节点都存在,通过sameVnode判断是不是相同节点,相同需要进一步执行patchVnode,不相同直接替换

patchVnode(oldVnode,vnode)

  1. 判断新旧虚拟节点是否相等,无需比较直接返回
  2. 新虚拟节点为文本节点
    • 新旧虚拟文本节点是否相等,不相等更新文本
  3. 新虚拟节点不是文本节点
    • 新虚拟节点存在子节点,旧虚拟节点不存在子节点,新增所有的子节点
    • 新虚拟节点不存在子节点,旧虚拟节点存在子节点,删除所有的子节点
    • 新旧虚拟节点都有子节点,执行updateChildren(elm,oldCh,ch)方法, 比较孩子节点

updateChildren(parentElm,oldCh,newCh)

  • 首先这个方法传入三个比较重要的参数,既 parentElm 父级真实节点,便于直接操作; oldCh 为oldVnode的孩子节点,newCh 为Vnode 的孩子节点。

  • oldCh和newCh都是数组。这个方法的作用就是对这两个数组一一比较,找到相同的节点,执行patchVnode 再次进行比较更新,剩下的新增或者删除。

  • 内部规定了只在同级比对,减少比对次数,最大化的提高比对性能。

  • 首尾指针法

    • 不管是新旧虚拟节点,都有首尾两个元素,对应的是startend。旧虚拟节点的start和新虚拟节点start,做比对,如果没有比对成功,旧虚拟节点的start和新虚拟节点的end,做比对,如果依旧没有比对成功,旧虚拟节点的end和新虚拟节点的start,做比对,如果依旧没有成功,旧虚拟节点的end和新虚拟节点的end做比对。
    • sameVnode比对成功,进一步进行patchVnode,每次比较成功的start和end点向中间靠拢
    • 当新旧节点中有一个start点跑到end点右侧时终止比对
    • 如果四种方法(新旧虚拟节点的交叉比对)都匹配不到相同节点的话,剩下的只能使用暴力解法去实现,也就是针对于 newStartVnode 这个节点,我们去遍历 oldCh 中剩余的节点,一一匹配
    • 生成一个 oldCh 得key-> index 的映射表,用变量 oldKeyToIdx 去存储,如果新虚拟孩子节点存在key值,直接用,oldKeyToIdx[newStartVnode.key] 拿到对应旧孩子节点的下标 index;如果没有key值,通过遍历 oldCh 中剩余得节点,一一进行匹配获取对应下标 index。
    • 如果oldCh匹配不到index,则创建新节点
    • 如果key值相同,但节点不同,创建新节点
    • key值相同,节点也相同,进行patchVnode
    • 这时候,我们 oldCh 和 newCh 两个数组一一比较差不多了,
  • 如果这个时候,oldCh 的两个指针已经重叠并越过,而 newCh 的两个指针还未重叠;说明 newCh 有多余的 vnode ,我们只需要新增他们就可以了

  • 或者相反情况下(newCh 的两个指针已经重叠并越过,而 oldCh 的两个指针还未重叠)说明 oldCh 有多余的 vnode ,我们只需要删除他们即可。

源码

  • isUndef(vnode): 这个函数用于检查 vnode 是否未定义或者为 null。如果 vnode 不存在,即 isUndef(vnode) 返回 true,那么表示没有新的虚拟节点需要渲染。

  • 如果 vnode 不存在,那么代码继续执行下一步:

    • isDef(oldVnode): 这个函数用于检查 oldVnode 是否已经被定义,即旧的虚拟节点是否存在。如果旧的虚拟节点 oldVnode 存在,即 isDef(oldVnode) 返回 true,那么说明之前有一个虚拟节点被渲染到了 DOM 上。
    • invokeDestroyHook(oldVnode): 这个函数用于调用旧虚拟节点上的销毁钩子函数。在 Vue.js 中,组件有一系列的生命周期钩子函数,其中包括 destroyed 钩子,用于在组件被销毁时执行一些清理操作。invokeDestroyHook 的作用就是执行旧虚拟节点上的销毁钩子函数。
return function patch(oldVnode, vnode, hydrating, removeOnly) {
    // 1、新虚拟节点不存在,旧虚拟节点存在,调用旧虚拟节点的destroy钩子函数
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue: any[] = []
    
    // 2、新虚拟节点存在,旧虚拟节点不存在,创建新的DOM元素,并进行跟踪。创建完成之后并将其挂载到文档中。
    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
    // 3、如果新旧虚拟节点都存在,判断是不是相同节点,相同需要进一步执行patchNode,不相同直接替换
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        if (isRealElement) {
          // mounting to a real element
          // check if this is server-rendered content and if we can perform
          // a successful hydration.
          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
            } else if (__DEV__) {
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                  'server-rendered content. This is likely caused by incorrect ' +
                  'HTML markup, for example nesting block-level elements inside ' +
                  '<p>, or missing <tbody>. Bailing hydration and performing ' +
                  'full client-side render.'
              )
            }
          }
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          oldVnode = emptyNodeAt(oldVnode)
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // create new node
        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)
        )

        // update parent placeholder node element, recursively
        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)
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                // clone insert hooks to avoid being mutated during iteration.
                // e.g. for customed directives under transition group.
                const cloned = insert.fns.slice(1)
                for (let i = 0; i < cloned.length; i++) {
                  cloned[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }

        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }