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

此文内容包括如下:vue

介绍diff算法node

  1. react-diff: 递增法

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

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

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

不足:从头至尾单边比较,容易增长比较次数数组

  1. vue2-diff: 双端比较

DOM节点何时须要移动和如何移动,总结以下:markdown

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

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

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

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

区别优化

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

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

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

1、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、vue二、vue3的diff算法实现原理和具体实现,同时比较了这3种算法,应对面试确定不会惧怕。固然总结它不只仅为了之后的面试,也为了提高算法思想。

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

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

相关文章
相关标签/搜索