React、Vue2.x、Vue3.0的diff算法

本文详细介绍了React和Vue2、Vue3的Diff算法实现,包括React的单向递增策略,Vue2的双端比较,以及Vue3的最长递增子序列思想。在React的Diff中,通过key值比较节点,优化时间复杂度。Vue2的双端比较通过头尾节点对比减少不必要的节点移动,而Vue3则引入最长递增子序列解决移动问题,提高效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

本文章不讲解 vDom 实现,mount 挂载,以及 render 函数。只讨论三种 diff 算法。VNode 类型不考虑 component、functional-component、Fragment、Teleport。只考虑 Element 和 Text。此文章全部代码可参考该项目。

下面的 diff 算法中会出现几个方法,在这里进行罗列,并说明其功能

  • mount(vnode, parent, [refNode]): 通过vnode生成真实的DOM节点。parent为其父级的真实 DOM 节点,refNode为真实的DOM节点,其父级节点为parent。如果refNode不为空,vnode生成的DOM节点就会插入到refNode之前;如果refNode为空,那么vnode生成的DOM节点就作为最后一个子节点插入到parent

  • patch(prevNode, nextNode, parent): 可以简单的理解为给当前DOM节点进行更新,并且调用diff算法对比自身的子节点;

一、React-Diff

React 的思路是递增法。通过对比新的列表中的节点,在原本的列表中的位置是否是递增,来判断当前节点是否需要移动。

1. 实现原理

来看这样一个例子。

nextList为新的列表,prevList为旧列表。这个例子我们一眼能看出来,新列表是不需要进行移动的。下面我用react的递增思想,解释一下为什么新列表中的节点不需要移动。

我们首先遍历nextList,并且找到每一个节点,在prevList中的位置。

function foo(prevList, nextList) {
    for (let i = 0; i < nextList.length; i++) {
        let nextItem = nextList[i];
        for (let j = 0; j < prevList.length; j++) {
            let prevItem = prevList[j]
            if (nextItem === prevItem) {

            }
        }
    }
}

找到位置以后,与上一个节点的位置进行对比,如果当前的位置大于上一个位置,说明当前节点不需要移动。因此我们要定义一个lastIndex来记录上一个节点的位置。

function foo(prevList, nextList) {
    let lastIndex = 0
    for (let i = 0; i < nextList.length; i++) {
        let nextItem = nextList[i];
        for (let j = 0; j < prevList.length; j++) {
            let prevItem = prevList[j]
            if (nextItem === prevItem) {
                if (j < lastIndex) {
                    // 需要移动节点
                } else {
                    // 不需要移动节点,记录当前位置,与之后的节点进行对比
                    lastIndex = j
                }
            }
        }
    }
}

在上面的例子中,nextList每个节点在prevList的位置为0 1 2 3。每一项都要比前一项要大,所以不需要移动,这就是reactdiff算法的原理。

2. 找到需要移动的节点

在上一小节中,我们是通过对比值是否相等,查找的对应位置。但是在 vdom 中,每一个节点都是一个 vNode,我们应该如何进行判断呢?

答案就是key,我们通过对每个节点的key进行赋值,并且让处于同一children数组下的vnodekey都不相同,以此来确定每个节点的唯一性,并进行新旧列表的对比。

function reactDiff(prevChildren, nextChildren, parent) {
    let lastIndex = 0
    for (let i = 0; i < nextChildren.length; i++) {
        let nextChild = nextChildren[i];
        for (let j = 0; j < prevChildren.length; j++) {
            let prevChild = prevChildren[j]
            if (nextChild.key === prevChild.key) {
                patch(prevChild, nextChild, parent)
                if (j < lastIndex) {
                    // 需要移动节点
                } else {
                    // 不需要移动节点,记录当前位置,与之后的节点进行对比
                    lastIndex = j
                }
            }
        }
    }
}

3. 移动节点

首先我们先明确一点,移动节点所指的节点是DOM节点。vnode.el指向该节点对应的真实DOM节点。patch方法会将更新过后的DOM节点,赋值给新的vnodeel属性。

为了画图方便,我们用key的值来表示vnode节点。为了行文方便,我们把key值为avnode简写为vnode-avnode-a对应的真实 DOM 节点为DOM-A

