vue3-runtime(三) diff

101 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第17天,点击查看活动详情

这一期讲核心diff算法,其实就是patchKeyedChildren,即是对有 key 存在的两组arrayChildrendiff 操作

基础实现

接上一节

接着来实现上一节的 patchArrayChildren,简单的作法是比较新旧子节点数量,得到节点数量小的,用这个长度按顺序去对比两个节点的子节点,若旧节点子节点多,则删除多出的节点,若少,则新渲染多的节点,但这种方法太暴力

function patchUnkeyedChildren(c1, c2, container, anchor) {
  const oldLength = c1.length;
  const newLength = c2.length;
  const commonLength = Math.min(oldLength, newLength);
  for (let i = 0; i < commonLength; i++) {
    patch(c1[i], c2[i], container, anchor);
  }
  if (newLength > oldLength) {
    mountChildren(c2.slice(commonLength), container, anchor);
  } else if (newLength < oldLength) {
    unmountChildren(c1.slice(commonLength));
  }
}

举个例子:

c1: a b c
c2: x a b c

这样会导致a改成x,b改成a,c改成b,渲染c,明明只要在a前面加个x就行了。复用性太低

所以推出diff算法,让真实dom的更新操作做到最小,但前提是带key,好判断是否同一节点

所以改了一下 patchChildren

function patchChildren(n1, n2, container, anchor) {
  const { shapeFlag: prevShapeFlag, children: c1 } = n1;
  const { shapeFlag, children: c2 } = n2;
​
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
。。。
  } else {
    // c2 is array or null
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // c1 was array
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // c2 is array
        // 简单认为头一个元素有key就都有key
        if (c1[0] && c1[0].key != null && c2[0] && c2[0].key != null) {
          patchKeyedChildren(c1, c2, container, anchor);
        } else {
          patchUnkeyedChildren(c1, c2, container, anchor);
        }
      } else {
        // c2 is null
        unmountChildren(c1);
      }
    } else {
。。。
    }
  }
}

在react的实现

有个核心的思想,依次找到的新节点在旧节点的位置是升序的话,说明不需要移动

zhuanlan.zhihu.com/p/553744711

旧:a b c

新:a c b

遍历a时,在旧中找到index为0,更新即可,

遍历c,index为2,更新max为2,更新c节点

遍历到b,index为1,比max小,需要移动b到c后边,也就是插入到c后边元素前边,insertBefore (b,newVnodeArray[i-1].nextSibling)

设置max为0,遍历c2元素 从c1中找key相等的元素,记录找到的index,如果大于max,更新max,如果小于,则将当前元素移动到c2前一个元素后(也就是 c2前一个元素的后一位元素(c2[i-1].el.nextSibling)的前面)

如果c2比c1数量多,需要添加挂载

如果少,需要遍历找到c2不存在的c1,删掉

function patchKeyedChildren(c1, c2, container, anchor) {
  let maxNewIndexSoFar = 0;
  for (let i = 0; i < c2.length; i++) {
    const next = c2[i];
    let find = false;
    for (let j = 0; j < c1.length; j++) {
      const prev = c1[j];
      if (prev.key === next.key) {
        find = true;
        patch(prev, next, container, anchor);
        if (j < maxNewIndexSoFar) {
          const curAnchor = c2[i - 1].el.nextSibling;
          container.insertBefore(next.el, curAnchor);
        } else {
          maxNewIndexSoFar = j;
        }
        break;
      }
    }
    if (!find) {
      const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
      patch(null, next, container, curAnchor);
    }
  }
  for (let i = 0; i < c1.length; i++) {
    const prev = c1[i];
    if (!c2.find((next) => next.key === prev.key)) {
      unmount(prev);
    }
  }
}
  • 优化,用map代替for遍历查找

用map代替for,用map存储c1的key, 节点和index

遍历c2,找不到c1就添加。找到就patch,然后看着插入,把map相应key去掉,最后map存在的节点都是新节点中没的,都删掉。

function patchKeyedChildren(c1, c2, container, anchor) {
  const map = new Map();
  c1.forEach((prev, j) => {
    map.set(prev.key, { prev, j });
  });
  let maxNewIndexSoFar = 0;
  for (let i = 0; i < c2.length; i++) {
    const next = c2[i];
    const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
    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;
      }
      map.delete(next.key);
    } else {
      patch(null, next, container, curAnchor);
    }
  }
  map.forEach(({ prev }) => {
    unmount(prev);
  });
}

缺点

举个例子

a b c
c a b
肉眼看只需要移动一次

react算法则需要移动两次,

遍历新节点时,c取到index为2,遍历a,得0,那么a要移动到c后边,遍历b为1,移动到a后边。移动了两次。

vue2的diff

blog.csdn.net/ioth5/artic…

四个指针,旧头旧尾,新头新尾

while 新头<=新尾

if 两头元素相等,两头指针后移

