阅读 886

react+vue2+vue3 diff算法分析及比较

此文内容包括以下:

介绍diff算法

  1. react-diff: 递增法

移动节点:移动的节点称为α,将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面

添加节点:在新列表中有全新的VNode节点,在旧列表中找不到的节点需要添加(通过find这个布尔值来查找)

移除节点:当旧的节点不在新列表中时,我们就将其对应的DOM节点移除(通过key来查找确定是否删除)

不足:从头到尾单边比较,容易增加比较次数

  1. vue2-diff: 双端比较

DOM节点什么时候需要移动和如何移动,总结如下:

  • 头-头:不移动
  • 尾-尾:不移动
  • 头-尾: 插入到旧节点的尾节点的后面
  • 尾-头:插入到旧列表的第一个节点之前
  • 以上4种都不存在(特殊情况):在旧节点中找,如果找到,移动找到的节点,移动到开头;没找到,直接创建一个新的节点放到最前面

添加节点【oldEndIndex以及小于了oldStartIndex】:将剩余的节点依次插入到oldStartNodeDOM之前

移除节点【newEndIndex小于newStartIndex】:将旧列表剩余的节点删除即可

  1. vue3-diff: 最长递增子序列

区别

  1. react和vue2的比较:
  • vue2双端比较解决react单端比较导致移动次数变多的问题,react只能从头到尾遍历,增加了移动次数
  1. vue2和vue3的比较:都用了双端指针

  2. vue3和react比较:vue3在判断是否需要移动,使用了react的递增法

几个算法看下来,套路就是找到移动的节点,然后给他移动到正确的位置。把该加的新节点添加好,把该删的旧节点删了,整个算法就结束了。

一、react-diff —— 递增法

实现原理

从头到尾遍历比较,新列表的节点在旧列表中的位置是否是递增 如果递增,不需要移动,否则需要移动。

通过key在旧节点中找到新节点的节点,所以key一定要代表唯一性。

移动节点:在旧节点中找到需要移动的VNode,我们称该VNode为α

生成的DOM节点插入到哪里?

将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面

image.png

将DOM-B移到DOM-D的后面

为什么这么移动?

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

添加节点:在新列表中有全新的VNode节点,在旧列表中找不到的节点需要添加

如何发现全新的节点?

定义一个find变量值为false。如果在旧列表找到了key 相同的vnode,就将find的值改为true。当遍历结束后判断find值,如果为false,说明当前节点为新节点

生成的DOM节点插入到哪里?

分两种情况:

    1. 新的节点位于新列表的第一个,这时候我们需要找到旧列表第一个节点,将新节点插入到原来第一个节点之前,这个很好理解,也就是最在最前面的新节点插入第一个节点之前。
    1. 将新的真实的DOM节点移动到,新列表中的前一个VNode对应的真实DOM的后面。移动原理同移动节点,也就是因为该节点之前已经排好序。

删除节点:当旧的节点不在新列表中时,我们就将其对应的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) {
            // 定义了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)
    }
}

复制代码

算法优化及不足

  1. 时间复杂度是O(m*n),有不足,可优化

我们可以用空间换时间,把keyindex的关系维护成一个Map,从而将时间复杂度降低为O(n)

function reactdiff(prevChildren, nextChildren, parent) {
  let prevIndexMap = {},
    nextIndexMap = {};
  for (let i = 0; i < prevChildren.length; i++) {
    let { key } = prevChildren[i]
    //保存旧列表key和指引i的关系
    prevIndexMap[key] = i
  }
  let lastIndex = 0;
  for (let i = 0; i < nextChildren.length; i++) {
    let nextChild = nextChildren[i],
      nextKey = nextChild.key,
      // 通过新列表的key得到旧列表的指引
      j = prevIndexMap[nextKey];

    //保存新列表key和指引i的关系
    nextIndexMap[nextKey] = i
    
    if (j === undefined) {
    //添加节点
      let refNode = i === 0
                    ? prevChildren[0].el
                    : nextChildren[i - 1].el.nextSibling;
      mount(nextChild, parent, refNode)
    } else {
      patch(prevChildren[j], nextChild, parent)
      if (j < lastIndex) {
      //移动节点:移动到前一个节点的后面
        let refNode = nextChildren[i - 1].el.nextSibling;
        parent.insertBefore(nextChild.el, refNode)
      } else {
       // 不需要移动节点,记录当前位置,与之后的节点进行对比
        lastIndex = j
      }
    }
  }

//删除节点
  for (let i = 0; i < prevChildren.length; i++) {
    let { key } = prevChildren[i]
    if (!nextIndexMap.hasOwnProperty(key)) parent.removeChild(prevChildren[i].el)
  }
}
复制代码
  1. 移动次数有不足