我们来将上图的例子代入reactDiff中执行。我们遍历新列表,并查找vnode旧列表中的位置。当遍历到vnode-d时,之前遍历在旧列表的位置为0 < 2 < 3,说明A C D这三个节点都是不需要移动的。此时lastIndex = 3, 并进入下一次循环,发现vnode-b旧列表index11 < 3,说明DOM-B要移动。

通过观察我们能发现,只需要把DOM-B移动到DOM-D之后就可以了。也就是找到需要移动的 VNode,我们称该 VNode 为 α,将 α 对应的真实的 DOM 节点移动到,α 在新列表中的前一个 VNode 对应的真实 DOM 的后面。

在上述的例子中,就是将vnode-b对应的真实 DOM 节点DOM-B, 移动到vnode-b在新列表中的前一个VNode——vnode-d对应的真实 DOM 节点DOM-D的后面。

function reactDiff(prevChildren, nextChildren, parent) {
    let lastIndex = 0
    for (let i = 0; i < nextChildren.length; i++) {
        let nextChild = nextChildren[i];
        for (let j = 0; j < prevChildren.length; j++) {
            let prevChild = prevChildren[j]
            if (nextChild.key === prevChild.key) {
                patch(prevChild, nextChild, parent)
                if (j < lastIndex) {
                    // 移动到前一个节点的后面
                    let refNode = nextChildren[i - 1].el.nextSibling;
                    parent.insertBefore(nextChild.el, refNode)
                } else {
                    // 不需要移动节点,记录当前位置,与之后的节点进行对比
                    lastIndex = j
                }
            }
        }
    }
}

为什么是这样移动的呢?首先我们列表是从头到尾遍历的。这就意味着对于当前VNode节点来说,该节点之前的所有节点都是排好序的,如果该节点需要移动,那么只需要将 DOM 节点移动到前一个vnode节点之后就可以,因为在新列表vnode的顺序就是这样的。

4. 添加节点

上一小节我们只讲了如何移动节点,但是忽略了另外一种情况,就是在新列表中有全新的VNode节点,在旧列表中找不到。遇到这种情况,我们需要根据新的VNode节点生成DOM节点,并插入DOM树中。

至此,我们面临两个问题:1.如何发现全新的节点、2. 生成的DOM节点插入到哪里

我们先来解决第一个问题,找节点还是比较简单的,我们定义一个find变量值为false。如果在旧列表找到了key
相同的vnode,就将find的值改为true。当遍历结束后判断find值,如果为false,说明当前节点为新节点。

function reactDiff(prevChildren, nextChildren, parent) {
   
  let lastIndex = 0
  for (let i = 0; i < nextChildren.length; i++) {
   
    let nextChild = nextChildren[i],
      find = false
    for (let j = 0; j < prevChildren.length; j++) {
   
      let prevChild = prevChildren[j]
      if (nextChild.key === prevChild.key) {
   
        find = true
        patch(prevChild, nextChild, parent)
        if (j < lastIndex) {
   
          // 移动到前一个节点的后面
          let refNode = nextChildren[i - 1].el.nextSibling
          parent.insertBefore(nextChild.el, refNode)
        } else {
   
          // 不需要移动节点,记录当前位置,与之后的节点进行对比
          lastIndex = j
        }
        break
      }
    }
    if (!find) {
   
      // 插入新节点
    }
  }
}

找到新节点后,下一步就是插入到哪里了,这里的逻辑其实是和移动节点的逻辑是一样的。我们观察上图可以发现,新的vnode-c是紧跟在vnode-b后面的,并且vnode-b的 DOM 节点——DOM-B是已经排好序的,所以我们只需要将vnode-c生成的 DOM 节点插入到DOM-B之后就可以了。

但是这里有一种特殊情况需要注意,就是新的节点位于新列表的第一个,这时候我们需要找到旧列表第一个节点,将新节点插入到原来第一个节点之前就可以了。

function reactDiff(prevChildren, nextChildren, parent) {
   
  let lastIndex = 0
  for (let i = 0; i < nextChildren.length; i++) {
   
    let nextChild = nextChildren[i],
      find = false
    for (let j = 0; j < prevChildren.length; j++) {
   
      let prevChild = prevChildren[j]
      if (nextChild.key === prevChild.key) {
   
        find = true
        patch(prevChild, nextChild, parent)
        if (j < lastIndex) {
   
          // 移动到前一个节点的后面
          let refNode = nextChildren[i - 1].el.nextSibling
          parent.insertBefore(nextChild.el, refNode)
        } else {
   
          // 不需要移动节点,记录当前位置,与之后的节点进行对比
          lastIndex = j
        }
        break
      }
    }
    if (!find) {
   
      // 插入新节点
      let refNode = i <= 0 ? prevChildren[0].el : nextChildren[i - 1].el.nextSibling
      mount(nextChild, parent, refNode)
    }
  }
}

