图文并茂地描绘Vue的Diff算法

1,615 阅读10分钟

Diff算法

Vue的Diff算法有两种不同的粒度,分别是组件级别(component Diff)和元素级别(Element Diff)。

假如有新旧两个不同的Virtual DOM Tree,如下图所示,Vue只会比较同一层级的节点,即只比较同颜色方框内的节点。其中,深蓝色方框属于组件级别,紫色方框、橙色方框和浅蓝色方框属于元素级别。那Vue具体是如何Diff的呢?

Virtual DOM Tree Diff

组件更新

当数据发生变化时,组件更新过程是什么样子的呢? 当数据发生变化时,会触发数据的setter,在setter中会触发notify通知渲染watcher,渲染watcher会通过updateComponent重新计算watcher的值。

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
// 我们在watcher的构造函数中为vm._watcher设置这个
// 因为观察者的初始补丁可能会调用 $forceUpdate(例如,在内部子组件的mounted钩子),它依赖于已定义的 vm._watcher
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)

updateComponent即是执行vm._update方法,其定义在文件中:src/core/instance/lifecycle.js

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  // ...
  const prevVnode = vm._vnode
  if (!prevVnode) {
    // 初始渲染
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 组件更新
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  // ...
}

组件更新的时候,会通过prevVnode判断是否是初始渲染,不过最后都是调用__patch__方法,只是参数不同,__patch__定义在文件中:src/core/vdom/patch.js

  return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // 初始渲染
      // 空的挂载(可能是组件),创建新的根元素
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue, parentElm, refElm)
    } else {
      // 组件更新
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 新旧节点相同
        // patch existing root node
        // 修补现有根节点
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } else {
        // 新旧节点不同
        // ...
      }
    }

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

path的逻辑是根据不同的情况进行处理的,先通过oldVnode是否存在来判断是初始渲染还是组件更新,如果是初始渲染,就直接通过createElm来创建元素,如果是组件更新,则通过sameVnode来判断新旧节点是否相同,相同的话则直接patchVnode进行更新,若不同则更新逻辑是不一样的。

其中,sameVnode的定义如下:

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)
      )
    )
  )
}

sameVnode的逻辑比较清晰,如果两个 vnode 的 key 不相等,则是不同的;否则继续判断,对于同步组件而言,会判断 isComment、data、input 类型等是否相同,对于异步组件而言,会判断 asyncFactory 是否相同。

组件更新时,新旧节点不同的处理逻辑是不一样的,我们先来看看新旧节点不同时的逻辑是怎么样的。

新旧节点不同

当新旧节点不同时,更新逻辑如下:

// 新旧节点不同
if (isRealElement) {
  // 挂载到一个真正的元素
  // 检查这是否是服务端渲染的内容,以及我们是否可以执行一个成功的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 (process.env.NODE_ENV !== 'production') {
      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.'
      )
    }
  }
  // 不是服务端渲染,就是hydration失败。
  // 创建一个空节点并替换它
  oldVnode = emptyNodeAt(oldVnode)
}

// 替换存在的元素
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

// 创建新节点
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)
)

// 递归更新父占位符节点元素
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
        for (let i = 1; i < insert.fns.length; i++) {
          insert.fns[i]()
        }
      }
    } else {
      registerRef(ancestor)
    }
    ancestor = ancestor.parent
  }
}

// 销毁旧节点
if (isDef(parentElm)) {
  removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
  invokeDestroyHook(oldVnode)
}

新旧节点不同时,更新逻辑比较清晰,首先创建新节点,接着更新父占位节点,最后删除旧节点,也就是创建新节点替换旧节点的过程。

新旧节点相同

新旧节点相同时,直接执行代码patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly),其定义在文件中:src/core/vdom/patch.js

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  if (oldVnode === vnode) {
    return
  }

  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被克隆时才这样做-
  // 如果未克隆新节点,则表示渲染函数已由热重载api重置,我们需要执行正确的重新渲染。
  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)
  }
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      nodeOps.setTextContent(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)
  }
}

patchVnode的主要逻辑是:

  • 调用prepatch钩子函数
  • 调用update钩子函数
  • 完成patch过程
    • 如果新节点不是文本节点:
      • 如果旧节点的子节点和新节点的子节点都存在且不相等的话,则通过updateChildren更新子节点
      • 如果只有新节点的子节点存在的话,则通过addVnodes新增DOM节点;如果旧节点是文本节点的话,则将文本设置为空
      • 如果只有旧节点的子节点存在的话,则通过removeVnodes删除DOM节点
      • 如果旧节点是文本节点的话,则将文本设置为空
    • 如果新节点是文本节点,且与旧节点的文本不同的话,则直接更新DOM的文本
  • 调用postpatch钩子函数