image.png

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

这是因为react只能从头到尾遍历,增加了移动次数。所以这里是有可优化的空间的,接下来我们介绍vue2.x中的diff算法——双端比较,该算法解决了上述的问题

vue2-diff —— 双端比较

实现原理

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

按照以下四个步骤进行对比

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

image.png

通过图形记住1-4的比较顺序,先前后双竖再首尾两交叉,记住这张图就够了

具体规则和移动规则,这里是重中之重,一定要学习

  1. 旧列表的头一个节点oldStartNode新列表的头一个节点newStartNode对比时key相同。那么旧列表的头指针oldStartIndex新列表的头指针newStartIndex同时向移动一位。

原本在旧列表中就是头节点,在新列表中也是头节点,该节点不需要移动,所以什么都不需要做

  1. 旧列表的最后一个节点oldEndNode新列表的最后一个节点newEndNode对比时key相同。那么旧列表的尾指针oldEndIndex新列表的尾指针newEndIndex同时向移动一位。

原本在旧列表中就是尾节点,在新列表中也是尾节点,说明该节点不需要移动,所以什么都不需要做

  1. 旧列表的头一个节点oldStartNode新列表的最后一个节点newEndNode对比时key相同。那么旧列表的头指针oldStartIndex移动一位;新列表的尾指针newEndIndex移动一位。

原本旧列表中是头节点,然后在新列表中是尾节点。那么只要在旧列表中把当前的节点移动到原本尾节点的后面,就可以了

  1. 旧列表的最后一个节点oldEndNode新列表的头一个节点newStartNode对比时key相同。那么旧列表的尾指针oldEndIndex移动一位;新列表的头指针newStartIndex移动一位。

本在旧列表末尾的节点,却是新列表中的开头节点,没有人比他更靠前,因为他是第一个,所以只需要把当前的节点移动到原本旧列表中的第一个节点之前,让它成为第一个节点即可。

DOM节点什么时候需要移动和如何移动,总结如下:

  • 头-头:不移动
  • 尾-尾:不移动
  • 头-尾: 插入到旧节点的尾节点的后面
  • 尾-头:插入到旧列表的第一个节点之前

当然也有特殊情况,下面继续

当四次对比都没找到复用节点

我们只能拿新列表的第一个节点去旧列表中找与其key相同的节点

找节点的时候有两种情况:

  1. 一种在旧列表中找到了

移动找到的节点,移动到开头

DOM移动后,由我们将旧列表中的节点改为undefined,这是至关重要的一步,因为我们已经做了节点的移动了所以我们不需要进行再次的对比了。最后我们将头指针newStartIndex向后移一位。

  1. 另一种情况是没找到

直接创建一个新的节点放到最前面就可以了,然后后移头指针newStartIndex

添加节点

oldEndIndex小于了oldStartIndex,但是新列表中还有剩余的节点,我们只需要将剩余的节点依次插入到oldStartNodeDOM之前就可以了。为什么是插入oldStartNode之前呢?原因是剩余的节点在新列表的位置是位于oldStartNode之前的,如果剩余节点是在oldStartNode之后,oldStartNode就会先行对比,这个需要思考一下,其实还是与第四步的思路一样。

移除节点

新列表newEndIndex小于newStartIndex时,我们将旧列表剩余的节点删除即可。这里我们需要注意,旧列表undefind。前面提到过,当头尾节点都不相同时,我们会去旧列表中找新列表的第一个节点,移动完DOM节点后,将旧列表的那个节点改为undefind。所以我们在最后的删除时,需要注意这些undefind,遇到的话跳过当前循环即可。

实现代码

