Vue3框架原理学习实现(四)- diff算法

257 阅读4分钟

目的

这次的目的是优化实现diff算法,上次实现的patchChildren实现较为简单,这次优化和实现Vue3的diff算法。

1.React diff 算法

c1: a b c
c2: c b a

面对上述情况,上次实现的patchChildren会逐个 a->b->c 比较,会重新生成ca但是这个明显只是顺序改变,需要优化。

为了标识前后的节点,所以diff这里给VNode引入了key标识虚拟节点。根据这个key值相等我们只改变节点的位置和属性,而不重新生成或者删除节点。 面对以上的前后节点情况,我们可以根据C2中的节点去查找它是否在C1中,来更新dom属性和位置。

 function patchKeyedChildren(c1, c2, container, anchor) {
   for (let i = 0; i < c2.length; i++) {
     const next = c2[i];
     for (let j = 0; j < c1.length; j++) {
       const prev = c1[i];
       //c1 中是否有key相同节点
       if (next.key === prev.key) {
         //更新dom
         patch(prev, next, container, anchor);
         // 插入next.el 的位置 在前一个节点c2[i - 1]的下一个节点之前插入 也就是c2[i - 1].nextSibling 
         // const curAnchor = c1[0].el; //如果是 第一个的话
         // const curAnchor = c2[i - 1].el.nextSibling;
         const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
         container.insertBefore(next.el, curAnchor);
         break;
      }
   }
   }
 }

上述代码复用了dom节点,但是对于移动节点的判断还是不够灵活

c1: a b c
c2: a b c

面对上述前后节点列表,明显不需要移动节点,但是上述会依次移动3次。需要尽量避免不必要的dom操作开销。

1.这里我们遍历C2中节点, 对于c2中每个节点命名为next,在c1中对应的相同key节点命名为prev
2.如果找到了,记录prev 的 index
3.如果index 呈 升序,不需要移动
4.如果index 不呈升序 需要移动

解决:

设置一个变量maxNewIndexSoFar, 记录当前的nextc1中找到的index的最大值。若新找到的index 大于等于 maxNewIndexSofar,说明index呈升序,不需要移动,并且更新maxNewIndexSofarindex,如果index小于maxNewIndexSofar,说明需要移动。他应该移动到上一个next之后,因此anchor设置为c2[i-1].nextSibling

function patchKeyedChildren(c1, c2, container, anchor) {
  let maxNewIndexSoFar = 0;
  for (let i = 0; i < c2.length; i++) {
    const next = c2[i];
    //判断c2中相同key节点是否在c1中找到的标志
    let find = false;

    for (let j = 0; j < c1.length; j++) {
      const prev = c1[i];

      if (next.key === prev.key) {
        find = true;
        patch(prev, next, container, anchor);
            //如果小于 说明这里不是升序 需要移动节点 这里插入的位置前面代码位置一样 插入到上一个next之后
        if (j < maxNewIndexSoFar) {
          const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
          container.insertBefore(next.el, curAnchor);
        } else {
        //大于maxNewIndexSofar 说明升序 不需要移动
          maxNewIndexSoFar = j;
        }
        break;
      }
    }
  //c2 节点在c1中没找到相同key 节点 则直接创建节点mount操作
    if (!find) {
      const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
      patch(null, next, container, curAnchor);
    }
  }
  //c2中节点遍历完毕 c1[i]中其他节点进行 unmount操作
  for (let i = 0; i < c1.length; i++) {
    const prev = c1[i];
    if (!c2.find((next) => (next.key = prev.key))) {
      unmount(prev);
    }
  }
}

上面代码用了两个for循环。这里我们可以用一个Map来存入c1中的节点,提高查找效率。

