一文详解Vue 的双端 diff 算法

目录
  • 前言
  • diff 算法
  • 简单 diff
  • 双端 diff
  • 总结

前言

Vue 和 React 都是基于 vdom 的前端框架,组件渲染会返回 vdom,渲染器再把 vdom 通过增删改的 api 同步到 dom。

当再次渲染时,会产生新的 vdom,渲染器会对比两棵 vdom 树,对有差异的部分通过增删改的 api 更新到 dom。

这里对比两棵 vdom 树,找到有差异的部分的算法,就叫做 diff 算法。

diff 算法

我们知道,两棵树做 diff,复杂度是 O(n^3) 的,因为每个节点都要去和另一棵树的全部节点对比一次,这就是 n 了,如果找到有变化的节点,执行插入、删除、修改也是 n 的复杂度。所有的节点都是这样,再乘以 n,所以是 O(n * n * n) 的复杂度。

这样的复杂度对于前端框架来说是不可接受的,这意味着 1000 个节点,渲染一次就要处理 1000 * 1000 * 1000,一共 10 亿次。

所以前端框架的 diff 约定了两种处理原则:只做同层的对比,type 变了就不再对比子节点。

因为 dom 节点做跨层级移动的情况还是比较少的,一般情况下都是同一层级的 dom 的增删改。

这样只要遍历一遍,对比一下 type 就行了,是 O(n) 的复杂度,而且 type 变了就不再对比子节点,能省下一大片节点的遍历。另外,因为 vdom 中记录了关联的 dom 节点,执行 dom 的增删改也不需要遍历,是 O(1)的,整体的 diff 算法复杂度就是 O(n) 的复杂度。

1000 个节点渲染一次最多对比 1000 次,这样的复杂度就是可接受的范围了。但是这样的算法虽然复杂度低了,却还是存在问题的。

比如一组节点,假设有 5 个,类型是 ABCDE,下次渲染出来的是 EABCD,这时候逐一对比,发现 type 不一样,就会重新渲染这 5 个节点。

而且根据 type 不同就不再对比子节点的原则,如果这些节点有子节点,也会重新渲染。dom 操作是比较慢的,这样虽然 diff 的算法复杂度是低了,重新渲染的性能也不高。

所以,diff 算法除了考虑本身的时间复杂度之外,还要考虑一个因素:dom 操作的次数。

上面那个例子的 ABCDE 变为 EABCD,很明显只需要移动一下 E 就行了,根本不用创建新元素。

但是怎么对比出是同个节点发生了移动呢?

判断 type 么? 那不行,同 type 的节点可能很多,区分不出来的。最好每个节点都是有唯一的标识。

所以当渲染一组节点的时候,前端框架会让开发者指定 key,通过 key 来判断是不是有点节点只是发生了移动,从而直接复用。这样,diff 算法处理一组节点的对比的时候,就要根据 key 来再做一次算法的优化。我们会把基于 key 的两组节点的 diff 算法叫做多节点 diff 算法,它是整个 vdom 的 diff 算法的一部分。

接下来我们来学习一下多节点 diff 算法:

简单 diff

假设渲染 ABCD 一组节点,再次渲染是 DCAB,这时候怎么处理呢?

多节点 diff 算法的目的是为了尽量复用节点,通过移动节点代替创建。

所以新 vnode 数组的每个节点我们都要找下在旧 vnode 数组中有没有对应 key 的,有的话就移动到新的位置,没有的话再创建新的。

也就是这样的:

const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    let j = 0
    let find = false
    // 遍历旧的 children
    for (j; j < oldChildren.length; j++) {
      const oldVNode = oldChildren[j]
      // 如果找到了具有相同 key 值的两个节点,则调用 patch 函数更新
      if (newVNode.key === oldVNode.key) {
        find = true
        patch(oldVNode, newVNode, container)

        处理移动...

        break //跳出循环,处理下一个节点
      }
   }
   // 没有找到就是新增了
   if (!find) {
      const prevVNode = newChildren[i - 1]
      let anchor = null
      if (prevVNode) {
        anchor = prevVNode.el.nextSibling
      } else {
        anchor = container.firstChild
      }
      patch(null, newVNode, container, anchor)
   }
}