function vue2diff(prevChildren, nextChildren, parent) {
  let oldStartIndex = 0,
    newStartIndex = 0,
    oldStartIndex = prevChildren.length - 1,
    newStartIndex = nextChildren.length - 1,
    oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldStartIndex],
    newStartNode = nextChildren[newStartIndex],
    newEndNode = nextChildren[newStartIndex];
    //循环结束条件
  while (oldStartIndex <= oldStartIndex && newStartIndex <= newStartIndex) {
    if (oldStartNode === undefined) {
      oldStartNode = prevChildren[++oldStartIndex]
    } else if (oldEndNode === undefined) {
      oldEndNode = prevChildren[--oldStartIndex]
    } else if (oldStartNode.key === newStartNode.key) {
    // 头-头:不移动
      patch(oldStartNode, newStartNode, parent)

      oldStartIndex++
      newStartIndex++
      oldStartNode = prevChildren[oldStartIndex]
      newStartNode = nextChildren[newStartIndex]
    } else if (oldEndNode.key === newEndNode.key) {
      // 尾-尾:不移动
      patch(oldEndNode, newEndNode, parent)

      oldStartIndex--
      newStartIndex--
      oldEndNode = prevChildren[oldStartIndex]
      newEndNode = nextChildren[newStartIndex]
    } else if (oldStartNode.key === newEndNode.key) {
    // 头-尾: 插入到旧节点的尾节点的后面
      patch(oldStartNode, newEndNode, parent)
      parent.insertBefore(oldStartNode.el, oldEndNode.el.nextSibling)
      oldStartIndex++
      newStartIndex--
      oldStartNode = prevChildren[oldStartIndex]
      newEndNode = nextChildren[newStartIndex]
    } else if (oldEndNode.key === newStartNode.key) {
    // 尾-头:插入到旧列表的第一个节点之前
      patch(oldEndNode, newStartNode, parent)
      parent.insertBefore(oldEndNode.el, oldStartNode.el)
      oldStartIndex--
      newStartIndex++
      oldEndNode = prevChildren[oldStartIndex]
      newStartNode = nextChildren[newStartIndex]
    } else {
    //特殊情况
      let newKey = newStartNode.key,
        oldIndex = prevChildren.findIndex(child => child && (child.key === newKey));
      if (oldIndex === -1) {
        mount(newStartNode, parent, oldStartNode.el)
      } else {
        let prevNode = prevChildren[oldIndex]
        patch(prevNode, newStartNode, parent)
        parent.insertBefore(prevNode.el, oldStartNode.el)
        prevChildren[oldIndex] = undefined
      }
      newStartIndex++
      newStartNode = nextChildren[newStartIndex]
    }
  }
  if (newStartIndex > newStartIndex) {
    while (oldStartIndex <= oldStartIndex) {
      if (!prevChildren[oldStartIndex]) {
        oldStartIndex++
        continue
      }
      parent.removeChild(prevChildren[oldStartIndex++].el)
    }
  } else if (oldStartIndex > oldStartIndex) {
    while (newStartIndex <= newStartIndex) {
      mount(nextChildren[newStartIndex++], parent, oldStartNode.el)
    }
  }
}

复制代码

vue3-diff —— 最长递增子序列

双端比较,while循环,两端是向内靠拢的 头-头
尾-尾

j是头向内靠拢指针;
prevEnd是尾向内靠拢指针

添加节点:j > prevEndj <= nextEnd【证明新列表有多余的】

移除节点:j > nextEnd【证明旧列表有多余的】

image.png

上图,j > prevEndj <= nextEnd,只需要把新列表jnextEnd之间剩下的节点插入进去。

如果j > nextEnd【证明旧列表有多余的】时,把旧列表jprevEnd之间的节点删除

移动节点

image.png

根据新列表剩余的节点数量,创建一个source数组,并将数组填满-1

创建数组和对象建立关系:

  • 数组source【来做新旧节点的对应关系的,根据source计算出它的最长递增子序列用于移动DOM节点】:新节点旧列表的位置存储在该数组中,
  • 对象nextIndexMap【通过新列表的key去找旧列表的key】:存储当前新列表中的节点key指引i的关系,再通过key去旧列表中去找位置