function patchKeyedChildren(c1, c2, container, anchor) {
  let maxNewIndexSoFar = 0;
  //用一个Map 存储c1中的节点 以key => {prev, j}存储信息
  const map = new Map();
  c1.forEach((prev, j) => map.set(prev.key, { prev, j }));

  for (let i = 0; i < c2.length; i++) {
    const next = c2[i];
    const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
        //判断是否c1中存在相同key节点
    if (map.has(next.key)) {
      const { prev, j } = map.get(next.key);

      patch(prev, next, container, anchor);
      if (j < maxNewIndexSoFar) {
        container.insertBefore(next.el, curAnchor);
      } else {
        maxNewIndexSoFar = j;
      }
      //删除c1中已遍历节点
      map.delete(next.key);
    } else {
      //不存在map中的话则创建节点
      patch(null, next, container, curAnchor);
    }
  }
  //map中多余节点进行unmount操作
  map.forEach(({ prev }) => {
    unmount(prev);
  });
}
    0 1 2
c1: a b c
c2: c a b

面对上述情况,可以看出最佳方案只需要移动一次c节点, 但是react算法这里会移动两次。

1.c节点index2 此时maxNewIndexSoFar为0,2 > 0, maxNewIndexSofar 更新为2 这里不需要移动
2.a节点index0 0 < 2 a节点需要移动操作
3.b节点index1 1 < 2 b节点需要移动操作 
一共两次移动操作

Vue2中diff算法

vue2 采用的是 双端比较算法,源自snabbdom.

Vue3中diff算法

Vue3 中采用的是另外的核心diff算法,它借鉴于iviinferno.

步骤依次是

  1. 从左至右依次比对
  2. 从右到左依次比对

image.png 3.经过1、2 直接将旧节点比对完, 则剩下的新节点mount,此时 i > e1的话

image.png

经过1、2 直接将新节点对比完,则剩下旧节点unmount 此时 i > e2

image.png

  1. 若不满足3 采用传统diff算法,但是不添加和移动,只做标记和删除
  2. 创建一个source 数组 用于存储剩下节点在C1中的index

image.png

image.png 需要移动,采用新的最长上升子序列算法 根据source数组算出一个最长上升子序列 seq

什么是最长递增子序列,给定一个数值序列,找到他的一个子序列,并且在子序列中的值是递增的,子序列中的元素在原序列中不一定连续
例如[0,8,4,12]
它的最长子序列可以是 [0,8,12] [0,4,12]也是可以的

image.png seq 得到的是 source 最长上升子序列的在source中的下标,他的意义是在seq 中的元素都不需要移动,而没有在seq中的元素都需要移动。 因此得到一下算法:

  1. 设两个指针分别指向sourceseq,source从后往前遍历。
  2. 遇到-1 执行mount source指针位置减一
  3. source 指针和seq相等,说明不用移动, 两个指针都减一
  4. source 指针和seq 指针不想等,执行移动,移动完之后source 指针减一 这里anchor 的计算 是 curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor

特殊情况

c1: a b c
c2: a x b y c

上面的例子 move 值是false 表示不需要移动, 但是仍然有未添加的元素 因此需要一个专门的toMounted去处理这种情况,toMounted存入的是元素的在C2下标

