手写 mini-vue3 - Diff 算法实现更新 Element 的 children(十)

上一篇写完了 Element 的 props 更新,这次来实现 Element 的 children 更新了。

Element 的 children 变化,有以下四种情况:

  1. 数组变文本
  2. 文本变文本
  3. 文本变数组
  4. 数组变数组

其中,前3种比较好处理,第四种数组对数组的比较,需要使用到 diff 算法去比较。

更新 Element children 的逻辑也是在 patchElement 函数里面开始。

patchElement

ts 复制代码
  function patchElement(n1, n2, container, parentComponent, anchor) {

    // 更新 props:
    const oldProps = n1.props || EMPTY_OBJ;
    const newProps = n2.props || EMPTY_OBJ;

    const el = (n2.el = n1.el);

    // 更新 Element 的 children 
    patchChildren(n1, n2, el, parentComponent, anchor);
    // 更新 Element 的 props,上次已经写了
    patchProps(el, oldProps, newProps);
  }

patchChildren

ts 复制代码
function patchChildren(n1, n2, parentComponent, anchor) {
    const prevShapeFlag = n1.shapeFlag;
    const { shapeFlag } = n2;
    const c1 = n1.children;
    const c2 = n2.children;

    //  新-text
    if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 1. 新-text & 旧-array
      if(prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 把老的 children 清空
        unmountChildren(n1.children);
      }
      
      // 2. 新-text & 旧-text
      if(c1 !== c2) {
        // 更新文本
        hostSetElementText(container, c2);
      }
      
    // 新-array
    } else {
      // 3. 新-array & 旧-text
      if(prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 先清空旧文本
        hostSetElementText(container, '');
        // 挂载新的 children
        mountChildren(c2, container, parentComponent, anchor);
      
      // 4. 新-array & 旧-array
      } else {
        // array diff array
        patchKeyedChildren(c1, c2, container, parentComponent, anchor);
      }
    }
}

patchKeyedChildren

数组 children 对比数组 children,使用的是双端对比 diff 算法,也就是使用双指针,分为老节点数组双指针、新节点数组双指针,同步移动指针,对新旧节点两两比对。

左侧比对

左侧比对,遇到相同的节点,同时移动新旧节点左指针,直到遇到不同的节点,停止移动指针。

相同的节点,是指 type key 都相同。

左指针都是从旧 children、新 children 头部 i = 0 开始,右指针 e1 e2 从 旧 children、新 children 的尾部开始

ts 复制代码
let i = 0;
let e1 = c1.length - 1; // c1 为 旧节点数组
let e2 = c2.length - 1; // c2 为新节点数组

// 左侧比对
while(i <= e1 && i <= e2) {

    const oldVNode = c1[i];
    const newVNode = c2[i];
    
    if(isSameVNodeType(oldVNode, newVNode)) {
        // 如果相同位置 i 的新旧节点一致,则继续对这两个节点比较
        patch(oldVNode, newVNode, container, parentComponent, parentAnchor);
    } else {
        break; // 如果不同,就立即退出,指针 i 停止移动;i 刚好指向不同的位置
    }
    
    i++; // 左指针右移
}

右侧比对

右侧比对也一样,遇到相同的节点,同时移动新旧节点右指针,直到遇到不同的节点,停止移动指针。

ts 复制代码
// 右侧比对
while(i <= e1 && i <= e2) {
    const oldVNode = c1[e1];
    const newVNode = c2[e2];
    
    if(isSameVNodeType(oldVNode, newVNode)) {
        patch(oldVNode, newVNode, container, parentComponent, parentAnchor);
    } else {
        break; // 如果不同,就立即退出,指针 e1 e2 停止移动;e1 e2 刚好指向不同的位置
    }
    
    // 右指针左移
    e1--;
    e2--;
}

不论是左指针从左到右,还是右指针从右往左,都有可能遇到新节点、旧节点个数不一样的情况。

新的比老的多 - 创建

