「学习记录」浅学一下vue2的双端diff算法

505 阅读11分钟

前提

最近vue官方团队为vue2.x进行了最后一次版本的更新

这是一个vue2与vue3之间的一个过渡版本

这也预示着vue2的在未来很长的一段时间里都不会有大的改动

虽然vue3已经非常优秀了,但vue2的源码依然有值得我们去研究的地方

这里我们浅学一下vue2的双端diff算法

看看vue2里是如何实现Vdom的diff

目标

  1. 了解该算法的比较过程
  2. 了解我们在日常开发中应该如何提高diff的效率

文章参考以及示例代码

参考:

霍春阳《vue设计与实现》

演示代码:

vue2.7版本的源码,详细见GitHub

什么是虚拟dom

在如今最热门的几个前端框架中,不管是react还是vue2,或者是vue3 都引入虚拟dom的概念

在这里我们不深入去研究虚拟dom给前端框架带来的优势

只需要明白虚拟dom是什么东西,这是diff算法的一个前置知识

简单来说,虚拟dom就是一个js对象,通常包含tag,props,children三个属性

用来描述一个具体的dom结构

tag为一个dom节点的标签名称,如div,p

props为一个标签的属性

children为该标签节点内的标签

举个例子,如下的dom结构

<div id="app">
  <p class="text">hello</p>
  <span style="color:red;">vdom</span>
</div>

可以用如下的js对象来描述

{
  tag: 'div',
  props: {
    id: 'app'
  },
  chidren: [
    {
      tag: 'p',
      props: {
        className: 'text'
      },
      chidren: [
        'hello'
      ]
    },{
      tag: 'span',
      props: {
        style: 'color:red;'
      },
      chidren: [
        'vdom'
      ]
    }
  ]
}


可见 我们可以使用js对象去描述一整个dom树,其中每一个虚拟dom节点,我们称之为vnode

我们的前端框架中的渲染器模块就是利用这个vdom树去进行页面内容的渲染

为何需要diff算法

所谓diff算法,就是用来比较新旧两棵vdom树差异的算法

如果我们页面每一次的变动,都销毁整个dom,并根据新的vdom重新渲染页面

这会是非常大的一笔性能开销,会造成不好的用户体验

所以diff算法应运而生,根据新旧差异来更新视图,减少的dom的操作,把性能压力都放到js的比较计算中

这非常有效地提高页面更新效率

vue2的双端diff算法

在解读vue2的双端diff算法前 我们先抛出一些diff算法的前置知识:

  1. 两棵vdom树只做同层比较

  2. tag的类型变了就不再对比子节点

  3. 尽可能减少dom的操作次数

我们知道,两棵vdom树,如果旧vdom树中每一个节点都与新vdom比较的话,时间复杂度是O(n2),再加上如果每个节点都进dom的操作那就是O(n3),这对于前端来说,是不可用的算法。

而vue2的双端diff算法中的双端diff算法则是利用了上面的3个关键点,把时间复杂度降低到O(n),使得算法可用。

双端diff算法图解

在看代码前我们先用图解演示一遍双端diff算法的过程

首先我们需要有同层的新旧vdom子节点两组,还需要一组来展示当前真实dom的情况

双端diff的步骤可以分成3步:

  1. 双端节点比较
  2. 非常规情况处理
  3. 边界情况处理

第一步:双端节点比较

双端节点比较,这种情况是比较常规的,我们通过新旧头尾4个节点的互相比较,找到可以复用的节点,判断是否需要进行移动,如下图

image.png

可以明显看到新首指针p4与旧尾指针p4是同一个节点,可进行复用,我们操作真实dom把p4移动到p1前面,即可完成这个diff算法更新操作。可以看到真实dom与新vdom已经一致了。

第二步:非常规情况处理

这种情况是在第一步双端节点比较没有符合的情况时进行的

我们以新节点第一个为标准,去旧子节点里面找是否有可以复用的节点,如有则移动,否则创建一个新的插入,如下图:

image.png

这里我们直接在旧节点中找到可复用节点p3并移动,最后真实的dom变成了p3->p2->p1->p4

然后指针移动,继续使用第一步中的比较,发现新首指针p2与旧首指针p2是同一个节点,直接原地复用

image.png

接着首指针继续移动,这里又遇到了第一步的情况,旧p4是可复用的节点,我们直接移动它到p1上面

image.png

到此,结合第二步与第一步的操作,让真实dom节点完成了更新,与新vdom保持了一致

第三步:边界情况处理

这种情况是在第一步和第二步都遍历处理完后,对剩余节点的处理

如果新节点有未创建的节点则创建并插入;如果旧节点有未被复用的节点,则清除

