手写 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--;
          }
        }
      }
    }
  }
相关推荐
一枚小小程序员哈1 小时前
基于Vue + Node能源采购系统的设计与实现/基于express的能源管理系统#node.js
vue.js·node.js·express
一枚小小程序员哈5 小时前
基于Vue的个人博客网站的设计与实现/基于node.js的博客系统的设计与实现#express框架、vscode
vue.js·node.js·express
定栓5 小时前
vue3入门-v-model、ref和reactive讲解
前端·javascript·vue.js
LIUENG6 小时前
Vue3 响应式原理
前端·vue.js
wycode7 小时前
Vue2实践(3)之用component做一个动态表单(二)
前端·javascript·vue.js
wycode8 小时前
Vue2实践(2)之用component做一个动态表单(一)
前端·javascript·vue.js
第七种黄昏8 小时前
Vue3 中的 ref、模板引用和 defineExpose 详解
前端·javascript·vue.js
pepedd8649 小时前
还在开发vue2老项目吗?本文带你梳理vue版本区别
前端·vue.js·trae
前端缘梦10 小时前
深入理解 Vue 中的虚拟 DOM:原理与实战价值
前端·vue.js·面试
HWL567910 小时前
pnpm(Performant npm)的安装
前端·vue.js·npm·node.js