新节点数比旧节点数多的时候:

  1. 只有左侧比对,也就是只有左侧有相同节点,右侧没有
  1. 或者只有右侧比对,也就是只有右侧有相同节点,左侧没有
  1. 两侧都比对

可以发现,一定有 i > e1。新节点里面, e1 < i <= e2,这个范围内的都需要新增。

ts 复制代码
// 退出左侧、右侧循环后,有:
const l2 = c2.length; // 新节点个数
if(i > e1) {
    if(i <= e2) {
    
        // 插入点索引
        const nextPos = e2 + 1;
        
        // 插入锚点
        const anchor = nextPos < l2 
            ? c2[nextPos].el 
            : null;
        
        // e1 < i <= e2 范围内的,需要新增
        while(i <= e2) {
            // 第一个参数传 null,创建
            patch(null, c2[i], container, parentComponent, anchor);
            i++;
        }
        
    }
}

上面的代码,l2 是新节点数组的长度, 新节点会在此 e2 + 1 位置(锚点)之前插入。

这里很巧妙的是,无论是左侧比对,还是右侧比对,都有:

如果是从左往右移动 i,则一定会有 e2 + 1 === l2

如果是从右往左移动 e1 e2,则一定会有 e2 + 1 < l2

通过比较 e2 + 1 l2 的大小,可以确定是在头部插入还是尾部插入新节点,也就是确定插入锚点的位置。

插入多个节点也是同样的锚点,因为是挨着锚点插入的。

ts 复制代码
// 插入点索引
const nextPos = e2 + 1;
// 插入锚点
const anchor = nextPos < l2 ? c2[nextPos].el : null;

// e1 < i <= e2 范围内的,需要新增
while(i <= e2) {
    // 第一个参数传 null,创建
    patch(null, c2[i], container, parentComponent, anchor); // anchor 是同一个
    i++;
}

(1)左侧比对的插入

(2)右侧比对的插入

老的比新的多 - 删除

老节点数比新节点数多的时候:

  1. 只有左侧比对,也就是只有左侧有相同节点,右侧没有
  1. 或者只有右侧比对,也就是只有右侧有相同节点,左侧没有
  1. 双侧都比对

可以发现,一定有 i > e2。旧节点里面,e2 < i <= e1 这个范围内的都需要移除。

ts 复制代码
// 新的比老的长
if(i > e1) {
    ...

// 老的比新的长
} else if (i > e2) {
    while(i <= e1) {
        hostRemove(c1[i].el); // 移除节点
        i++;
    }
}

中间比对

  1. 假如一开始左右都有相同节点,因为在一开始就把左侧相同的节点都比对完了,左指针来到不同节点的位置;接着处理右侧相同节点,右指针也来到了不同节点的位置。
  1. 假如一开始左右侧都没有相同的节点,那就是直接来到中间比对了。

所以,中间比对的条件是 i <= e1 && i <= e2

中间比对存在以下几种情况:

1. 在老的里面存在,新的里面不存在 - 删除

  1. 在老的里面存在,新的里面不存在
  1. 已经 patched 完,还有多的旧节点,需删除

ps: 还有一种情况,在老的、新的里面都存在,但是节点没有 key 也会被删除,但该节点后续会再被创建,所以该节点还是没变。