至此,组件级别的Diff过程就是如此,当组件是初始渲染时,Vue直接创建DOM元素;当组件是更新时,若新旧节点相同,会通过patchVnode进行更新,若新旧节点不同时,会创建新元素并替换旧元素。那元素级别的Diff过程是什么样的呢?在patchVnode过程中,如果旧节点的子节点和新节点的子节点都存在且不相等的话,则通过updateChildren更新子节点,其中updateChildren就是元素级别的Diff过程了,下面来看看updateChildren的具体逻辑。

updateChildren

当新旧节点的子节点都存在且不相等时,会调用updateChildren更新子节点

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是一个特殊标志,仅由<transition group>使用,
  // 以确保在离开转换期间移除的元素保持在正确的相对位置
  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]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      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 {
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 相同的键,但不同的元素。视为新元素
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  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(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

这段逻辑是比较复杂的,核心逻辑是通过对比新旧两个节点的子节点列表,找出列表中相同的节点,然后将相同的旧节点移动到新位置上,如果旧列表中不存在新节点,则进行创建新节点,最后,删除旧列表中的旧节点。这样做可以很大程度地将旧节点重用,从而提高性能。

具体的算法是:

  • 两个新旧列表,分别建立头尾两个索引,一共四个索引,newStartIdx/newEndIdxoldStartIdx/oldEndIdx
  • 进行while循环,条件是oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
    • 如果oldStartIdxnewStartIdx对应的两个节点相同,则进行patchVnode,并将oldStartIdxnewStartIdx进行自增
    • 如果oldEndIdxnewEndIdx对应的两个节点相同,则进行patchVnode,并将oldEndIdxnewEndIdx进行自减
    • 如果oldStartIdxnewEndIdx对应的两个节点相同,则进行patchVnode,并将DOM上oldStartIdx对应的节点插入到oldEndIdx之后,接着oldStartIdx自增,newEndIdx自减
    • 如果oldEndIdxnewStartIdx对应的两个节点相同,则进行patchVnode,并将DOM上oldEndIdx对应的节点插入到oldStartIdx之前,接着newStartIdx自增,oldEndIdx自减
    • 最后,试图在oldStartIdxoldEndIdx之间找到和newStartIdx的节点相同的节点,若存在,则进行patchVnode,并将找到的节点插入到oldStartIdx之前;若不存在,则创建新的元素。newStartIdx进行自增
  • 循环结束,若oldStartIdx > oldEndIdx,则说明newStartIdxnewEndIdx之间的节点都是旧列表没有的,所以需要将这些节点添加到DOM上;若newStartIdx > newEndIdx,则说明oldStartIdxoldEndIdx之间的这些节点是新列表没有的,所以需要将这些节点从DOM上删除。

单纯的代码和文字描述可能有点晦涩,我们举一个具体的例子,如图所示,有新旧两个列表,当前真实DOM是旧列表的映射

(1) oldStart=a oldEnd=d newStart=a newEnd=c
oldStart和newStart相同,所以进行patchVnode,接着oldStart和newStart会前进

(2) oldStart=b oldEnd=d newStart=f newEnd=c

newStart在old中没有找到相同的节点,所以会创建f插入到b之前,接着newStart会前进

(3) oldStart=b oldEnd=d newStart=d newEnd=c

oldEnd和newStart相同,所以会将d插入到b之前,接着oldEnd会后退,newStart会前进

(4) oldStart=b oldEnd=c newStart=e newEnd=c

oldEnd和newEnd相同,所以进行patchVnode,接着oldEnd会后退,newEnd会后退

(5) oldStart=b oldEnd=b newStart=e newEnd=e

newStart在old中没有找到相同的节点,所以会创建e插入到b之前,接着newStart会前进

(6) oldStart=b oldEnd=b newStart=c newEnd=e

newStart是在newEnd后面的,说明oldStart和oldEnd之间的节点是需要删除的,所以将b进行移除,移除后的DOM列表将和new列表是一样的。

至此,子节点列表的Diff过程结束,思想是在旧列表中找到和新列表中相同的元素,并移动到正确的位置;若没找到相同的元素,则进行创建,并插入到正确的位置;若旧列表中含有新列表中没有的元素,则进行删除。这个过程是Vue中元素级别的Diff流程。

小结

Vue的Diff算法分为两个粒度,一个是组件级别(component Diff),另一个是元素级别(Element Diff)。组件级别的Diff算法比较简单,节点不相同就进行创建和替换,节点相同的话就会对其子节点进行更新;对子节点进行更新也就是元素级别的Diff,通过插入、移动和删除等方式对旧列表改造成和新列表一致。

Diff 粒度

Vue的整个Diff过程就是整个patch方法的流程,整个流程也会通过递归地调用patchVnode来完成对整颗Virtual DOM Tree的更新。

Diff 流程图

如果你想了解关于Diff之前响应式数据是如何进行依赖收集和派发更新的话,点击《浅析Vue.js依赖收集和派发更新中观察者模式的应用》