如果旧节点在新列表中没有的话,直接删除就好


    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1,     // 新列表中剩余的节点长度
      source = new Array(nextLeft).fill(-1),  // 创建数组,填满-1
      nextIndexMap = {},                      // 新列表节点与index的映射
      patched = 0;                            // 已更新过的节点的数量
      
    // 保存映射关系  
    for (let i = nextStart; i <= nextEnd; i++) {
      let key = nextChildren[i].key
      nextIndexMap[key] = i
    } 
    
    // 去旧列表找位置
    for (let i = prevStart; i <= prevEnd; i++) {
      let prevNode = prevChildren[i],
      	prevKey = prevNode.key,
        nextIndex = nextIndexMap[prevKey];
      // 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
      if (nextIndex === undefind || patched >= nextLeft) {
        parent.removeChild(prevNode.el)
        continue
      }
      // 找到对应的节点
      let nextNode = nextChildren[nextIndex];
      patch(prevNode, nextNode, parent);
      // 给source赋值
      source[nextIndex - nextStart] = i
      patched++
    }
  }
复制代码

在找节点时要注意,如果旧节点在新列表中没有的话,直接删除就好。除此之外,我们还需要一个数量表示记录我们已经patch过的节点,如果数量已经与新列表剩余的节点数量一样,那么剩下的旧节点就直接删除

如果是全新的节点的话,其在source数组中对应的值就是初始的-1,通过这一步可以区分出来哪个为全新的节点,哪个是可复用的。

判断是否要移动?递增法,同react思路:如果找到的index是一直递增的,说明不需要移动任何节点。我们通过设置一个变量move来保存是否需要移动的状态。

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  outer: {
  // ...
  }
  
  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    // ...
  } else if (j > nextEnd && j <= prevEnd) {
    // ...
  } else {
    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1,     // 新列表中剩余的节点长度
      source = new Array(nextLeft).fill(-1),  // 创建数组,填满-1
      nextIndexMap = {},                      // 新列表节点与index的映射
      patched = 0,
      move = false,                           // 是否移动
      lastIndex = 0;                          // 记录上一次的位置
      
    // 保存映射关系  
    for (let i = nextStart; i <= nextEnd; i++) {
      let key = nextChildren[i].key
      nextIndexMap[key] = i
    } 
    
    // 去旧列表找位置
    for (let i = prevStart; i <= prevEnd; i++) {
      let prevNode = prevChildren[i],
      	prevKey = prevNode.key,
        nextIndex = nextIndexMap[prevKey];
      // 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
      if (nextIndex === undefind || patched >= nextLeft) {
        parent.removeChild(prevNode.el)
        continue
      }
      // 找到对应的节点
      let nextNode = nextChildren[nextIndex];
      patch(prevNode, nextNode, parent);
      // 给source赋值
      source[nextIndex - nextStart] = i
      patched++
      
      // 递增方法,判断是否需要移动
      if (nextIndex < lastIndex) {
      	move = false
      } else {
      	lastIndex = nextIndex
      }
    }
    
    if (move) {
    
    // 需要移动
    } else {
	
    //不需要移动
    }
  }
}

复制代码

怎么移动?

一旦需要进行DOM移动,我们首先要做的就是找到source最长递增子序列

从后向前进行遍历source每一项。此时会出现三种情况:

  1. 当前的值为-1,这说明该节点是全新的节点,又由于我们是从后向前遍历,我们直接创建好DOM节点插入到队尾就可以了。
  2. 当前的索引为最长递增子序列中的值,也就是i === seq[j],这说说明该节点不需要移动
  3. 当前的索引不是最长递增子序列中的值,那么说明该DOM节点需要移动,这里也很好理解,我们也是直接将DOM节点插入到队尾就可以了,因为队尾是排好序的。

image.png


function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  if (move) {
	const seq = lis(source); // [0, 1]
    let j = seq.length - 1;  // 最长子序列的指针
    // 从后向前遍历
    for (let i = nextLeft - 1; i >= 0; i--) {
      let pos = nextStart + i, // 对应新列表的index
        nextNode = nextChildren[pos],	// 找到vnode
      	nextPos = pos + 1// 下一个节点的位置,用于移动DOM
        refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM节点
        cur = source[i];  // 当前source的值,用来判断节点是否需要移动
    
      if (cur === -1) {
        // 情况1,该节点是全新节点
      	mount(nextNode, parent, refNode)
      } else if (cur === seq[j]) {
        // 情况2,是递增子序列,该节点不需要移动
        // 让j指向下一个
        j--
      } else {
        // 情况3,不是递增子序列,该节点需要移动
        parent.insetBefore(nextNode.el, refNode)
      }
    }
  } else {
    //不需要移动: 我们只需要判断是否有全新的节点【其在source数组中对应的值就是初始的-1】,给他添加进去
    for (let i = nextLeft - 1; i >= 0; i--) {
      let cur = source[i];  // 当前source的值,用来判断节点是否需要移动
    
      if (cur === -1) {
       let pos = nextStart + i, // 对应新列表的index
          nextNode = nextChildren[pos],	// 找到vnode
          nextPos = pos + 1// 下一个节点的位置,用于移动DOM
          refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM节点
      	mount(nextNode, parent, refNode)
      }
    }
  }
}

