diff算法可能会问到的(Vue2+Vue3)

353 阅读8分钟

前言

本文根据掘金一些大佬的文章总结+自己理解+《Vue.js设计与实现》总结出来的,加粗部分可以直接当面试答案来理解记忆回答,不加粗部分也很重要是加分项!!!

diff算法(虚拟dom)

  • Vue2:其实就是snabbdom库。

源码架构

-src
  -moduel
      - attributes
      - props
      - styles
      - class 
  ...core
⭐️ sameVnode:判断两个vnode节点是否相同

源码: 通过判断vnode节点上的key、sel、幽灵标签或者文本等等是否相等,这里说一下sel是选择器(div或者class等等),key就是唯一标识

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  const isSameKey = vnode1 . key === vnode2 . key;
  const isSameIs = vnode1 . data ?. is === vnode2 . data ?. is;
  const isSameSel = vnode1 . sel === vnode2 . sel;
  const isSameTextOrFragment =
    !vnode1 . sel && vnode1 . sel === vnode2 . sel
      ? typeof vnode1 . text === typeof vnode2 . text
      : true;
  return isSameSel && isSameKey && isSameIs && isSameTextOrFragment;
}
⭐️ init:核心core代码,根据传入的module,返回一个函数 patch (里面是核心逻辑)
⭐️ patch :整个流程核心,

image.png

return function patch(
    oldVnode: VNode | Element | DocumentFragment,
    vnode: VNode
  ): VNode {
    let i: number, elm: Node, parent: Node;
    const insertedVnodeQueue: VNodeQueue = [];
    for (i = 0; i < cbs . pre . length; ++i) cbs . pre[i]();

    // if (isElement(api, oldVnode)) {
    //  oldVnode = emptyNodeAt(oldVnode);
    // } else if (isDocumentFragment(api, oldVnode)) {
    //  oldVnode = emptyDocumentFragmentAt(oldVnode);
    // }
        
    
    if (sameVnode(oldVnode, vnode)) {
    // 如果是相同节点
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
    // 如果不是相同节点
      elm = oldVnode . elm!;
      parent = api . parentNode(elm) as Node;

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api . insertBefore(parent, vnode . elm!, api . nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue . length; ++i) {
      insertedVnodeQueue[i] . data! . hook! . insert!(insertedVnodeQueue[i]);
    }
    for (i = 0; i < cbs . post . length; ++i) cbs . post[i]();
    return vnode;
  };
}
⭐️ patchVnode:对比两个节点的差异,这里设计的情况比较多,源码比较的复杂,所以我引用@渣渣xiong 这位大佬的思维导图

juejin.cn/post/700026…

具体源码:

  function patchVnode(
    oldVnode: VNode,
    vnode: VNode,
    insertedVnodeQueue: VNodeQueue
  ) {
    const hook = vnode . data ?. hook;
    hook ?. prepatch ?. (oldVnode, vnode);
    const elm = (vnode . elm = oldVnode . elm)!;
    if (oldVnode === vnode) return;
    if (
      vnode . data !== undefined ||
      (vnode . text !== undefined && vnode . text !== oldVnode . text)
    ) {
      vnode . data ??= {};
      oldVnode . data ??= {};
      for (let i = 0; i < cbs . update . length; ++i)
        cbs . update[i](oldVnode, vnode);
      vnode . data ?. hook ?. update ?. (oldVnode, vnode);
    }
    const oldCh = oldVnode . children as VNode[];
    const ch = vnode . children as VNode[];
    if (vnode . text === undefined) {
      if (oldCh !== undefined && ch !== undefined) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      } else if (ch !== undefined) {
        if (oldVnode . text !== undefined) api . setTextContent(elm, "");
        addVnodes(elm, null, ch, 0, ch . length - 1, insertedVnodeQueue);
      } else if (oldCh !== undefined) {
        removeVnodes(elm, oldCh, 0, oldCh . length - 1);
      } else if (oldVnode . text !== undefined) {
        api . setTextContent(elm, "");
      }
    } else if (oldVnode . text !== vnode . text) {
      if (oldCh !== undefined) {
        removeVnodes(elm, oldCh, 0, oldCh . length - 1);
      }
      api . setTextContent(elm, vnode . text);
    }
    hook ?. postpatch ?. (oldVnode, vnode);
  }
⭐️ updateChildren:判断子节点的差异,这里也就是diff算法的核心,

diff算法是干什么的就显而易见了,就是为了对比虚拟节点中子节点的差异的,当然diff算法有好多版本,传统版本采用的是逐一比较,复杂度是O(n^3)显然复杂度较高,这里snabbdom采用的是同一层级进行比较(因为变化是局部的,大多数的变化都是在同一层的),这样大大节省了时间复杂度

源码:这里首先是分别定义了新旧节点的开始索引和结束索引

使用while循环,当新节点的开始索引和结束索引碰头了,或者旧节点的开始和结束索引碰头了,那我们就跳出循环,这里也就是说我们的diff算法进行完毕,已经比较出差异

单次循环:

  • 新、旧开始节点相同,patchVnode(发现差异并更新),两者的StartIdx++

  • 新、旧结束节点相同,patchVnode(发现差异并更新),两者的EndIdx--

  • 节点的开始节点、节点结束节点相同,patchVnode(发现差异并更新)newStartIdx++ oldEndIdx--修改结束节点的真实dom然后移动到最前面

  • 节点的结束节点、节点开始节点相同,patchVnode(发现差异并更新)newEndIdx-- oldStartIdx--修改开始节点的真实dom然后移动到最后面

  • 如果都不满足则直接在旧节点中遍历寻找相同的旧节点,然后patchVnode(发现差异并更新) newStartIdx++ 直接将新节点对应的真实dom插入到最前面