这里的 patch 函数的作用是更新节点的属性,重新设置事件监听器。如果没有对应的旧节点的话,就是插入节点,需要传入一个它之后的节点作为锚点 anchor。

我们遍历处理新的 vnode:

先从旧的 vnode 数组中查找对应的节点,如果找到了就代表可以复用,接下来只要移动就好了。如果没找到,那就执行插入,锚点是上一个节点的 nextSibling。

那如果找到了可复用的节点之后,那移动到哪里呢?其实新的 vnode 数组中记录的顺序就是目标的顺序。所以把对应的节点按照新 vnode 数组的顺序来移动就好了

const prevVNode = newChildren[i - 1]
if (prevVNode) {
    const anchor = prevVNode.el.nextSibling
    insert(newVNode.el, container, anchor)
}

要插入到 i 的位置,那就要取 i-1 位置的节点的 nextSibling 做为锚点来插入当前节点。

但是并不是所有的节点都需要移动,比如处理到第二个新的 vnode,发现它在旧的 vnode 数组中的下标为 4,说明本来就是在后面了,那就不需要移动了。反之,如果是 vnode 查找到的对应的旧的 vnode 在当前 index 之前才需要移动。

也就是这样:

let j = 0
let find = false
// 遍历旧的 children
for (j; j < oldChildren.length; j++) {
    const oldVNode = oldChildren[j]
    // 如果找到了具有相同 key 值的两个节点,则调用 patch 函数更新之
    if (newVNode.key === oldVNode.key) {
        find = true
        patch(oldVNode, newVNode, container)

        if (j < lastIndex) { // 旧的 vnode 数组的下标在上一个 index 之前,需要移动
          const prevVNode = newChildren[i - 1]
          if (prevVNode) {
            const anchor = prevVNode.el.nextSibling
            insert(newVNode.el, container, anchor)
          }
        } else {// 不需要移动
          // 更新 lastIndex
          lastIndex = j
        }
        break
    }
}

查找新的 vnode 在旧的 vnode 数组中的下标,如果找到了的话,说明对应的 dom 就是可以复用的,先 patch 一下,然后移动。

移动的话判断下下标是否在 lastIndex 之后,如果本来就在后面,那就不用移动,更新下 lastIndex 就行。如果下标在 lastIndex 之前,说明需要移动,移动到的位置前面分析过了,就是就是新 vnode 数组 i-1 的后面。这样,我们就完成了 dom 节点的复用和移动。

新的 vnode 数组全部处理完后,旧的 vnode 数组可能还剩下一些不再需要的,那就删除它们:

// 遍历旧的节点
for (let i = 0; i < oldChildren.length; i++) {
    const oldVNode = oldChildren[i]
    // 拿着旧 VNode 去新 children 中寻找相同的节点
    const has = newChildren.find(
      vnode => vnode.key === oldVNode.key
    )
    if (!has) {
      // 如果没有找到相同的节点,则移除
      unmount(oldVNode)
    }
}

这样,我们就完成了两组 vnode 的 diff 和对应 dom 的增删改。

小结一下:

diff 算法的目的是根据 key 复用 dom 节点,通过移动节点而不是创建新节点来减少 dom 操作。

对于每个新的 vnode,在旧的 vnode 中根据 key 查找一下,如果没查找到,那就新增 dom 节点,如果查找到了,那就可以复用。

复用的话要不要移动要判断下下标,如果下标在 lastIndex 之后,就不需要移动,因为本来就在后面,反之就需要移动。

最后,把旧的 vnode 中在新 vnode 中没有的节点从 dom 树中删除

这就是一个完整的 diff 算法的实现:

这个 diff 算法我们是从一端逐个处理的,叫做简单 diff 算法。简单 diff 算法其实性能不是最好的,比如旧的 vnode 数组是 ABCD,新的 vnode 数组是 DABC,按照简单 diff 算法,A、B、C 都需要移动。

那怎么优化这个算法呢?从一个方向顺序处理会有这个问题,那从两个方向同时对比呢?

这就是双端 diff 算法:

双端 diff

简单 diff 算法能够实现 dom 节点的复用,但有的时候会做一些没必要的移动。双端 diff 算法解决了这个问题,它是从两端进行对比。

我们需要 4 个指针,分别指向新旧两个 vnode 数组的头尾:

头和尾的指针向中间移动,直到 oldStartIdx <= oldEndIdx 并且 newStartIdx <= newEndIdx,说明就处理完了全部的节点。

每次对比下两个头指针指向的节点、两个尾指针指向的节点,头和尾指向的节点,是不是 key是一样的,也就是可复用的。如果是可复用的话就直接用,调用 patch 更新一下,如果是头尾这种,还要移动下位置。

也就是这样的:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (oldStartVNode.key === newStartVNode.key) { // 头头
    patch(oldStartVNode, newStartVNode, container)
    oldStartVNode = oldChildren[++oldStartIdx]
    newStartVNode = newChildren[++newStartIdx]
  } else if (oldEndVNode.key === newEndVNode.key) {//尾尾
    patch(oldEndVNode, newEndVNode, container)
    oldEndVNode = oldChildren[--oldEndIdx]
    newEndVNode = newChildren[--newEndIdx]
  } else if (oldStartVNode.key === newEndVNode.key) {//头尾,需要移动
    patch(oldStartVNode, newEndVNode, container)
    insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)

    oldStartVNode = oldChildren[++oldStartIdx]
    newEndVNode = newChildren[--newEndIdx]
  } else if (oldEndVNode.key === newStartVNode.key) {//尾头,需要移动
    patch(oldEndVNode, newStartVNode, container)
    insert(oldEndVNode.el, container, oldStartVNode.el)

    oldEndVNode = oldChildren[--oldEndIdx]
    newStartVNode = newChildren[++newStartIdx]
  } else {
    // 头尾没有找到可复用的节点
  }
}

头头和尾尾的对比比较简单,头尾和尾头的对比还要移动下节点。比如旧 vnode 的头节点是新的 vnode 的尾节点,那就要把它移动到旧的 vnode 的尾节点的位置。

也就是:

insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)

插入节点的锚点节点是 oldEndVNode 对应的 dom 节点的 nextSibling。如果旧 vnode 的尾节点是新 vnode 的头结点,那就要把它移动到旧 vnode 的头结点的位置。

也就是:

insert(oldEndVNode.el, container, oldStartVNode.el)

插入节点的锚点节点是 oldStartVNode 对应的 dom 节点(因为要插在它之前)。从双端进行对比,能尽可能的减少节点移动的次数。

当然,还要处理下如果双端都没有可复用节点的情况:

如果双端都没有可复用节点,那就在旧节点数组中找,找到了就把它移动过来,并且原位置置为 undefined。没找到的话就插入一个新的节点。

也就是这样:

const idxInOld = oldChildren.findIndex(
  node => node.key === newStartVNode.key
)
if (idxInOld > 0) {
  const vnodeToMove = oldChildren[idxInOld]
  patch(vnodeToMove, newStartVNode, container)
  insert(vnodeToMove.el, container, oldStartVNode.el)
  oldChildren[idxInOld] = undefined
} else {
  patch(null, newStartVNode, container, oldStartVNode.el)
}

因为有了一些 undefined 的节点,所以要加上空节点的处理逻辑:

if (!oldStartVNode) {
    oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
    oldEndVNode = newChildren[--oldEndIdx]
}

这样就完成了节点的复用和移动的逻辑。那确实没有可复用的节点的那些节点呢?

经过前面的移动之后,剩下的节点都被移动到了中间,如果新 vnode 有剩余,那就批量的新增,如果旧 vnode 有剩余那就批量的删除。因为前面一个循环的判断条件是 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx,这样如果 old vnode 多了,最后 newStartIdx 会小于 newEndIdx。如果 new vnode 多了,最后 oldStartIdx 会小于 oldEndIdx。

所以判断条件是这样的:

if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
  // 添加新节点
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    patch(null, newChildren[i], container, oldStartVNode.el)
  }
} else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
  // 移除操作
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    unmount(oldChildren[i])
  }
}

这样就是一个完整的 diff 算法了,包括查找可复用节点和移动节点、新增和删除节点。而且因为从两侧查找节点,会比简单 diff 算法性能更好一些。

比如 ABCD 到 DABC,简单 diff 算法需要移动 ABC 三个节点,而双端 diff 算法只需要移动 D 一个节点。

小结一下:

双端 diff 是头尾指针向中间移动的同时,对比头头、尾尾、头尾、尾头是否可以复用,如果可以的话就移动对应的 dom 节点。

如果头尾没找到可复用节点就遍历 vnode 数组来查找,然后移动对应下标的节点到头部。最后还剩下旧的 vnode 就批量删除,剩下新的 vnode 就批量新增

双端 diff 算法是 Vue2 采用的 diff 算法,性能还不错。后来,Vue3 又对 diff 算法进行了一次升级,叫做快速 diff 算法。这个后面再讲。

总结

React 和 Vue 都是基于 vdom 的前端框架,组件产生 vdom,渲染器再把 vdom 通过增删改的 dom api 更新到 dom。

当再次渲染出 vdom 时,就要新旧两棵 vdom 树做 diff,只更新变化的 dom 节点。两棵树的 diff 是 O(n^3) 的,时间复杂度太高,因此前端框架规定了只做同层 diff,还有 type 不一样就认为节点不一样,不再对比子节点。这样时间复杂度一下子就降到了 O(n)。但是对于多个子字节点的 diff 不能粗暴的删除和新增,要尽量复用已有的节点,也就是通过移动代替新增。所以多节点的时候,要指定 key,然后 diff 算法根据 key 来查找和复用节点。

简单 diff 算法是依次根据 key 查找旧节点的,移动的话通过 lastIndex 判断,大于它就不用动,小于它才需要移动。剩下的节点再批量删除和新增。但是简单 diff 算法局限性还是比较大的,有些情况下性能并不好,所以 vue2 用的是双端 diff 算法。

双端 diff 算法是头尾指针向中间移动,分别判断头尾节点是否可以复用,如果没有找到可复用的节点再去遍历查找对应节点的下标,然后移动。全部处理完之后也要对剩下的节点进行批量的新增和删除。其实 diff 算法最重要的就是找到可复用的节点,然后移动到正确的位置。只不过不同的算法查找顺序不一样。