ts 复制代码
// 新的比旧的多
if(i > e1) {
    // ...(省略代码)
    
// 新的比旧的少
} else if (i > e2) {
    // ...(省略代码)
    
// 中间比对    
} else {
      let s1 = i; // 旧节点开始位置
      let s2 = i; // 新节点开始位置

      /**
       * keyToNewIndexMap key-newIndex 
       * key 新节点的 key 
       * value 新节点的索引
       */
    const keyToNewIndexMap = new Map();
    
    let toBePatched = e2 - s2 + 1; // 中间部分需要 patch 的节点数
    let patched = 0; // 中间部分已经 patch 的数量
    
    // 遍历新节点
    for(let i = s2; i <= e2; i++) {
        const nextChild = c2[i];
        keyToNewIndexMap.set(nextChild.key, i);
    }
    
    let newIndex;
    
    // 遍历旧节点
    for(let i = s1; i <= e1; i++) {
        const prevChild = c1[i];
        
        // 2. 如果应该 patch 的已经 patch 完了,还有剩余的就删除
        if(patched >= toBePatched) {
            hostRemove(prevChild.el);
        }
        
        if(prevChild.key != null) {
            newIndex = keyToNewIndexMap.get(prevChild.key);
        }
       
       // 1. 在新节点中找不到对应老节点的索引,删除
       if(newIndex === undefined) {
           hostRemove(prevChild.el);
       
       // 找到,就 patch 新旧节点
       } else {
           patch(prevChild, c2[newIndex], container, parentCompoent, null);
           patched++;
       }
    }
}

在老的里面不存在,新的里面存在 - 创建

  1. 没有 key 的节点,由于 newIndexundefined 已被移除,但会来到这里新创建,也就是没有 key 的节点,经历了移除、创建两个步骤
  2. key 但不存在于老节点中

判断新节点在老节点里面是否存在,使用了 newIndexToOldIndexMap 映射表,是个数组。

  • index 表示 toBePatched 的序号(从0到toBePatched);

  • value,如果新节点存在于旧节点,值为该节点对应的 旧节点索引值 + 1;如果新节点不存在,值为 0。所以,可以判断值是否为 0,以判断是否存在于旧节点中,只有值为 0 的,创建。

diff 复制代码
if(i > e1) {

} else if(i > e2) {

} else {
    const s1 = i; // 旧节点开始位置
    const s2 = i; // 新节点开始位置
    
    const keyToNewIndexMap = new Map();
    
    let toBePatched = e2 - s2 + 1; // 中间部分需要 patch 的节点数
    let patched = 0; // 中间部分已经 patch 的数量
    
+    // 中间部分,toBePatched 的索引与旧索引的关系表
+    // index - (0 ~ (toBePatched - 1))
+    // value - 旧节点的索引值
+    const newIndexToOldIndexMap = new Array(toBePatched);
    
+    // 初始化 newIndexToOldIndexMap 各项 value = 0
+    for(let i = 0; i < toBePatched; i++) {
+        newIndexToOldIndexMap[i] = 0;
+    }
    
    // 遍历新节点
    for(let i = s2; i <= e2; i++) {
        const nextChild = c2[i];
        keyToNewIndexMap.set(nextChild.key, i);
    }
    
    let newIndex;
    
    // 遍历旧节点
    for(let i = s1; i <= e1; i++) {
        const prevChild = c1[i];
        if(prevChild.key != null) {
            newIndex = keyToNewIndexMap.get(prevChild.key);
        }
       
       // 当前老节点在新的里面不存在,删除
       if(newIndex === undefined) {
           hostRemove(prevChild.el);
       
       // 当前老节点在新的里面存在,更新
       } else {
+           // 当前老节点的索引赋给在 newIndexToOldIndexMap 列表中的值
+           // i + 1 是为了规避初始的 0
+           newIndexToOldIndexMap[newIndex - s2] = i + 1;
            patch(prevChild, c2[newIndex], container, parentComponent, null);
       }
    }
    
+    // 创建新节点
+    for(let i = 0; i < toBePatched; i++) {
+        // newIndexToOldIndexMap[i] === 0 表示经过上面遍历老节点,没有改变值,
+        // 所以说明老节点里面不存在该新节点,需要创建
+        // 1. 上面没有 key 的节点,由于 newIndex 为 undefined 已被移除,但会来到这里新创建
+        // 也就是没有 key 的节点,经历了移除、创建两个步骤
+        // 2. 有 key 但不存在于老节点中
+        if(newIndexToOldIndexMap[i] === 0) {
+            const nextIndex = s2 + i;
+            const nextChild = c2[nextIndex];
+            const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null;
+            patch(null, nextChild, container, parentComponent, anchor);
+        }
+    }
}