//1 5 3 7
function patchKeyedChildren(c1, c2, container, anchor) {
  let i = 0;
  let e1 = c1.length - 1;
  let e2 = c2.length - 1;

  //1.从左至右依次比对
  while (i <= e1 && i <= e2 && c1[i].key === c2[i].key) {
    patch(c1[i], c2[i], container, anchor);
    i++;
  }

  //2.从右至左 依次对比
  while (i <= e1 && i <= e2 && c1[e1].key === c2[e2].key) {
    patch(c1[e1], c2[e2], container, anchor);
    e1--;
    e2--;
  }

  //c1: a b c
  //c2: a d b c
  // 旧节点比对完毕 直接mount 新节点
  if (i > e1) {
    for (let j = i; j <= e2; j++) {
      const nextPos = e2 + 1;
      const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
      patch(null, c2[i], container, curAnchor);
    }
    //新的节点 比对完成 旧节点 unmount
  } else if (i > e2) {
    for (let j = i; j <= e1; j++) {
      unmount(c1[j]);
    }
    //不满足上述 采用传统diff 算法 只做标记和删除
  } else {
    let maxNewIndexSoFar = 0;
    // 4.采用传统diff算法,但不真的添加和移动,只做标记和删除
    // 将 i 至 e1 需要传统diff 算法处理的 c1 中节点存入Map 方便c2遍历
    const map = new Map();
    for (let j = i; j <= e1; j++) {
      const prev = c1[j];
      map.set(prev.key, { prev, j });
    }
    //source 数组i-e2需要处理的节点的在C1中的index 不存在为 -1
    const source = new Array(e2 - i + 1).fill(-1);
    //判断是否需要移动
    let move = false;
    //为C2节点 在C1中相同key 节点恰好是升序时 还存在新增加的节点 存在的话 存入toMounted
    const toMounted = [];

    for (let k = 0; k < e2 - i + 1; k++) {
      //这里从i 开始遍历 直到e2;
      const next = c2[k + i];
      //c1中存在相同key节点
      if (map.has(next.key)) {
        const { prev, j } = map.get(next.key);

        patch(prev, next, container, anchor);
        //不是升序 需要移动
        if (j < maxNewIndexSoFar) {
          move = true;
        } else {
          //升序 更新 maxNewIndexSofar
          maxNewIndexSoFar = j;
        }
        //将节点对应的index存入 source中
        source[k] = j;
        //删除Map 中 C1 和 C2 都有的key 节点
        map.delete(next.key);
      } else {
        // c2 中不存在 c1 中 的节点 防止 c2中节点呈升序时 这些节点不挂载
        toMounted.push(i + k);
      }
    }
    //不存在C2中的直接unmount
    map.forEach(({ prev }) => {
      unmount(prev);
    });

    if (move) {
      //5. 需要移动 则采用新的最长上升子序列算法
      const seq = getSequence(source);
      let j = seq.length - 1;
      //倒序遍历source 数组
      for (let k = source.length - 1; k >= 0; k--) {
        if (source[k] === -1) {
          //mount -1代表不存在 直接创建节点
          //pos i + k 代表在c2中的位置 source的长度表示只是需要传统diff算法处理的长度
          const pos = i + k;
          //查到pos + 1 也就是C2 pos下一个节点之前 因为是source倒序遍历 pos + 1的位置已经处理好了 如果不存在则直接插入最后了 也就是anchor
          const nextPos = pos + 1;
          const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
          patch(null, c2[pos], container, curAnchor);
          //是否等于最长子序列 最后一项
        } else if (seq[j] === k) {
          //不用移动 j--代表向左移动一位 继续匹配
          j--;
        } else {
          //需要移动
          const pos = i + k;
          const nextPos = pos + 1;
          const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
          container.insertBefore(c2[pos].el, curAnchor);
        }
      }
    } else if (toMounted.length) {
      //c2呈升序时 但是存在新的节点 则需要创建这些节点
      for (let k = toMounted.length - 1; k >= 0; k--) {
        const pos = toMounted[k];
        const nextPos = pos + 1;
        const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
        patch(null, c2[pos], container, curAnchor);
      }
    }
  }
}

上面的getSequence是计算source上升最长子序列,返回的是在source的下标 这里的相关实现 可以参考leetcode相关题目

//最长上升子序列 算法
function getSequence(nums) {
  //记录最长子序列 最大长度 和 利用它记录 对应的 index 位置
  const records = [nums[0]];
  // 记录 对用nums各个位置 最长子序列 位置
  const position = [0];
  for (let i = 1; i < nums.length; i++) {
    // -1 这里不做处理
    if (nums[i] === -1) continue;
    if (nums[i] > records[records.length - 1]) {
      records.push(nums[i]);
      position.push(records.length - 1);
    } else {
      for (let j = 0; j < records.length; j++) {
        if (records[j] > nums[i]) {
          records[j] = nums[i];
          position.push(j);
          break;
        }
      }
    }
    // 找出一组最长子序列
    let cur = records.length - 1;
    for (let i = position.length - 1; i >= 0 && cur >= 0; i--) {
      if (position[i] === cur) {
        records[cur] = i;
        cur--;
      }
    }
    return records;
  }
}

完工

看了两遍视频,然后自己跟着写了一遍,然后再写篇博客记录一下,对于相关知识点的认识更深了,以后还需多多学习。