如下图:

经过不断重复第一步和第二步,p4是多余节点,我们移除它

image.png

经过不断重复第一步和第二步,p3是新vdom中未创建的节点,我们在最后创建并插入到p4前面

image.png

源码中diff算法的位置

我们把源码clone下来后,在以下路径

src\core\vdom\patch.ts

找到我们的patch.ts文件,这是vdom更新的核心模块

我们可以看到,文件里面有很多工具函数,而这个patch.ts文件主要export出一个patch函数,提供给实例进行新旧vdom的diff

我们找到双端diff算法的核心函数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 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 (__DEV__) {
      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,
          newCh,
          newStartIdx
        )
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(
          oldEndVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        )
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // Vnode moved right
        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
        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 {
          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]
      }
    }
    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)
    }
  }

接下来我们开始分析这段代码

updateChildren的入参与变量初始化

我们先来看看函数入参的含义

  1. parentElm 父元素
  2. oldCh 旧子节点
  3. newCh 新子节点
  4. insertedVnodeQueue 记录下所有新插入的节点以备调用(可以不过分关注)
  5. 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]
// oldKeyToIdx记录节点key与下标的映射关系
// idxInOld记录通过key找到的节点下标
// vnodeToMove记录需要移动的节点
// refElm记录dom节点的引用
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// 可以进行移动(可以不过分关注)
const canMove = !removeOnly

接下来,我们配合着算法图解去理解代码

双端指针进行比较

根据图解,我们知道接下来要对节点进行双端比较

在代码中,使用了一连串的if-else条件语句完成这部分的比较

if (sameVnode(oldStartVnode, newStartVnode)) { 
    // 旧头 与 新头 节点比较
    // do something...
} else if (sameVnode(oldEndVnode, newEndVnode)) {
    // 旧尾 与 新尾 节点比较
    // do something...
} else if (sameVnode(oldStartVnode, newEndVnode)) {
    // 旧头 与 新尾 节点比较
   	// do something...    
} else if (sameVnode(oldEndVnode, newStartVnode)) {
    // 旧尾 与 新头 节点比较
   // do something...     
}

这里我们看到一个工具函数sameVnode,用于判断这两个节点是否是同一个节点,也是能否复用该节点的标准

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

sameVnode内部判断的机制比较复杂,还涉及到input标签的处理等等一些边界条件,我们只需要知道这个sameVnode是作用于两节点比较即可,目前可以简单理解为当 a.key === b.key值与a.tag === b.tag两值相等的时候,sameVnode返回true,注意key 未定义时为undefined

旧头新头比较,旧尾新尾比较

接下来看旧头与新头节点比较,旧尾与新尾节点比较的结果处理

如果旧头与新头节点,旧尾与新尾节点的sameVnode结果为true,证明已经找到了可以复用的节点

看源码中做了什么

if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(
          oldStartVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        )
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(
          oldEndVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        )
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      }

这里调用了patchVnode函数,这其实是一个递归,由于我们已经找到了可以复用的节点,所以我们得对这两个节点的子节点也进行diff处理,本质上也是调用了updateChildren

然后我们要进行对应的指针的移动

旧头新尾比较,旧尾新头比较

这两个情况与上面的比较类似,当sameVnode为true的时候,进行节点操作

if (sameVnode(oldStartVnode, newStartVnode)) {
    // do something...   
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
          // do something...   
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // Vnode moved right
        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
        patchVnode(
          oldEndVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        )
        canMove &&
          nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      }

与之前两种情况类似,我们依然调用了patchVnode函数进行子节点的diff

与之前两种情况不同的是,旧头新尾比较,旧尾新头比较的节点更新采用的是dom节点移动的方式

nodeOps.insertBefore与nodeOps.nextSibling是vue框架开源给开发者们的一个节点方法的集合

这里抽象出来是为了便于利用vdom进行跨端平台的定制开发,我们可以不过分关注

关于web端的节点操作我们可以看

src\platforms\web\runtime\node-ops.ts

export function insertBefore(
  parentNode: Node,
  newNode: Node,
  referenceNode: Node
) {
  parentNode.insertBefore(newNode, referenceNode)
}

export function nextSibling(node: Node) {
  return node.nextSibling
}

这里我们只需要知道insertBefore的作用结果即可,即把某个节点插入到指定节点的前面。

在移动完真实节点后,我们同样也要进行指针移动

非常规情况下的处理

我们根据diff图解的方式来分析了源码中4种节点比较情况

接下来继续分析源码是如何处理非常规情况下的节点处理

if {
 // do something...  
} else {
        if (isUndef(oldKeyToIdx))
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
         // do something...  
 }