在老的和新的里面存在,只是位置变了 - 移动

上面代码,在遍历老节点时,对存在于老节点,也存在于新节点的节点进行了 pacth 更新,但是我们除了需要更新节点,还需要考虑节点的位置是否发生了变化,也就是需要考虑移动节点的情况。

移动节点,用到的是最长递增子序列算法,在 ToBePatched 即中间部分需要 pacth 的节点数组 newIndexToOldIndexMap 中,找到最长的递增子序列。

最长递增子序列,在 Diff 算法中的作用是,找到一组前后顺序不变的节点子集,前后顺序不变,指的就是在一组新节点中,对应的旧索引值是递增的,即原本该靠前的靠前,该靠后的还是原来的靠后。

移动节点时,把最长递增子序列排除掉,只移动不在这个最长递增子序列里的索引对应的节点即可。

根据上面对 newIndexToOldIndexMap 的初始化及在遍历老节点时赋值的操作,我们应该已经清楚它各项的数值表示的是旧索引。

分析新旧节点,旧节点数组索引是递增的,那如果原本靠前的节点索引变得比原来大,那么说明该节点肯定是需要移动到靠后去的。

而如果这组旧节点数组对应在新节点中,原本靠前的节点还是靠前,原本靠后的节点还是靠后,位置不用发生变化。

而我们就是要在 newIndexToOldIndexMap 中找到最长递增的子序列,这些子序列对应的节点不用移动,那么把这些子序列排除掉,只移动不在最长递增子序列里的索引对应的节点即可。

使用 maxNewIndexSoFar 记录历史最大的 newIndex,协助判断是否有需要移动的节点。

使用 moved 标记是否移动。

diff 复制代码
if(i > e1) {

} else if(i > e2) {

} else {
    const s1 = i; // 旧节点开始位置
    const s2 = i; // 新节点开始位置
    
    const keyToNewIndexMap = new Map();
    
    let toBePatched = e2 - s2 + 1; // 中间部分需要 patch 的节点数
    let patched = 0; // 中间部分已经 patch 的数量
    
    // 中间部分,toBePatched 的索引与旧索引的关系表
    // index - (0 ~ (toBePatched - 1))
    // value - 节点的旧索引
    const newIndexToOldIndexMap = new Array(toBePatched);
    
    // 初始化 newIndexToOldIndexMap 各项 value = 0
    for(let i = 0; i < toBePatched; i++) {
        newIndexToOldIndexMap[i] = 0;
    }
    
    // 遍历新节点
    for(let i = 0; i <= e2; i++) {
        const nextChild = c2[i];
        keyToNewIndexMap.set(nextChild.key, i);
    }
    
    let newIndex;
+    // 协助判断 moved,遍历老节点,如当前对应的 newIndex 更大,更新 maxNewIndexSoFar
+    // 理应当前旧节点的对应的 newIndex 比上一个节点大,否则表明有移动
+    let maxNewIndexSoFar = 0;
+    let moved = false;
    
    // 遍历旧节点
    for(let i = 0; i <= e1; i++) {
        const prevChild = c1[i];
        
         // 中间部分,老的比新的多,那么多出来的直接可以被干掉
        if(patched >= toBePatched) {
          hostRemove(prevChild.el);
          continue;
        }
        
        if(prevChild.key != null) {
            newIndex = keyToNewIndexMap.get(prevChild.key);
        }
       
       // 当前老节点在新的里面不存在,删除
       if(newIndex === undefined) {
           hostRemove(prevChild.el);
       
       // 当前老节点在新的里面存在
       } else {
+           // 随着老节点往下遍历,老节点对应的 newIndex 更大的话
+           if(newIndex >= maxNewIndexSoFar) {
+               // 更新 maxNewIndexSoFar 值
+               maxNewIndexSoFar = newIndex; // 相当于记录上一个最大的 newIndex 的值
           
+           // 如果上一个 newIndex (maxNewIndexSoFar) 比当前 newIndex 小,表明有移动
+           } else {
+               moved = true;
+           }
       
           // 当前老节点的索引赋给在 toBePatched 中的值
           // i + 1 是为了规避初始的 0
           newIndexToOldIndexMap[newIndex - s2] = i + 1;
           patch(prevChild, c2[newIndex], container, parentComponent, null);
           patched++;
       }
    }
    
+    // 求得 newIndexToOldIndexMap 的最长递增子序列
+    const increasingSequence = getSequence(newIndexToOldIndexMap);
+    let j = increasingSequence.length - 1;
    
    for(let i = 0; i < toBePatched; i++) {
        const nextIndex = s2 + i; // 待插入节点索引
        const nextChild = c2[nextIndex]; // 待插入节点
        const anchor = nextIndex + 1 < l2 
            ? c2[nextIndex + 1].el 
            : null; // 插入锚点
            
        // 经过上面遍历老节点,没有改变值,所以说明老节点里面不存在该新节点,创建
        if(newIndexToOldIndexMap[i] === 0) {
            patch(null, nextChild, container, parentComponent, anchor);
        }
        
+        // 从需要 patch 的节点中,找出不在最长递增子序列中的索引 i,该索引 i 对应的节点才需要移动
+        if(moved) {
+            if(j < 0 || i !== increasingSequence[j]) {
+                // 移动节点
+                hostInsert(nextChild.el, container, anchor);
+            } else {
+                如果 i 包含在最长递增子序列内,则移动指针,判断下一个
+                j--;
+            }
+        }
    }
}