5. 移除节点

有增就有减,当旧的节点不在新列表中时,我们就将其对应的 DOM 节点移除。

function reactDiff(prevChildren, nextChildren, parent) {
   
  let lastIndex = 0
  for (let i = 0; i < nextChildren.length; i++) {
   
    let nextChild = nextChildren[i],
      find = false
    for (let j = 0; j < prevChildren.length; j++) {
   
      let prevChild = prevChildren[j]
      if (nextChild.key === prevChild.key) {
   
        find = true
        patch(prevChild, nextChild, parent)
        if (j < lastIndex) {
   
          // 移动到前一个节点的后面
          let refNode = nextChildren[i - 1].el.nextSibling
          parent.insertBefore(nextChild.el, refNode)
        } else {
   
          // 不需要移动节点,记录当前位置,与之后的节点进行对比
          lastIndex = j
        }
        break
      }
    }
    if (!find) {
   
      // 插入新节点
      let refNode = i <= 0 ? prevChildren[0].el : nextChildren[i - 1].el.nextSibling
      mount(nextChild, parent, refNode)
    }
  }
  for (let i = 0; i < prevChildren.length; i++) {
   
    let prevChild = prevChildren[i],
      key = prevChild.key,
      has = nextChildren.find((item) => item.key === key)
    if (!has) parent.removeChild(prevChild.el)
  }
}

6.优化与不足

以上就是 React 的 diff 算法的思路。

目前的reactDiff的时间复杂度为O(m*n),我们可以用空间换时间,把keyindex的关系维护成一个Map,从而将时间复杂度降低为O(n),具体的代码可以查看此项目

我们接下来看这样一个例子

根据reactDiff的思路,我们需要先将DOM-A移动到DOM-C之后,然后再将DOM-B移动到DOM-A之后,完成Diff。但是我们通过观察可以发现,只要将DOM-C移动到DOM-A之前就可以完成Diff

这里是有可优化的空间的,接下来我们介绍vue2.x中的diff算法——双端比较,该算法解决了上述的问题

二、Vue2.X Diff —— 双端比较

所谓双端比较就是新列表旧列表两个列表的头与尾互相对比,,在对比的过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。

1. 实现原理

我们先用四个指针指向两个列表的头尾

function vue2Diff(prevChildren, nextChildren, parent) {
   
  let oldStartIndex = 0,
    oldEndIndex = prevChildren.length - 1
  ;(newStartIndex = 0), (newEndIndex = nextChildren.length - 1)
  let oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldEndIndex],
    newStartNode = nextChildren[nextStartIndex],
    newEndNode = nextChildren[nextEndIndex]
}

我们根据四个指针找到四个节点,然后进行对比,那么如何对比呢?我们按照以下四个步骤进行对比

  1. 使用旧列表的头一个节点oldStartNode新列表的头一个节点newStartNode对比
  2. 使用旧列表的最后一个节点oldEndNode新列表的最后一个节点newEndNode对比
  3. 使用旧列表的头一个节点oldStartNode新列表的最后一个节点newEndNode对比
  4. 使用旧列表的最后一个节点oldEndNode新列表的头一个节点newStartNode对比

使用以上四步进行对比,去寻找key相同的可复用的节点,当在某一步中找到了则停止后面的寻找。具体对比顺序如下图

对比顺序代码结构如下:

function vue2Diff(prevChildren, nextChildren, parent) {
   
  let oldStartIndex = 0,
    oldEndIndex = prevChildren.length - 1
  ;(newStartIndex = 0), (newEndIndex = nextChildren.length - 1)
  let oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldEndIndex],
    newStartNode = nextChildren[newStartIndex],
    newEndNode = nextChildren[newEndIndex]

  if (oldStartNode.key === newStartNode.key) {
   
  } else if (oldEndNode.key === newEndNode.key) {
   
  } else if (oldStartNode.key &#
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值