Vue中diff算法

166 阅读3分钟

DIFF算法

  1. Vue中的diff算法称为patching算法,它由Snabbdom修改而来,虚拟DOM要想转化为真是DOM就需要通过patch方法转换。
  2. 最初Vue1.x视图中每个依赖均有更新函数对应,可以做到精准更新,因此并不需要虚拟DOM和patching算法支持,但是这样粒度过细导致Vue1.x无法承载较大应用;Vue2.x中为了降低Watcher粒度,每个组件只有一个Watcher与之对应,此时就需要引入patching算法才能精确找到发生变化的地方并高效更新。

3.Vue中diff执行的时刻是组件内响应式数据变更触发实力执行其更新函数时,更新函数会在此执行render函数获得最新的虚拟DOM,然后执行patch函数,并传入新旧两次虚拟DOM,通过比对两者找到变化的地方,最后将其转化为对应的DOM操作。

  1. patch过程是一个递归过程,遵循深度优先、同层比较的策略;以Vue3的patch为例
  • 首先判断两个节点是否为相同同类节点,不同则删除重新创建

  • 如果双方都是文本则更新文本内容

  • 如果双发都是元素节点则递归更新子元素,同时更新元素属性

  • 更新子节点时又分了几种情况:

    • 新的子节点是文本,老的子节点是数组则清空,并设置文本;
    • 新的子节点是文本,老的子节点时文本则直接更新文本;
    • 新的子节点是数组,老的子节点时文本则清空文本,并创建新子节点数组中的子元素;
    • 新的子节点是数组,老的子节点也是数组,那么比较两组子节点,更新细节略
  1. Vue3中引入的更新策略:编译期优化patchFlags、block等。
// dom
// old array  a b c d e f g
// new array  a b e c d h f g

// mountElement 新增元素 h
// patch  复用元素 a b c d e f g
// unmount 删除元素
// todo
// move 元素移动 ?

exports.diffArray = (c1, c2, { mountElement, patch, unmount, move }) => {
  function isSameVnodeType(n1, n2) {
    return n1.key === n2.key; //&& n1.type === n2.type;
  }

  let i = 0;
  const l1 = c1.length;
  const l2 = c2.length;
  let e1 = l1 - 1;
  let e2 = l2 - 1;

  // *1. 从左边往右查找,如果节点可以复用,则继续往右,不能就停止循环
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    if (isSameVnodeType(n1, n2)) {
      patch(n1.key);
    } else {
      break;
    }
    i++;
  }

  // *2. 从右边往左边查找,如果节点可以复用,则继续往左,不能就停止循环
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];
    if (isSameVnodeType(n1, n2)) {
      patch(n1.key);
    } else {
      break;
    }
    e1--;
    e2--;
  }

  // *3.1 老节点没了,新节点还有
  if (i > e1) {
    if (i <= e2) {
      while (i <= e2) {
        const n2 = c2[i];
        mountElement(n2.key);
        i++;
      }
    }
  }

  // *3.2 老节点还有,新节点没了
  else if (i > e2) {
    while (i <= e1) {
      const n1 = c1[i];
      unmount(n1.key);
      i++;
    }
  } else {
    // *4 新老节点都有,但是顺序不稳定
    // 遍历新老节点
    // i是新老元素的起始位置

    // *4.1 把新元素做成Map图,key: value(index)
    const s1 = i;
    const s2 = i;

    //
    const keyToNewIndexMap = new Map();
    for (i = s2; i <= e2; i++) {
      const nextChild = c2[i];
      keyToNewIndexMap.set(nextChild.key, i);
    }

    // *4.2 当前还有多少新元素要被patch(新增、更新)
    const toBePatched = e2 - s2 + 1;
    let patched = 0;

    const newIndexToOldIndexMap = new Array(toBePatched);
    // 下标是新元素的相对下标,值是老元素的下标+1
    for (i = 0; i < toBePatched; i++) {
      newIndexToOldIndexMap[i] = 0;
    }

    // *4.3 先遍历老元素 检查老元素是否要被复用,如果复用就patch,如果不能复用就删除
    let moved = false;
    let maxNewIndexSoFar = 0;
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i];

      if (patched >= toBePatched) {
        unmount(prevChild.key);
        continue;
      }

      const newIndex = keyToNewIndexMap.get(prevChild.key);
      if (newIndex === undefined) {
        // 节点没法复用
        unmount(prevChild.key);
      } else {
        //移动发生在这里,这里的节点可能要被移动(ecd)
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex;
        } else {
          // 相对位置发生变化
          moved = true;
        }
        newIndexToOldIndexMap[newIndex - s2] = i + 1;
        patch(prevChild.key);
        patched++;
      }
    }

    // *4.4 遍历新元素 mount move
    // 返回不需要移动的节点
    const increasingNewIndexSequece = moved
      ? getSequence(newIndexToOldIndexMap)
      : [];
    let lastIndex = increasingNewIndexSequece.length - 1;
    // 相对下标
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextChildIndex = s2 + i;
      const nextChild = c2[nextChildIndex];

      // 判断nextChild是mount还是move
      // 在老元素中出现的元素可能要move,没有出现过的要mount
      if (newIndexToOldIndexMap[i] === 0) {
        mountElement(nextChild.key);
      } else {
        // 可能move
        if (lastIndex < 0 || i !== increasingNewIndexSequece[lastIndex]) {
          move(nextChild.key);
        } else {
          lastIndex--;
        }
      }
    }
  }

  // 返回不需要移动的节点
  // 得到最长递增子序列lis(算法+实际应用,跳过0),返回路径
  function getSequence(arr) {
    // return [1, 2];

    // 最长递增子序列路径, 有序递增
    const lis = [0];

    // 相当于复制一份arr数组,此数组用于稍后纠正lis用的
    const recordIndexOfI = arr.slice();

    const len = arr.length;
    for (let i = 0; i < len; i++) {
      const arrI = arr[i];
      // 如果元素值为0,证明节点是新增的,老dom上没有,肯定不需要移动,所以跳过0,不在lis里
      if (arrI !== 0) {
        // 判断arrI插入到lis哪里
        const last = lis[lis.length - 1];
        // arrI比lis最后一个元素还大,又构成最长递增
        if (arr[last] < arrI) {
          // 记录第i次的时候,本来的元素是什么,后面要做回溯的
          recordIndexOfI[i] = last;
          lis.push(i);
          continue;
        }
        // 二分查找插入元素
        let left = 0,
          right = lis.length - 1;
        while (left < right) {
          const mid = (left + right) >> 1;
          //  0 1 2 3 4 (1.5)
          if (arr[lis[mid]] < arrI) {
            // mid< 目标元素 , 在右边
            left = mid + 1;
          } else {
            right = mid;
          }
        }

        if (arrI < arr[lis[left]]) {
          // 从lis中找到了比arrI大的元素里最小的那个,即arr[lis[left]]。
          // 否则则没有找到比arrI大的元素,就不需要做什么了
          if (left > 0) {
            // 记录第i次的时候,上次的元素的是什么,便于后面回溯
            recordIndexOfI[i] = lis[left - 1];
          }
          lis[left] = i;
        }
      }
    }

    // 遍历lis,纠正位置
    let i = lis.length;
    let last = lis[i - 1];

    while (i-- > 0) {
      lis[i] = last;
      last = recordIndexOfI[last];
    }

    return lis;
  }
};