到此这篇关于一文详解Vue 的双端 diff 算法的文章就介绍到这了,更多相关 Vue diff 算法内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 详解为什么Vue中不要用index作为key(diff算法)

    前言 Vue 中的 key 是用来做什么的?为什么不推荐使用 index 作为 key?常常听说这样的问题,本篇文章带你从原理来一探究竟. 另外本文的结论对于性能的毁灭是针对列表子元素顺序会交换.或者子元素被删除的特殊情况,提前说明清楚,喷子绕道. 本篇已经收录在 Github 仓库,欢迎 Star: https://github.com/sl1673495/blogs/issues/39 示例 以这样一个列表为例: <ul> <li>1</li> <li>

  • 详解Vue2的diff算法

    前言 双端比较算法是vue2.x采用的diff算法,本篇文章只是对双端比较算法粗略的过程进行了一下分析,具体细节还是得Vue源码,Vue的源码在这 过程 假设当前有两个数组arr1和arr2 let arr1 = [1,2,3,4,5] let arr2 = [4,3,5,1,2] 那么其过程有五步 arr1[0] 和 arr2[0]比较 arr1[ arr1.length-1 ] 和 arr2[ arr2.length-1 ] 比较 arr1[0] 和 arr2[ arr2.length-1

  • 详解vue3.0 diff算法的使用(超详细)

    前言:随之vue3.0beta版本的发布,vue3.0正式版本相信不久就会与我们相遇.尤玉溪在直播中也说了vue3.0的新特性typescript强烈支持,proxy响应式原理,重新虚拟dom,优化diff算法性能提升等等.小编在这里仔细研究了vue3.0beta版本diff算法的源码,并希望把其中的细节和奥妙和大家一起分享. 首先我们来思考一些大中厂面试中,很容易问到的问题: 1 什么时候用到diff算法,diff算法作用域在哪里? 2 diff算法是怎么运作的,到底有什么作用? 3 在v-f

  • 一篇文章带你搞懂Vue虚拟Dom与diff算法

    前言 使用过Vue和React的小伙伴肯定对虚拟Dom和diff算法很熟悉,它扮演着很重要的角色.由于小编接触Vue比较多,React只是浅学,所以本篇主要针对Vue来展开介绍,带你一步一步搞懂它. 虚拟DOM 什么是虚拟DOM? 虚拟DOM(Virtual   Dom),也就是我们常说的虚拟节点,是用JS对象来模拟真实DOM中的节点,该对象包含了真实DOM的结构及其属性,用于对比虚拟DOM和真实DOM的差异,从而进行局部渲染来达到优化性能的目的. 真实的元素节点: <div id="wr

  • vue diff算法全解析

    前言 我们知道 Vue 使用的是虚拟 DOM 去减少对真实 DOM 的操作次数,来提升页面运行的效率.今天我们来看看当页面的数据改变的时候,Vue 是如何来更新 DOM 的.Vue和React在更新dom时,使用的算法基本相同,都是基于 snabbdom. 当页面上的数据发生变化时,Vue 不会立即渲染.而是经过 diff 算法,判断出哪些是不需要变化的,哪些是需要变化更新的,只需要更新那些需要更新的 DOM 就可以了,这样就减少了很多不必要的 DOM 操作,大大提升了性能. Vue就使用了这样

  • vue中虚拟DOM与Diff算法知识精讲

    目录 前言 知识点: 虚拟DOM(Virtual DOM): 虚拟dom库 diff算法 snabbdom的核心 init函数 h函数 patch函数(核心) 题外话:diff算法简介 传统diff算法 snabbdom的diff算法优化 updateChildren(核中核:判断子节点的差异) 新结束节点和旧结束节点(情况2) 旧结束节点/新开始节点(情况4) 前言 面试官:"你了解虚拟DOM(Virtual DOM)跟Diff算法吗,请描述一下它们"; 我:"额,...鹅

  • Vue的diff算法原理你真的了解吗

    目录 思维导图 0.从常见问题引入 1.生成虚拟dom 1.h方法实现 2.render方法实现 3.再次渲染 2.diff算法 1.对常见的dom做优化 情况1:末尾追加一个元素(头和头相同) 情况2:队首添加一个节点(尾和尾) 情况3:翻转类型(头和尾) 情况4:暴力比对复用 对于key的探讨 1.为什么不能没有key 2.为什么key不能是index 3.diff的遍历方式 总结 思维导图 0. 从常见问题引入 虚拟dom是什么? 如何创建虚拟dom? 虚拟dom如何渲染成真是dom? 虚

  • Vue3组件更新中的DOM diff算法示例详解

    目录 同步头部节点 同步尾部节点 添加新的节点 删除多余节点 处理未知子序列 移动子节点 建立索引图 更新和移除旧节点 移动和挂载新节点 最长递增子序列 总结 总结 在vue的组件更新过程中,新子节点数组相对于旧子节点数组的变化,无非是通过更新.删除.添加和移动节点来完成,而核心 diff 算法,就是在已知旧子节点的 DOM 结构.vnode 和新子节点的 vnode 情况下,以较低的成本完成子节点的更新为目的,求解生成新子节点 DOM 的系列操作. 举例来说,假说我们有一个如下的列表 <ul>

  • 简单谈谈Vue中的diff算法

    目录 概述 虚拟Dom(virtual dom) 原理 实现过程 patch方法 sameVnode函数 patchVnode函数 updateChildren函数 结语 概述 diff算法,可以说是Vue的一个比较核心的内容,之前只会用Vue来进行一些开发,具体的核心的内容其实涉猎不多,最近正好看了下这方面的内容,简单聊下Vue2.0的diff算法的实现吧,具体从几个实现的函数来进行分析 虚拟Dom(virtual dom) virtual DOM是将真实的DOM的数据抽取出来,以对象的形式模

  • Vue2 的 diff 算法规则原理详解

    目录 前言 算法规则 diff 优化策略 老数组的开始与新数组的开始 老数组的结尾与新数组的结尾 老数组的开始与新数组的结尾 老数组的结尾与新数组的开始 以上四种情况都没对比成功 推荐在渲染列表时为节点设置 key 循环比对结束的后续处理工作 源码解析 总结 前言 所谓 diff 算法,就是通过比对新旧两个虚拟节点不一样的地方,针对那些不一样的地方进行新增或更新或删除操作.接下来我们详细介绍节点更新的过程. 首先进行静态节点处理,判断新旧两个虚拟节点是否是静态节点,如果是,就不需要进行更新操作,

随机推荐