图解Vue Diff算法

106 阅读10分钟

什么是虚拟DOM?

虚拟DOM是一个对象,用来表示真实DOM的对象。

<!-- 真实DOM -->
<ul class=“list”>
    <li class=“item”>a</li>
    <li class=“item”>b</li>
    <li class=“item”>c</li>
</ul>
// 对应的虚拟DOM
let listDom = {
    tagName: ‘ul’, // 标签名
    props: {
        class: ‘list‘ // 标签属性
    },
    children: [ // 标签子节点
        { tagName: ‘li’, props: { class: ‘item’ }, children: ‘a’ },
        { tagName: ‘li’, props: { class: ‘item’ }, children: ‘b’ },
        { tagName: ‘li’, props: { class: ‘item’ }, children: ‘c’ }
    ]
}

此时修改一个li的文本,把c修改为d

<ul class=“list”>
    <li class=“item”>a</li>
    <li class=“item”>b</li>
    <li class=“item”>d</li>
</ul>

这时生成新的虚拟DOM为:

let listNewDom = {
    tagName: ‘ul’, // 标签名
    props: {
        class: ‘list‘ // 标签属性
    },
    children: [ // 标签子节点
        { tagName: ‘li’, props: { class: ‘item’ }, children: ‘a’ },
        { tagName: ‘li’, props: { class: ‘item’ }, children: ‘b’ },
        { tagName: ‘li’, props: { class: ‘item’ }, children: ‘d’ }
    ]

}

这时候新的虚拟DOM是数据的最新状态。

问题:现在我拿这个新的虚拟DOM去渲染成真实DOM,效率会比直接操作真实DOM高吗?

截屏2023-10-20 11.53.44.png

通过这张图,我们可以发现,肯定是第二种方式比较快,因为第一种方式中间还夹杂着一个虚拟DOM的步骤,所以虚拟DOM比真实DOM快这句话是不严谨的,正确的说法是虚拟DOM算法操作真实DOM,性能高于直接操作真实DOM,虚拟DOM和虚拟DOM算法是两个概念。

虚拟DOM算法= 虚拟DOM + Diff算法

什么是Diff算法

前面我们提到,只更新一个li的文本内容,其他都是不变的,所以没必要所有的节点都更新,只要更新这个li标签就行,Diff算法就是要找出哪个li标签需要进行更新的算法。

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

使用虚拟DOM算法的损耗计算: 总损耗 = 虚拟DOM增删改查 + 真实DOM增删改查 + (可能较少节点)排版与重绘
直接操作真实DOM的损耗计算: 总损耗 = 真实DOM完全增删改查 + (可能较多的节点)排版与重绘

在这里简单说一下上面标出的使用虚拟DOM算法的排版和重绘的节点少的原因:

1.批量更新,虚拟DOM可以进行批量更新操作,通过比较差异,只对差异部分进行更新,不会每次都操作真实DOM。
2.虚拟DOM的内存操作,在虚拟DOM中,所有的操作都是在内存中运行的,而不是操作真实的DOM,而这也意味着在进行排版和重绘时,不会触发浏览器的回流和重绘操作,和两个操作时十分消耗性能的。相反,虚拟DOM会将所有的操作都集中在一起,然后一次性的将差异更新到真实DOM中,从而减少回流和重绘操作。

Diff算法的原理

diff的执行策略是:深度优先,同层比较

深度优先是指在比较过程中首先比较当前层级的节点,然后再递归地比较子节点。

截屏2023-10-20 11.58.04.png

Diff对比流程

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

newVnode 和 oldVnode: 是指同层的新旧虚拟节点

截屏2023-10-20 11.59.08.png

接下来分别介绍下图中的方法

patch方法

patch方法的作用,就是对比当前同层的虚拟节点是否为同一种类型的标签
是:继续执行patchVnode方法 进行深层对比
否:不再进行对比,直接整个节点替换成新虚拟节点

// patch的核心原理代码
function patch (oldVnode, newVnode) {
    // 比较是否为一个类型的节点
    if (sameVnode(oldVnode, newVnode)) {
        // 是:继续进行深层比较
        patchVnode(oldVnode, newVnode)
    } else {
        // 否
        const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
        const parentEle = api.parentNode(oldEl)  // 获取父节点
        createEle(newVnode)  // 创建新虚拟节点对应的真实DOM节点
        if (parental !== null) {
            api.insetBefore(parentEle, vnode.el, api.nextsibling(oEl)) // 将新元素添加到父元素

            api.removeChild(parentEle, oldVnode.el) // 移除以前的旧的元素节点

            oldVnode = null  // 设置null,释放内存
        }
    }
    return newVnode
}

sameVnode方法

