【Vue深入】之DIFF算法

352 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

vue深入系列包括以下内容,有兴趣的读者可以选择阅读:

【Vue深入】之虚拟DOM - 掘金 (juejin.cn)

【Vue深入】之生命周期 - 掘金 (juejin.cn)

【Vue深入】之路由router - 掘金 (juejin.cn)

【Vue深入】之响应式原理 - 掘金 (juejin.cn)

【Vue深入】之Vuex状态管理 - 掘金 (juejin.cn)

引言

现如今Vue已成为当下主流框架,其中一些核心概念更是面试须知,本文主要进行对DIFF算法的一些原理分析,希望能够对大家有所帮助。

Diff算法

什么是Diff算法

Diff算法其实是一种对比算法。对比的是旧虚拟DOM和新虚拟DOM,目的是对比出是哪个虚拟节点进行了更改,并找出这个虚拟节点,只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM,进而提高效率

核心方法patch

当新旧虚拟DOM进行对比的时候,Diff算法比较只会在同层级进行对比, 不会跨层级进行比较。 所以Diff算法是:深度优先算法

//patch方法作用就是,对比当前同层的虚拟节点是否为同一种类型的标签:
function patch(oldVnode, newVnode) {
  // 使用sameVnode函数比较是否为一个类型的节点
  if (sameVnode(oldVnode, newVnode)) {                 
    patchVnode(oldVnode, newVnode)                               // 是同一类型结点的话,继续进行深层比较
  } else {
    // 不是同一类型结点的话,直接将整个节点替换成新虚拟节点
    const oldEl = oldVnode.el                                     // 旧虚拟节点中的真实DOM节点
    const parentEle = api.parentNode(oldEl)                       // 获取父节点
    createEle(newVnode)                                           // 创建新虚拟节点所对应的真实DOM节点
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
      api.removeChild(parentEle, oldVnode.el)                     // 移除以前的旧元素节点      
      oldVnode = null                                             // 设置null,释放内存
    }
  }

  return newVnode
}

sameVnode方法:(判断是否为同一类型节点)

function sameVnode(oldVnode, newVnode) {
  return (
      oldVnode.key === newVnode.key &&                  // key值是否一样
      oldVnode.tagName === newVnode.tagName &&          // 标签名是否一样
      oldVnode.isComment === newVnode.isComment &&      // 是否都为注释节点
      isDef(oldVnode.data) === isDef(newVnode.data) &&  // 是否都定义了data
      sameInputType(oldVnode, newVnode)                 // 当标签为input时,type必须是否相同
  )
}

patchVnode方法:(是同一类型节点,则进行深层次比较)

function patchVnode(oldVnode, newVnode) {
  const el = newVnode.el = oldVnode.el                              // 获取真实DOM对象
  const oldCh = oldVnode.children, newCh = newVnode.children        // 获取新旧虚拟节点的子节点数组
  if (oldVnode === newVnode) return                                 // 如果新旧虚拟节点是同一个对象,则终止
  // 如果新旧虚拟节点是文本节点,并且文本内容不一致
  if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
    api.setTextContent(el, newVnode.text)                           // 则直接将真实DOM中文本更新为新虚拟节点的文本
  } else {
    // 文本内容一致的话
    if (oldCh && newCh && oldCh !== newCh) {                         // 新旧虚拟节点都有子节点,且子节点不一样
      updateChildren(el, oldCh, newCh)                               // 新旧虚拟节点的子节点对比,并更新
    } else if (newCh) {                                              // 新虚拟节点有子节点,旧虚拟节点没有
      createEle(newVnode)                                            // 创建新虚拟节点的子节点,并更新到真实DOM上去
    } else if (oldCh) {                                              // 旧虚拟节点有子节点,新虚拟节点没有 
      api.removeChild(el)                                            //直接删除真实DOM里对应的子节点
    }
  }
}

updateChildren方法:(新旧虚拟节点的子节点对比)

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0, 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
  let idxInOld
  let elmToMove
  let before
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode)
      api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode)
      api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }
      idxInOld = oldKeyToIdx[newStartVnode.key]
      if (!idxInOld) {
        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
        newStartVnode = newCh[++newStartIdx]
      }
      else {
        elmToMove = oldCh[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
        } else {
          patchVnode(elmToMove, newStartVnode)
          oldCh[idxInOld] = null
          api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
  if (oldStartIdx > oldEndIdx) {
    before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
    addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

新旧虚拟DOM进行对比的时候主要采用首尾指针法。

新的子节点集合和旧的子节点集合,各有首尾两个指针,举个例子:

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

那么新旧DOM的两个子节点集合以及其首尾指针分别为:

截屏2021-08-08 下午2.55.26.png

按照updateChildren方法判断逻辑会按顺序进行互相比较,总共有以下五种比较情况:

  • 1、oldS 和 newS 进行相同节点比较进行比较,sameVnode(oldS, newS)
  • 2、oldE 和 newE 进行相同节点比较进行比较,sameVnode(oldE, newE)
  • 3、oldS 和 newE 使进行相同节点比较进行比较,sameVnode(oldS, newE)(新前指向的节点,涉及移动节点,移动到旧后之后)
  • 4、oldE 和 newS 进行相同节点比较进行比较,sameVnode(oldE, newS)(新前指向的节点,涉及移动节点,移动到旧前之前)
  • 5、如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index表,然后用新 vnode 的 key去找出在旧节点中可以复用的位置。

截屏2021-08-08 下午2.57.22.png

接下来就以updateChildren方法为例,分析一下比较的过程

分析之前,请大家记住一点,最终的渲染结果都要以newVDOM为准,这也解释了为什么之后的节点移动需要移动到newVDOM所对应的位置

第一步:(1,2情况都没满足,满足第3种情况)

截屏2021-08-08 下午3.03.31.png

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

比较结果:oldS 和 newE 相等,需要把节点a移动到oldE之后,也就是末尾,同时oldS++,newE--

第二步:

截屏2021-08-08 下午3.26.25.png

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

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

第三步:

截屏2021-08-08 下午3.27.13.png

第四步:

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

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

截屏2021-08-08 下午3.31.48.png

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

截屏2021-08-08 下午3.37.51.png

更新画面

当数据发生改变时,Vue底层的响应式中的set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。

diff算法流程图

借鉴

结语

本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力。