复制代码

小结

  1. 需要创建数组和对象建立关系:
  • 数组source【来做新旧节点的对应关系的,根据source计算出它的最长递增子序列用于移动DOM节点】:新节点旧列表的位置存储在该数组中,
  • 对象nextIndexMap【通过新列表的key去找旧列表的key】:存储当前新列表中的节点key指引i的关系,再通过key去旧列表中去找位置
  1. 移除节点满足以下任何一个条件:
  • j > nextEnd
  • 如果旧节点在新列表中没有的话,直接删除
  • 已经更新了全部的新节点,剩下的旧节点就直接删除了【patch标记已更新过的节点的数量】
  1. 新增节点满足以下任何一个条件:
  • j > prevEndj <= nextEnd
  • 如果是全新的节点的话,其在source数组中对应的值就是初始的-1,新增
  1. 移动节点满足以下任何一个条件:
  • 当前的索引不是最长递增子序列中的值,那么说明该DOM节点需要移动
  1. 最长递增子序列是为了操作移动DOM

  2. 对比规则:

第一步:对比新老节点数组的头头和尾尾 在这一步将两头两尾相同的进行 patch 第二步:头尾 patch 结束之后,查看新老节点数组是不是有其中一方已经 patch 完了,假如是,那么就多删少补 第三步:遍历老节点,看老节点是否在新节点里面存在,假如不存在,就删除。 // 假如新的子节点都被遍历完了,那么就代表说老的数组之后的,都是需要被删除的 第四步:获取最长递增子序列

总结

介绍diff算法

  1. react-diff: 递增法

移动节点:移动的节点称为α,将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面

添加节点:在新列表中有全新的VNode节点,在旧列表中找不到的节点需要添加(通过find这个布尔值来查找)

移除节点:当旧的节点不在新列表中时,我们就将其对应的DOM节点移除(通过key来查找确定是否删除)

不足:从头到尾单边比较,容易增加比较次数

  1. vue2-diff: 双端比较

DOM节点什么时候需要移动和如何移动,总结如下:

  • 头-头:不移动
  • 尾-尾:不移动
  • 头-尾: 插入到旧节点的尾节点的后面
  • 尾-头:插入到旧列表的第一个节点之前
  • 以上4种都不存在(特殊情况):在旧节点中找,如果找到,移动找到的节点,移动到开头;没找到,直接创建一个新的节点放到最前面

添加节点【oldEndIndex以及小于了oldStartIndex】:将剩余的节点依次插入到oldStartNodeDOM之前

移除节点【newEndIndex小于newStartIndex】:将旧列表剩余的节点删除即可

  1. vue3-diff: 最长递增子序列

区别

  1. react和vue2的比较:
  • vue2双端比较解决react单端比较导致移动次数变多的问题,react只能从头到尾遍历,增加了移动次数
  1. vue2和vue3的比较:都用了双端指针

  2. vue3和react比较:vue3在判断是否需要移动,使用了react的递增法;react是单端比较,这样移动效率降低,vue3是使用双端比较

几个算法看下来,套路就是找到移动的节点,然后给他移动到正确的位置。把该加的新节点添加好,把该删的旧节点删了,整个算法就结束了。

此文借鉴别人的文章,梳理成自己的笔记,分别分析了react、vue2、vue3的diff算法实现原理和具体实现,同时比较了这3种算法,应对面试肯定不会害怕。当然总结它不仅仅为了以后的面试,也为了提升算法思想。

最长递增子序列可以使用动态规划方法 juejin.cn/post/696278…

React、Vue2、Vue3的三种Diff算法 (juejin.cn)

文章分类
前端
文章标签