我们可以看到先调用了createKeyToOldIdx创建了一个以node的key 为key ,索引下标为value的对象,将它保存为oldKeyToIdx

其实这里就是建立一个key和下标之间的映射关系,方便后面的查找

这里有个小细节,代码中if (isUndef(oldKeyToIdx))的情况满足时才建立这个映射关系

即这个映射关系只建立一次,后续节点移动也还是依靠这个来查找节点

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
}

接下来就是找到idxInOld,顾名思义就是在找到新的头节点在所有旧子节点中的索引位置

这里有两种情况,使用了一个三目运算符进行区分

  1. 当节点存在key,则在映射关系oldKeyToIdx中找

  2. 当节点不存在key,则使用findIdxInOld函数去找到对应的idx

findIdxInOld作用是在未移动的旧节点范围内找到对应的可复用的节点,并返回索引下标

  function findIdxInOld(node, oldCh, start, end) {
    for (let i = start; i < end; i++) {
      const c = oldCh[i]
      if (isDef(c) && sameVnode(node, c)) return i
    }
  }

ok现在我们找到了索引下标

接下来我们来进行移动,新增的操作

if{
// do something...  
} 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 {
          // do something...  
        }
        newStartVnode = newCh[++newStartIdx]
      }

先来看上面的第一种情况,即idxInOld的结果为undefined的情况时,直接调用createElm去创建一个新的dom并插入并插入到oldStartVnode前面

这里我们就不展开讨论createElm

当idxInOld的结果不为undefined时,代码处理如下

if{
// do something...  
} 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
          // do something... 
        } else {
          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]
      }

这里对idxInOld的结果不为undefined的情况多做了一次sameVnode的判断

先找到需要移动的节点vnodeToMove,然后使用sameVnode让vnodeToMove与newStartVnode进行比较

这里其实主要是比较节点的tag是否相同,因为前面我们是根据key来找到的旧节点,所以key值一定是相同的

  1. 当tag相同时,复用该旧节点,调用patchVnode与nodeOps.insertBefore进行子节点diff与移动,这里注意oldCh[idxInOld] = undefined需要把原来旧节点的vnode位置设置为undefined,避免数组长度崩塌
  2. 当tag不同时,直接调用createElm创建新节点,并插入到oldStartVnode前面

最后操作完,需要把newStartIdx指针后移动一位

循环结束条件与边界处理

我们知道双端指针算法肯定需要用到循环,这里就需要设置一个循环结束条件

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 结束条件1:oldStartIdx <= oldEndIdx
	// 结束条件2:newStartIdx <= newEndIdx
}

即有两个前后指针重合时,循环结束

我们使用该循环语句包裹上面的diff比较过程,来重复进行diff操作

但循环结束后还是会存在剩余的旧节点

下面来看看如何处理

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

当oldStartIdx > oldEndIdx时,即旧子节点已经复用完,新节点还有未创建的节点,这里调用addVnodes遍历去创建剩余的节点并插入

  function addVnodes(
    parentElm,
    refElm,
    vnodes,
    startIdx,
    endIdx,
    insertedVnodeQueue
  ) {
    for (; startIdx <= endIdx; ++startIdx) {
      createElm(
        vnodes[startIdx],
        insertedVnodeQueue,
        parentElm,
        refElm,
        false,
        vnodes,
        startIdx
      )
    }
  }

当newStartIdx > newEndIdx时,即新子节点已经创建完,旧节点还有剩余未使用的节点,这里调用removeVnodes去删除剩余的节点

  function removeVnodes(vnodes, startIdx, endIdx) {
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      if (isDef(ch)) {
        if (isDef(ch.tag)) {
          removeAndInvokeRemoveHook(ch)
          invokeDestroyHook(ch)
        } else {
          // Text node
          removeNode(ch.elm)
        }
      }
    }
  }

至此,一个diff算法的代码实现已经完成。

让diff算法更高效

在日常开发中如何编写代码可以让diff算法更高效的运作呢?

我们得合理运用我们的key值

使用v-for时不要使用index当key

为何不要使用index当key呢,因为在日常开发中,我们通常需要对列表数据进行操作,这大概率会影响到一个数组的index值,所以这个index值并不是一个稳定不变的值。

由于index一直在变,在diff算法的可复用节点的比较中,会影响到能否找到真的可以复用的节点,同时也会影响到后续递归的diff过程,这是非常低效的。

不要用随机数作为key

为何不要用随机数作为key呢,具体描述是每次更新时的产生的随机数都不一样,会造成每次diff的key都不一样,根据diff算法中的相同节点判定,key值不同就不是可复用节点,这就会造成每次更新都会创建新的节点,销毁旧的节点,造成diff算法非常低效。