深入解析 Vue2 中的 Diff 算法

286 阅读9分钟

一、引言

 Vue2 中的 Diff 算法是基于虚拟 Dom 的一种对比算法。其本质就是通过对比两个 Dom 树来最大程度减少 Dom 更新。

二、diff 算法的特点

在 Vue2 中,diff 算法有以下特点。👇

  • 同层比较 :仅比较同层级节点,不跨层级
  • 双端比较 :使用头尾指针进行四种可能性快速匹配
  • key的作用 :通过唯一标识复用相同节点
  • 就地复用 :尽可能复用现有 DOM 节点

同层比较

 

// 旧的虚拟 DOM
const oldVDOM = {
    tag: 'div',
    children: [
        {
            tag: 'p',
            props: {},
            children: 'p'
        },
        {
            tag: 'ul',
            props: {},
            children: [
                {
                    tag: 'li',
                    props: { key: 1 },
                    children: 'li1'
                },
                {
                    tag: 'li',
                    props: { key: 2 },
                    children: 'li2'
                }
            ]
        }
    ]
};

// 新的虚拟 DOM
const newVDOM = {
    tag: 'div',
    children: [
        {
            tag: 'p',
            children: 'p'
        },
        {
            tag: 'ul',
            children: [
                {
                    tag: 'li',
                    props: { key: 1 },
                    children: 'li2'
                },
                {
                    tag: 'li',
                    props: { key: 3 },
                    children: 'li3'
                }
            ]
        }
    ]
};


// 这里只会同层的 p 标签进行对比,同层的 ul 标签对比,同层的 li 标签对比

4.jpg

为什么要这样呢?这样看起来好像不太对吧?那是因为在实际的前端开发中,DOM 结构的变化通常是局部的、同层的 。例如,列表项的增删改操作、组件内部元素的更新等,大多发生在同一层级。同层比较可以避免对整个树进行不必要的深度遍历。如果选择全部对比,时间复杂度是指数级别的上升,非常不利于性能。

这样我们只需要对比同一层级的三种情况:增,删,改

以上面的例子,同层的 li 进行比较,这些用到了一个后面会说到的特性:Key。这里我们可以先把它当作一个标识符。

  • key 为1的 li1 节点的文本修改了,就是修改 的节点
  • key 为2的 li2 节点因为在新节点没有对应的 key,所以是删除 的节点
  • key 为3的 li3 节点在旧节点没有对应的 kye,所以是 增的节点

在源码里,Diff 操作通常是在 updateChildren 函数中进行,以下是简化后的源码片段:

// src/core/vdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 这里的 oldCh 和 newCh 是同一父节点下的新旧子节点列表
  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]

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 具体的比较逻辑,仅在当前层级进行操作
    // 这里我们先知道他处理同层节点
  }
}

双端比较

双端比较是指使用头尾指针同时对新旧节点列表的头尾节点进行比较,有四种可能的匹配情况,以此来快速定位可复用的节点。

在 uupdateChildren 函数中,通过四个指针(oldStartIdx 、oldEndIdx 、newStartIdx 、newEndIdx )来实现双端比较,以下是具体的源码及注释解析:

two.jpg

*1: *四个指针:分别指向新旧子节点列表的首尾,即 oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。四个指针所指向的节点:分别为oldStartVnode、oldEndVnode、newStartVnode、newEndVnode。

*2: *通过一个 while 循环来不断比较新旧子节点列表,只要新旧子节点列表都还有未比较的节点,就继续循环。在循环内部,会根据不同的情况进行处理。

*2.1: *如果 oldStartVnode 和 newStartVnode 是同一个节点(通过 sameVnode 函数判断,该函数主要比较节点的keytag是否相同),则调用 patchVnode 函数来更新该节点,并将 oldStartIdx 和 newStartIdx指针后移一位。

*2.2: *如果 oldEndVnode 和 newEndVnode 是同一个节点,则调用 patchVnode 函数更新该节点,并将 oldEndIdx 和 newEndIdx 指针前移一位。

*2.3: *如果 oldStartVnode 和 newEndVnode 是同一个节点,则调用 patchVnode 函数更新该节点,并将 oldStartVnode 对应的真实 DOM 节点移动到 oldEndVnode 对应的真实 DOM 节点之后,同时将 oldStartIdx 指针后移一位,newEndIdx 指针前移一位。

*2.4: *如果 oldEndVnode 和 newStartVnode 是同一个节点,则调用 patchVnode 函数更新该节点,并将 oldEndVnode 对应的真实 DOM 节点移动到 oldStartVnode 对应的真实 DOM 节点之前,同时将 oldEndIdx 指针前移一位,newStartIdx 指针后移一位。

*2.5: *当以上四种情况都不满足时,会在 oldch 中查找是否存在与 newStartVnode 具有相同 key 的节点。如果存在,则将该节点移动到oldStartVnode 对应的真实 DOM 节点之前,并更新该节点;如果不存在,则创建一个新的节点插入到 oldStartVnode 对应的真实 DOM 节点之前。

*3.1: *如果 oldStartIdx > oldEndIdx,说明旧子节点列表已经比较完,而新子节点列表还有剩余节点,此时需要将新子节点列表中剩余的节点插入到真实 DOM 中。

*3.2: *如果 newStartIdx > newEndIdx,说明新子节点列表已经比较完,而旧子节点列表还有剩余节点,此时需要将旧子节点列表中剩余的节点从真实 DOM 中移除。

