Vue diff 算法核心原理解析

640 阅读6分钟

虚拟 DOM 介绍

虚拟DOM是使用JS对象标识DOM元素及结构
真实DOM

<ul id="list">
  <li class="item">项目一</li>
  <li class="item">项目二</li>
  <li class="item">项目三</li>
</ul>

对应的虚拟DOM

const oldVDOM = { // 旧虚拟DOM
  tagName: 'ul',  // 标签名
  props: {  // 标签属性
    id: 'list'
  },
  children: [  // 标签子节点
    {
      tagName: 'li', props: { class: 'item' }, children: ['项目一']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['项目二']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['项目三']
    }
  ]
}

这时候如果我们修改了一个li标签的内容,变成如下DOM

<ul id="list">
  <li class="item">项目一</li>
  <li class="item">项目二</li>
  <li class="item">项目四</li>
</ul>

这时会生成新的虚拟DOM,如下所示:

const newVDOM = { // 旧虚拟DOM
  tagName: 'ul',  // 标签名
  props: {  // 标签属性
    id: 'list'
  },
  children: [  // 标签子节点
    {
      tagName: 'li', props: { class: 'item' }, children: ['项目一']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['项目二']
    },
    {
      tagName: 'li', props: { class: 'item' }, children: ['项目四']
    }
  ]
}

这样就形成了新旧两个虚拟DOM,如果我们直接用修改后生成的新的DOM去进行渲染,效率是不会比直接操作真实DOM快的,如下图:

image.png

由此可见,如果仅仅有虚拟DOM,是不会比直接操作DOM节点进行渲染来的快的,还需要有一个diff算法,能让我们比较出新旧两个虚拟DOM的差异,从而只对这部分差异进行对应的更新操作

虚拟DOM算法 = 虚拟DOM + diff算法

什么是 diff 算法

image.png diff算法是一种对比差异的算法,通过比较新、旧虚拟DOM节点,找出哪些虚拟节点有修改,然后去更新对应的真实DOM,提高效率和性能。
使用虚拟DOM算法的损耗计算: 总损耗 = 虚拟DOM增删改+(与Diff算法效率有关)真实DOM差异增删改+(较少的节点)排版与重绘
直接操作真实DOM的损耗计算: 总损耗 = 真实DOM完全增删改+(可能较多的节点)排版与重绘

diff 算法的原理分析

diff 同层对比

因为我们基本上不会把一个节点进行跨层级移动,所以当新旧DOM节点进行对比的时候,采用深度遍历优先,只进行同层级比较,不进行跨级比较。时间复杂度为O(n) image.png

diff 对比流程

当数据改变时,会触发数据的setter,并通过Dep.notify()去通知所有订阅者更新Watcher,订阅者们就会在更新视图前,调用patch方法,给真实的DOM打补丁,更新响应的视图。

image.png

patch 方法

该方法会比较同层的新旧两个节点是否是同一种类型的vnode,如果是同一种类型,则执行patchVnode方法去进行比较,如果不是同一个类型的,则直接用新节点替换旧的节点。
核心代码如下:

function patch (oldVnode, vnode, hydrating, removeOnly) {
    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // 组件初始化时,没有 oldVnode
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 是同类型的节点
      if (sameVnode(oldVnode, vnode)) {
        // 进行深层比较
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        // 不是同类型的节点
        const oldElm = oldVnode.elm
        // 获取父元素
        const parentElm = nodeOps.parentNode(oldElm)
        // 创建新节点,插入到父元素中
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

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

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

sameVnode 方法

patch过程中,重要的一步就是判断新旧两个vnode是否是同一类型的节点,sameVnode方法的代码如下:

function sameVnode (a, b) {
  return (
    a.key === b.key &&       // key 值是否一样
    a.asyncFactory === b.asyncFactory && (      
      (
        a.tag === b.tag &&       // 标签是否一样
        a.isComment === b.isComment &&     // 是否都是注释
        isDef(a.data) === isDef(b.data) &&     // 是否都定义了 data
        sameInputType(a, b)      // 都是 input 时,type 是否相同
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

patchVnode 方法

该方法主要做了以下几点工作

  • 判断oldVnodevnode是否相等,如果相等直接返回
  • 获取真实的DOM,定义为elm
  • 如果新节点没有文本节点,对比oldVnodevnode的子节点
  • 如果oldVnodevnode的都有子节点且不相等,调用updateChildren对比子节点并更新
  • 新节点有子节点,同时旧节点由文本节点,将文本清空,在elm中插入新节点的子节点
  • 新节点没有子节点,旧节点存在子节点,直接将旧节点的子节点移除
  • 旧节点有文本节点,清空文本
  • 新旧节点都有文本节点且不相等,直接修改元素文本节点为新节点的文本值
function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    if (oldVnode === vnode) {
      return
    }
    
    // 获取真实的 DOM
    const elm = vnode.elm = oldVnode.elm

    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
    // 新节点没有 text
    if (isUndef(vnode.text)) {
      // 新旧节点都有子节点
      if (isDef(oldCh) && isDef(ch)) {
        // 旧节点的子节点不等于新节点的子节点,调用 updateChildren 方法对比子节点并更新
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        // 新节点有子节点,同时旧节点由文本节点,将文本清空
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        // 在 dom 中插入新节点的子节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 新节点没有子节点,旧节点存在子节点,直接将旧节点的子节点移除
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 旧节点有文本节点,清空文本
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) { // 新旧节点 text 不相等
      // 直接修改元素文本为新节点的文本值
      nodeOps.setTextContent(elm, vnode.text)
    }
}

updateChildren 方法

通过updateChildren方法,对子节点进行更新操作,该方法使用了首尾指针法进行对比,新旧节点各有首尾两个指针,例子如下;

<ul>
    <li>a</li>
    <li>b</li>
    <li>c</li>
</ul>

修改数据后

<ul>
    <li>b</li>
    <li>c</li>
    <li>e</li>
    <li>a</li>
</ul>

新旧节点集合及首尾指针如下图:

image.png

首尾指针对比,主要有以下几种情况:

  • oldStartIdx对应的oldS未定义,指针后移一位,更新oldS
  • oldEndVnode对应的oldE未定义,指针前移一位,更新oldE
  • 对比oldSnewSsameVnode(oldS, newS)
  • 对比oldEnewEsameVnode(oldE, newE)
  • 对比oldSnewEsameVnode(oldS, newE)
  • 对比oldEnewSsameVnode(oldE, newS)
  • 如果以上逻辑都未匹配到,则把所有旧节点的子节点的keyindex做一个映射,然后用新的vnodekey找到可以复用的旧节点

image.png

我们通过代码分析一下对比过程:

image.png

第一步比较:

oldS = a, oldE = c
newS = b, newE = a

比较结果:oldS 和 newE 相等,需要把节点a移动到newE所对应的位置,也就是末尾,同时oldS++newE--

image.png

第二步比较:

oldS = b, oldE = c
newS = b, newE = e

比较结果:oldS 和 newS相等,需要把节点b移动到newS所对应的位置,同时oldS++newS++

image.png

第三步比较:

oldS = c, oldE = c
newS = c, newE = e

比较结果:oldS、oldE 和 newS相等,需要把节点c移动到newS所对应的位置,同时oldS++newS++

image.png

第四步: oldS > oldE,则oldCh先遍历完成了,而newCh还没遍历完,说明newCh比oldCh多,所以需要将多出来的节点,插入到真实DOM上对应的位置上

image.png

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

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

为什么不要用 index 做 key

我们平时开发列表的时候,最好不用indexkey,因为一旦列表中有插入元素,会导致列表中的其他元素没变,但是他们的index变了,也就导致key发生了变化,导致两个不同的元素,有了相同的key,会进行patchVnode更新节点的文本,无法进行复用。所以最要用独一无二的标识做元素的key