else if 两尾元素相等,两尾指针前移

else if 新头等于旧尾,新头指针后移,旧尾前移

else if 新尾等于旧头。。。

else if 遍历旧节点中有没有新头,有则把元素拉到旧头指针前,没有则新建元素,放旧头指针前

一直循环

最后删掉 旧头尾间的元素

vue3

github1s.com/vuejs/core/…

1 从左到右依次对比,相同就继续前进

2 从右到左依次对比,相同就继续后退

3 如果旧节点比完了,说明其他的都是新节点,挂载上去

4 如果新节点比完了,说明多了一些旧节点,要删掉

5 如果都没比完,采用传统 diff 算法,但不真的添加和移动,只做标记和删除。建数组fill 0,存新节点在旧节点位置,查找过程中比对找到的位置,如果有降序,说明要移动,找出最长上升子序列,得出移动最少的步骤

从右到左遍历,如果是-1,则挂载上去,如果当前元素是序列末位,则说明存在序列中,不用移动,序列末位前移;若不是,则要移动到新节点中该节点下一位节点之前。或者直接说移动到当前位置。

6 查找过程中比对找到的位置,如果没降序,说明是单纯的插入。 a b c=>a x b c

1-2 从左到右依次对比,相同就继续前进;从右到左依次对比,相同就继续后退

diff2.469b3f9b.png

3 如果旧节点比完了,说明其他的都是新节点,挂载上去

diff5.edd80c32.png 4 如果新节点比完了,说明多了一些旧节点,要删掉

diff7.df9450ee.png

5 如果都没比完,采用传统 diff 算法,但不真的添加和移动,只做标记和删除。建数组fill 0,存新节点在旧节点位置,查找过程中比对找到的位置,如果有降序,说明要移动,找出最长上升子序列,得出移动最少的步骤

diff12.566f24a9.png 此时最长序列是 【2,3】 相应的索引组成的数组的【0,1】

从右到左遍历 sources,如果等于0(源码用的0,代码用的-1),说明要插入;如果当前索引等于序列【0,1】的末位,那么说明不需要移动,末位前移;如果不等于序列末位,说明要移动这个节点,b要移动到g前面,g为当前元素索引+1.

6 也有经过1-2,但不需要移动的情况,也是插入到当前元素索引后面元素前

c1: a b c

c2: a x b y c

source: [1,-1,2,-1,3]

seq: [1,2,3]

旧 d b c a

新 a b d c ,得【3,1,0,2】

最长序列 【1,2】[b,c] 在新中索引为 【1,3】

从右到左比3-0对,3一样,不挪,缩,2d不一样,挪,1b一样,0a不一样挪。

代码

function patchKeyedChildren(c1, c2, container, anchor) {
  // anchor来源于fragment的尾节点。为了避免插入节点插到末位去了
  // anchor没有的话,那就是undefined,insertBefore插入undefined不会报错,就是插入末尾
  let i = 0,
    e1 = c1.length - 1,
    e2 = c2.length - 1;
  // 1.从左至右依次比对
  // key的判断可能要换成isSameVNodetype
  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--;
  }
// a b c
// a d b c
// 像这种情况
  if (i > e1) {
    // 3.经过1、2直接将旧结点比对完,则剩下的新结点直接mount,就是d到插到b前
    // 取到b的位置,在b的前面mount元素。
    // 如果从右开始没有匹配到,那么e2+1的元素是undefined,那还是用anchor
    const nextPos = e2 + 1;
    const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
    for (let j = i; j <= e2; j++) {
      patch(null, c2[j], container, curAnchor);
    }
  } else if (i > e2) {
    // 3.经过1、2直接将新结点比对完,则剩下的旧结点直接unmount
    for (let j = i; j <= e1; j++) {
      unmount(c1[j]);
    }
  } else {
    // 4.采用传统diff算法,但不真的添加和移动,只做标记和删除
    const map = new Map();
    for (let j = i; j <= e1; j++) {
      const prev = c1[j];
      map.set(prev.key, { prev, j });
    }
    // used to track whether any node has moved
    let maxNewIndexSoFar = 0;
    let move = false; // 用来校验是否需要移动 情况6
    const toMounted = [];
    const source = new Array(e2 - i + 1).fill(-1); //占位-1,找到就赋值
    // 前后被匹配完后,遍历中间没被匹配的节点
    for (let k = 0; k < e2 - i + 1; k++) {
      const next = c2[k + i];
      if (map.has(next.key)) {
        const { prev, j } = map.get(next.key);
        patch(prev, next, container, anchor);
        // 如果一直都是升序的,那就不需要移动
        if (j < maxNewIndexSoFar) {
          move = true;
        } else {
          maxNewIndexSoFar = j;
        }
        source[k] = j;
        map.delete(next.key);
      } else {
        // 将待新添加的节点放入toMounted,情况6
        toMounted.push(k + i);
      }
    }

    // 先刪除多余旧节点
    map.forEach(({ prev }) => {
      unmount(prev);
    });

    if (move) {
      // 5.需要移动,则采用新的最长上升子序列算法
      const seq = getSequence(source); // [0,1]
      let j = seq.length - 1;
      for (let k = source.length - 1; k >= 0; k--) {
        if (k === seq[j]) {
          // 不用移动,从后到前遍历,如果等于最长上升子序列里的末位,说明存在序列里
          j--;
        } else {
          // 找出要插入的位置,
          // 也就是 i 前面删掉的相同的点 + 当前的source的位置 得到当前的位置
          // 在当前位置的下一位前插入
          const pos = k + i;
          const nextPos = pos + 1;
          const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
          if (source[k] === -1) {
            // mount
            patch(null, c2[pos], container, curAnchor);
          } else {
            // 移动   当前节点移动到下一节点前
            container.insertBefore(c2[pos].el, curAnchor);
          }
        }
      }
    } else if (toMounted.length) {
      // 6.不需要移动,但还有未添加的元素
      // c1: a b c
      // c2: a x b y c
      // y 需要插入到 c 前
      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);
      }
    }
  }
}