// 后续会用到的工具函数简单说明
// isUndef 用于判断一个值是否为 undefined 或者 null
// canMove 是一个布尔类型的变量,用于表示是否允许移动 DOM 节点。在某些情况下,可能由于性能或者其他原因,不希望进行 DOM 节点的移动操作,这时 canMove 就会被设置为 false
// nodeOps 是一个包含了各种 DOM 操作方法的对象,它封装了不同平台下的 DOM 操作,使得 Vue 的代码可以在不同的环境(如浏览器、Weex 等)中都能正常工作。通过 nodeOps,Vue 可以实现对真实 DOM 的插入、删除、获取兄弟节点等操作。
// src/core/vdom/patch.js
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]

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx]
    } 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)) {
      // 旧头节点和新尾节点相同,进行更新并移动节点
      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)) {
      // 旧尾节点和新头节点相同,进行更新并移动节点
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 如果四种情况都不匹配,再进行其他处理
      // ...
    }
  }
}

总结 👇

  • 初始化指针 :为新旧子节点列表分别设置首尾指针。
  • 循环比较 :在oldStartIdx <= oldEndIdx 且 newStartIdx <= newEndIdx 条件下,持续比较:

    • 头头比较 :若旧头与新头节点相同,更新节点,指针后移。
    • 尾尾比较 :若旧尾与新尾节点相同,更新节点,指针前移。
    • 头尾比较 :若旧头与新尾节点相同,更新节点并移动 DOM 位置,旧头指针后移、新尾指针前移。
    • 尾头比较 :若旧尾与新头节点相同,更新节点并移动 DOM 位置,旧尾指针前移、新头指针后移。
    • 其他情况 :在旧列表找与新头节点 key 相同的节点,有则移动更新,无则创建新节点插入。
  • 循环结束处理

    • 若旧列表先遍历完,将新列表剩余节点插入 DOM。
    • 若新列表先遍历完,移除旧列表剩余节点对应的 DOM。

key 的作用

 key 作为节点的唯一标识,在 Diff 过程中能够帮助 Vue 2 准确地复用相同的节点。当节点有 key 时,Vue 可以根据 key 快速找到可复用的节点,避免不必要的 DOM 操作。
在 updateChildren 函数中,如果上述四种双端比较情况都不匹配,会尝试根据 key 来查找可复用的节点,源码如下:

// 后续会用到的工具函数简单说明
// isUndef 用于判断一个值是否为 undefined 或者 null
// canMove 是一个布尔类型的变量,用于表示是否允许移动 DOM 节点。在某些情况下,可能由于性能或者其他原因,不希望进行 DOM 节点的移动操作,这时 canMove 就会被设置为 false
// nodeOps 是一个包含了各种 DOM 操作方法的对象,它封装了不同平台下的 DOM 操作,使得 Vue 的代码可以在不同的环境(如浏览器、Weex 等)中都能正常工作。通过 nodeOps,Vue 可以实现对真实 DOM 的插入、删除、获取兄弟节点等操作。
// src/core/vdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // ...
  let oldKeyToIdx
  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 {
      // same key but different element. treat as new element
      createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    }
  }
  newStartVnode = newCh[++newStartIdx]
  // ...
}

function createKeyToOldIdx (children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}

 createKeyToOldIdx 函数会创建一个以 key 为键、节点索引为值的映射对象,方便根据 key 快速查找节点。

就地复用

就地复用是指在 Diff 过程中,尽可能复用现有的 DOM 节点,减少 DOM 操作。当找到可复用的节点时,Vue 会直接更新该节点的属性和内容,而不是重新创建一个新的节点。

4.jpg

就像我们上面这张图一样,li1 的 key 都为1,那么他们就能就地复用,不用重新创建删除节点,只是修改就像。
在 pathVnode 函数中实现了节点的就地复用,以下是简化后的源码:

// 后续会用到的工具函数简单说明
// isUndef 用于判断一个值是否为 undefined 或者 null
// nodeOps 是一个包含了各种 DOM 操作方法的对象,它封装了不同平台下的 DOM 操作,使得 Vue 的代码可以在不同的环境(如浏览器、Weex 等)中都能正常工作。通过 nodeOps,Vue 可以实现对真实 DOM 的插入、删除、获取兄弟节点等操作。
// src/core/vdom/patch.js
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  if (oldVnode === vnode) {
    return
  }

  const elm = vnode.elm = oldVnode.elm

  // 复用旧节点的 DOM 元素,更新节点的属性和内容
  if (isDef(vnode.data) && isDef(i = vnode.data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }
  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isDef(vnode.data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    if (isDef(i = vnode.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(vnode.data) && isDef(i = vnode.data.hook) && isDef(i = i.postpatch)) {
    i(oldVnode, vnode)
  }
}

 
在 patchVnode 函数中,直接复用了旧节点的 DOM 元素 oldVnode.elm,并更新其属性和内容,避免了重新创建 DOM 节点的开销。

三、总结

 Vue 的 Diff 算法通过虚拟 DOM 和双指针策略,高效地比较新旧虚拟 DOM 树的差异,减少了不必要的 DOM 操作,提高了页面的更新性能。在实际开发中,合理使用 key 可以进一步优化 Diff 算法的效率。之前写过一篇关于 key 的文章,感兴趣的可以去看看。