完整的 patchKeyChildren

这里的注释可能更清晰点。

ts 复制代码
  function patchKeyedChildren(c1, c2, container, parentComponent, parentAnchor) {
    const l2 = c2.length;
    let i = 0;
    let e1 = c1.length - 1;
    let e2 = l2 - 1;

    function isSameVNodeType(n1, n2) {
      return n1.type === n2.type && n1.key === n2.key;
    }

    // 左侧
    while(i <= e1 && i <= e2) {
      const n1 = c1[i];
      const n2 = c2[i];

      if(isSameVNodeType(n1, n2)) {
        patch(n1, n2, container, parentComponent, parentAnchor);
      } else {
        break;
      }

      i++;
    }

    // 右侧
    while(i <= e1 && i <= e2) {
      const n1 = c1[e1];
      const n2 = c2[e2];

      if(isSameVNodeType(n1, n2)) {
        patch(n1, n2, container, parentComponent, parentAnchor);
      } else {
        break;
      }

      e1--;
      e2--;
    }

    // 新的比老的多 创建
    if(i > e1) {
      if(i <= e2) {
        const nextPos = e2 + 1;
        const anchor = nextPos < l2 ? c2[nextPos].el : null;

        while(i <= e2) {
          patch(null, c2[i], container, parentComponent, anchor);
          i++;
        }
      }
    // 老的比新的长 删除
    /**
     * 
     */
    } else if(i > e2) {
      while(i <= e1) {
        hostRemove(c1[i].el);
        i++;
      }
    } else {
      // 中间对比
      let s1 = i; // 旧节点开始位置
      let s2 = i; // 新节点开始位置

      /**
       * keyToNewIndexMap key-newIndex 
       * key 新节点的 key 
       * value 新节点的索引
       */
      const keyToNewIndexMap = new Map(); 
      
      // 新节点中间那些需要patch的个数,newIndexToOldIndexMap 的 length
      const toBePatched = e2 - s2 + 1;
      let patched = 0; // 开始的patch数

      /**
       * newIndexToOldIndexMap 对应中间那些待 patch 的新节点,
       * 索引 index 为 0 ~ (toBepatched-1)
       * 值为老节点索引
       */
      const newIndexToOldIndexMap = new Array(toBePatched);
      let moved = false;
      // 协助判断 moved,遍历老节点,如当前对应的 newIndex 更大,更新 newIndex
      // 理应当前的对应的 newIndex 比上一个节点大,否则表明有移动
      let maxNewIndexSoFar = 0;

      // 初始化 newIndexToOldIndexMap,处于没被标记为有对应的老节点的状态
      for(let i = 0; i < toBePatched; i++) {
        newIndexToOldIndexMap[i] = 0;
      }

      // 遍历新节点
      for(let i = s2; i <= e2; i++) {
        const nextChild = c2[i];
        keyToNewIndexMap.set(nextChild.key, i);
      }

      // 遍历老节点
      for(let i = s1; i <= e1; i++) {
        const prevChild = c1[i];

        // 中间部分,老的比新的多,那么多出来的直接可以被干掉
        if(patched >= toBePatched) {
          hostRemove(prevChild.el);
          continue;
        }

        let newIndex;
        if(prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key);
        } else {
          for(let j = s2; j <= e2; j++) {
            if(isSameVNodeType(prevChild, c2[j])) {
              newIndex = j;

              break;
            }
          }
        }

        // 老的节点不存在于新节点中
        if(newIndex === undefined) {
          hostRemove(prevChild.el);
        
        // 老的节点存在于新节点中
        } else {

          if(newIndex >= maxNewIndexSoFar) {
            // 随着老节点往下遍历,老节点对应的 newIndex 更大的话,更新 maxNewIndexSoFar 值
            maxNewIndexSoFar = newIndex;
          } else {
            // 只要有 newIndex 变小了,就说明有移动
            moved = true;
          }

          // 可以看作是需要 patch 的新节点数组的索引与老节点索引之间的映射关系
          // i + 1 是为了规避 0
          // 0 在 newIndexToOldIndexMap 中表示还没开始找映射关系的一种状态
          newIndexToOldIndexMap[newIndex - s2] = i + 1;
          patch(prevChild, c2[newIndex], container, parentComponent, null);
          patched++;
        }
      } 

      // increasingNewIndexSequence 是最长递增子序列
      // 项代表需要 patch 的节点的索引
      // 在 increasingNewIndexSequence 序列中的元素不需要移动
      const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
      // 相当于一个指针指向最长递增子序列数组尾部
      let j = increasingNewIndexSequence.length - 1;

      // 遍历需要 patch 的新节点找出不在最长递子序列中的节点索引,进行节点的移动,
      // 之所以 for 遍历 toBePatched + 移动 j 指针 可行 ,是因为它们都是升序的
      // 从后往前遍历,性能更优
      for(let i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = i + s2; // 当前需要被移动的节点位置
        const nextChild = c2[nextIndex]; // 当前需要被移动的节点
        const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null; // 插入锚点
        
        // 当前节点不存在于老节点中,创建
        // 1. 上面没有 key 的节点,由于 newIndex 为 undefined 已被移除,但会来到这里新创建
        // 也就是没有 key 的节点,经历了移除、创建两个步骤
        // 2. 有 key 但不存在于老节点中
        if(newIndexToOldIndexMap[i] === 0) {
          patch(null, nextChild, container, parentComponent, anchor);
        }

        if(moved) {
          // 从需要 patch 的节点中,找出不在最长递增子序列中的索引 i,
          // 该索引 i 对应的节点才需要移动
          if(j < 0 || i !== increasingNewIndexSequence[j]) {
            console.log('移动');
            hostInsert(nextChild.el, container, anchor);
          } else {
            // 如果 i 包含在最长递增子序列内,则移动指针,判断下一个
            j--;
          }
        }
      }
    }
  }
相关推荐
王哲晓39 分钟前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
理想不理想v44 分钟前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云1 小时前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
GIS程序媛—椰子2 小时前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
我血条子呢3 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
半开半落3 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt
麦麦大数据3 小时前
基于vue+neo4j 的中药方剂知识图谱可视化系统
vue.js·知识图谱·neo4j
customer083 小时前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea
理想不理想v4 小时前
vue经典前端面试题
前端·javascript·vue.js