patch关键的一步就是sameVnode方法判断是否为同一类型节点,那怎么才算同一类型节点呢,类型的标准是什么呢?

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必须是否相同
    }
}
  • Key是虚拟节点的一个特殊属性,用于在更新过程中标识节点的唯一性。如果连个key值的节点不同,被认为是不同节点。
  • 虚拟节点的tagName表示节点的标签名,例如‘div’ ‘span’ 等,如果两个标签名不同,就是不同节点。
  • 注释节点是一种特殊的节点类型,表示HTML中的注释,如果两个节点都是注释节点,被认为是相同类型的节点。
  • Data是虚拟节点的属性对象,包含节点的属性、事件等信息,如果两个节点都定义了data,说明都具有属性信息,否则认为是不同类型的节点。
  • 当标签为input时,他的type属性决定了输入框的类型,例如文本框、复选框、单选框等,如果他们的type不同,那么认为是不同节点。

patchVnode方法

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

patchVnode方法做了以下的事情,总结:

  • 找到对应的真实DOM,称为el
  • 判断newVnode和oldVnode是否指向同一个对象,如果是,直接return
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为newVnode的文本节点
  • 如果oldVnode有子节点而newVnode没有,则删除el的子节点
  • 如果oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点

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>

那么新旧两个子节点集合以及首尾指针为:

截屏2023-10-20 13.17.48.png

进行互相比较,共有5种比较情况:
1、oldS和newS使用sameVnode方法进行比较,sameVnode(oldS, newS)
2、oldS和newE使用sameVnode方法进行比较,sameVnode(oldS, newE)
3、oldE和newS使用sameVnode方法进行比较,sameVnode(oldE, newS)
4、oldE和newE使用sameVnode方法进行比较,sameVnode(oldE, newE)
5、如果以上逻辑都匹配不到,再把所有的旧子节点的key做一个映射到旧节点下标的key->index表,然后用新vnode的key去找出旧节点中可以复用的位置

实例分析

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

第一步

截屏2023-10-20 13.19.40.png oldS = a, oldE = c
newS = b, newE = a
比较结果:oldS 和 newE 相等,需要把节点a移动到newE所对应的位置,也就是末尾,同时oldS++,newE--

第二步

截屏2023-10-20 13.21.50.png oldS = b, oldE = c
newS = b, newE = e
比较结果:oldS 和 newS相等,需要把节点b移动到newS所对应的位置,同时oldS++,newS++

第三步

截屏2023-10-20 13.22.26.png oldS = c, oldE = c
newS = c, newE = e
比较结果:oldS、oldE 和 newS相等,需要把节点c移动到newS所对应的位置,同时oldS++,newS++

第四步

截屏2023-10-20 13.23.36.png

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

截屏2023-10-20 13.24.39.png 最后附上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 {
      // 使用key时的比较
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
      }
      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)
  }
}

用index做key

问题:平常v-for循环渲染的时候,为什么不建议用index作为循环项的key呢?
举个例子,上边是初始列表,然后我在列表前插入一条新数据d,变成下边的列表

<ul>                      
    <li key="0">a</li>        
    <li key="1">b</li>        
    <li key="2">c</li>        
</ul>
<ul>
    <li key="0">d</li>
    <li key="1">a</li>
    <li key="2">b</li>
    <li key="3">c</li>
</ul>

按理说,最理想的结果是:只插入一个li标签新节点,其他都不动,确保操作DOM效率最高。但是我们这里用了index来当key的话,和理想结果是有出入的:

<ul>
   <li v-for="(item, index) in list" :key="index">{{ item.title }}</li>
</ul>
<button @click="add">增加</button>
list: [
  { title: "a", id: "a" },
  { title: "b", id: "d" },
  { title: "c", id: "c" }
]
add() {
  this.list.unshift({ title: "d", id: "d" });
}

点击查看运行结果
点击按钮我们可以看到,并不是我们预想的结果,而是所有li标签都更新了,为什么会这样呢?通过下图来解释
按理说,a,b,c三个li标签都是复用之前的,因为他们三个根本没改变,改变的只是前面新增了一个d

截屏2023-10-20 13.33.38.png

但是我们前面说了,在进行子节点的diff算法过程中,会进行旧首节点和新首节点的sameNode对比,这一步命中了逻辑,因为现在新旧两次首部节点的key 都是 0了,同理,key为1和2的也是命中了逻辑,导致相同key的节点会去进行patchVnode更新文本,而原本就有的c节点,却因为之前没有key为4的节点,而被当做了新节点,所以使用index做key,最后新增的却是本来就已有的c节点。所以前三个都进行patchVnode更新文本,最后一个进行了新增,那就解释了为什么所有li标签都更新了。

截屏2023-10-20 13.35.31.png

那我们可以怎么解决呢?其实我们只要使用一个独一无二的值来当做key就行了

<ul>
   <li v-for="item in list" :key="item.id">{{ item.title }}</li>
</ul>

再来看看效果 点击查看运行效果
为什么用了id来当做key就实现了我们的理想效果呢,因为这么做的话,a,b,c节点的key就会是永远不变的,更新前后key都是一样的,并且又由于a,b,c节点的内容本来就没变,所以就算是进行了patchVnode,也不会执行里面复杂的更新操作,节省了性能,而d节点,由于更新前没有他的key所对应的节点,所以他被当做新的节点,增加到真实DOM上去了。

截屏2023-10-20 13.37.57.png