function updateChildren(
    parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue
  ) {
    let oldStartIdx = 0;
    let newStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];
    let oldKeyToIdx: KeyToIndexMap | undefined;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx];
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(
          parentElm,
          oldStartVnode.elm!,
          api.nextSibling(oldEndVnode.elm!)
        );
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        idxInOld = oldKeyToIdx[newStartVnode.key!];
        if (idxInOld === undefined) {
          // `newStartVnode` is new, create and insert it in beginning
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          );
          newStartVnode = newCh[++newStartIdx];
        } else if (oldKeyToIdx[newEndVnode.key!] === undefined) {
          // `newEndVnode` is new, create and insert it in the end
          api.insertBefore(
            parentElm,
            createElm(newEndVnode, insertedVnodeQueue),
            api.nextSibling(oldEndVnode.elm!)
          );
          newEndVnode = newCh[--newEndIdx];
        } else {
          // Neither of the new endpoints are new vnodes, so we make progress by
          // moving `newStartVnode` into position
          elmToMove = oldCh[idxInOld];
          if (elmToMove.sel !== newStartVnode.sel) {
            api.insertBefore(
              parentElm,
              createElm(newStartVnode, insertedVnodeQueue),
              oldStartVnode.elm!
            );
          } else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
          }
          newStartVnode = newCh[++newStartIdx];
        }
      }
    }

    if (newStartIdx <= newEndIdx) {
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        before,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );
    }
    if (oldStartIdx <= oldEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

Vue3中的diff算法

⭐️ 预处理(快捷路径)
  • 借鉴了文本预处理的方式

就像这样:

新、旧前置节点比较和新、旧后置节点进行比较,如果有一端的前后索引碰头的话就代表已经diff完成不用进行核心的diff处理,这样在只添加或者删除节点,并且顺序没有改变的情况下节省了性能

  • 具体操作方法:

前置节点: 我们可以建立索引 j,初始值为0。

后置节点: 因为可能两组节点长度不同,所以我们在尾端设置新旧两个指针

两个while循环: 让j递增,让newEnd和oldEnd递减,遇到不同的节点就停下,相同的节点就打补丁(调用patch进行更新)

跳出循环后判断:

  • j<=newEnd && j > oldEnd 说明有新节点,那么就在newEnd+1的索引位置添加新的节点

  • j>newEnd && j<=oldEnd1 说明有旧的节点被删除了,就卸载 j到oldEnd之间的节点。

⭐️ 判断是否要进行dom移动操作

预处理因为只是针对在节点顺序不改变的情况下,增添和删除节点的情况(比较理想化的情况).

构造一个source数组,长度为预处理之后剩余的未处理节点的数量,储存未处理节点在旧节点数组的索引,后面将会使用它计算出一个最长递增子序列,并用于辅助完成 DOM 移动的操作

填充source数组: 构造一个索引表遍历未处理新节点数组,key为当前节点的key,value是新节点的索引值,

遍历旧节点对应的数组,oldVnode.key->查询索引表->newVnode索引->source[newVnode索引]=oldVNode索引

定义k(最大的key值)和move(是否需要移动): 如果当前key小于k那说明需要移动,大于k则不需要移动

问:为什么要通过最大的key值来判断是否需要移动呢

因为正常情况下oldNode的key值是递增的趋势,所以newNode中小于k的就说明应该在前面某个位置,所以我们需要用一个当前循环中最大的key值来的判断是否需要移动这个节点

问:为什么要维护一张索引表,而不直接去遍历旧节点一个个的去寻找呢

因为那样两层嵌套循环的复杂度为On^2,这样做能够将时间复杂度降到On,

⭐️ 如何移动元素

根据source数组计算一个最大递增子序列, 返回的是source数组中最长序列元素的索引,

重新对未处理的子节点数组索引编排,第一个元素为索引为0

source和新节点数组末尾定义两个索引s和i,判断i==seq[s]

  • 不相等(i--,s不变)

    • 是-1,表示是新增节点,在i+newStart中挂载
    • 不是-1,通过insert移动
  • 相等(i--,s--)

Vue2和Vue3中diff算法的差异

  • vue2用的是双端diff算法,vue3使用的是快速diff算法

  • vue2中分5种情况(头对头,头对尾,尾对头,尾对尾) 进行判断然后移动、添加或者卸载节点,vue3经过预处理,生成 最长递增子序列判断是否需要移动、添加或者卸载, 然后对需要移动的节点进行移动

  • 在 Vue 2 中,Diff 算法的时间复杂度为 O(n) 。而在 Vue 3 中,Diff 算法的时间复杂度为 O(n log N)。

diff算法的复杂度,其本质是一个什么算法

Vue2其实就是找dom树树之间的差异,Vue3其实就是最长递增子序列

diff算法的理解

是干什么的:vue2中snabbdom中对比子节点差异使用的一种算法(updateChildren),snabbdom把时间复杂度降低到了On,传统的diff算法是逐个进行比较的,复杂度为On^3

是什么:比较两个虚拟dom对象树的差异,双端比较,使用同层比较的方法,时间复杂度为On

详解:5种情况(上面提到的)

Vue2和Vue3中diff算法的差异

vue3中使用的是最长递增子序列的方法,二分查找+贪心算法(为什么不用动态规划是因为最长的子序列可能有很多个,动态规划只是能求出长度但无法保存有哪些最长子序列是最长的,所以这里用了贪心+二分)

diff算法的复杂度,其本质是一个什么算法

diff 算法的时间复杂度为 O(n),diff 算法本质上是一个比较两个虚拟 DOM 树差异的算法