最长上升子序列

dp 版 O(n^2)

1 2 5 3 4 6 4

遍历元素时,前面找比他小的元素,以它为结尾的最长上升子序列是 比他小的元素的序列+1 里面找最大的就行

比如3 找了1和2 和2合作比较大,结果为3

ps:所有比他小的都要比较,比如6不能只找5去比

var lengthOfLIS = function (nums) {
  let dp = new Array(nums.length).fill(1);
  let max = 1;
  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }
    max = Math.max(max, dp[i]);
  }
  return max;
};

贪心算法 O(n^2)

维护一个数组,遍历,如果大于数组末位,加进去,如果小,找位置插进去。

ps:插入后,后面的数据不用管,反正数组长度不会撑大。

var lengthOfLIS = function (nums) {
  let arr = [nums[0]];
  for (let i = 1; i < nums.length; i++) {
    if (nums[i] > arr[arr.length - 1]) {
      arr.push(nums[i]);
    } else {
      for (let j = 0; j < arr.length; j++) {
        if (nums[i] <= arr[j]) {
          arr[j] = nums[i];
          break;
        }
      }
    }
  }
  return arr.length;
};

贪心算法+二分 O(nlogn)

由于数组是升序,所以 插入的时候用二分去插

var lengthOfLIS = function (nums) {
  let arr = [nums[0]];
  for (let i = 1; i < nums.length; i++) {
    if (nums[i] > arr[arr.length - 1]) {
      arr.push(nums[i]);
    } else {
      let l = 0,
        r = arr.length - 1;
      while (l <= r) {
        let mid = ~~((l + r) / 2);
        if (nums[i] > arr[mid]) {
          l = mid + 1;
        } else if (nums[i] < arr[mid]) {
          r = mid - 1;
        } else {
          l = mid;
          break;
        }
      }
      arr[l] = nums[i];
    }
  }
  return arr.length;
};

最终版,要求返回索引。略过-1,返回子序列

新建数组position,存储元素插入序列时在序列的位置。得到最终子序列长度后,从右到左在position数组找出元素的值与子序列长度-1相等,说明这个是子序列最大值,取出它的索引。子序列最大值-1,继续找。

例子:

1 2 5 3 4 0

1 2 3 4// 最长子序列

0 1 2 2 3 0 // 索引数组position

0 1 3 4 //最终结果 arr

长度是4,要找索引为3的,从右到左找position元素,取出3,3的索引是4,arr[3] = 4

然后长度-1,找2,。。。再找1

function getSequence(nums) {
  let arr = [];
  let position = [];
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] === -1) { //源码是0不参与
      continue;
    }
    // arr[arr.length - 1]可能为undefined,此时nums[i] > undefined为false
    if (nums[i] > arr[arr.length - 1]) {
      arr.push(nums[i]);
      position.push(arr.length - 1);
    } else {
      let l = 0,
        r = arr.length - 1;
      while (l <= r) {
        let mid = ~~((l + r) / 2);
        if (nums[i] > arr[mid]) {
          l = mid + 1;
        } else if (nums[i] < arr[mid]) {
          r = mid - 1;
        } else {
          l = mid;
          break;
        }
      }
      arr[l] = nums[i];
      position.push(l);
    }
  }
  let cur = arr.length - 1;
  // 这里复用了arr,它本身已经没用了
  for (let i = position.length - 1; i >= 0 && cur >= 0; i--) {
    if (position[i] === cur) {
      arr[cur--] = i;
    }
  }
  return arr;
}

源码

源码优化了索引的取值。我是一言难尽。

function getSequence(arr: number[]): number[] {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      u = 0
      v = result.length - 1
      while (u < v) {
        c = (u + v) >> 1
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }
  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}