vue2 diff算法

196 阅读6分钟

vue2 diff算法

先到语

在上一篇中,我们学习了vue2的vdom,并用snabbdom对比了使用vdom渲染和不使用vdom渲染时的区别。那今天,我们就来学习vdom中的核心API-diff算法。

diff 概述

vdom-diff算法的特点:

  1. 只比较同一层级,不跨级比较
  2. tag不相同,则直接删掉重建
  3. tag和key相同则认为两者相同,不做深度比较

知道了diff算法的这些特点,我们接下来就通过snabbdom的源码来看看具体的diff算法的流程是怎样的。

snabbdom 源码解读

vue2 的vdom是参考snabbdom来实现的,所以这里我们就直接通过snabbdom来学习vue2的diff算法。
在上一篇文章中,我们使用snabbdom做了一个vdom的demo,通过这个demo,大家有没有很好奇snabbdom是如何生成一个vnode的?它又是如何去对比新旧vnode的?

snabbdom如何生成一个vnode?

在上一节的demo中,我们看到了,是通过snabbdom的h函数来生成的,所以接下来我们找到snabbdom中的h函数,在h.ts中:找到h函数:

export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData | null): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(
  sel: string,
  data: VNodeData | null,
  children: VNodeChildren
): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
  let data: VNodeData = {};
  let children: any;
  let text: any;
  let i: number;
  if (c !== undefined) {
    if (b !== null) {
      data = b;
    }
    if (is.array(c)) {
      children = c;
    } else if (is.primitive(c)) {
      text = c.toString();
    } else if (c && c.sel) {
      children = [c];
    }
  } else if (b !== undefined && b !== null) {
    if (is.array(b)) {
      children = b;
    } else if (is.primitive(b)) {
      text = b.toString();
    } else if (b && b.sel) {
      children = [b];
    } else {
      data = b;
    }
  }
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i]))
        children[i] = vnode(
          undefined,
          undefined,
          undefined,
          children[i],
          undefined
        );
    }
  }
  if (
    sel[0] === "s" &&
    sel[1] === "v" &&
    sel[2] === "g" &&
    (sel.length === 3 || sel[3] === "." || sel[3] === "#")
  ) {
    addNS(data, children, sel);
  }
  return vnode(sel, data, children, text, undefined);
}

可以看到h函数最后返回的是一个vnode函数:

export function vnode(
  sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | Text | undefined
): VNode {
  const key = data === undefined ? undefined : data.key;
  return { sel, data, children, text, elm, key };
}

vnode函数中返回的是一个对象,对象中有:sel(传入的dom对象),data(dom对象上的style,事件以及一些自定义属性等),children(dom 对象的子对象数组),text(如果dom对象不存在children,则渲染text的内容),elm(挂载的对象),key

image.png

在生成vnode之后,要通过patch函数来渲染,那patch函数又是那里来的呢?在上一节的demo中我们可以看到patch函数是通过snabbdom的init函数返回出来的,所以我们找到源码中的init函数: 在init.ts中,找到init方法,我们可以看到init方法返回了patch函数:

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

  //如果第一个元素不是vnode,就创建一个空的vnode,也就是:patch(container, vnode)这种调用方法
  if (!isVnode(oldVnode)) {
    oldVnode = emptyNodeAt(oldVnode);
  }

  //如果第一个元素是vnode,也就是:patch(vnode, newVnode)这种调用方法,并且这两次传入的vode的tag,key和sel相同,则执行patchVnode方法
  if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode, insertedVnodeQueue);
  } else {

    //如果oldVnode和vnode不相同,则把之前的vnode删除掉,直接插入新的vnode
    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;
};

patch 方法有两种调用方式分别是:patch(element,vnode)和patch(vnode,vnode),patch方法中的核心思想就是:先去判断是那种调用方式,如果patch(element,vnode)就先去创建一个空的vnode对象,然后比较两个vnode对象是否相等,如果相等,就去做patchVnode的操作,如果不相等,则直接删掉oldVnode,再根据vnode重建。
在patch方法中判断两个vnode是否相等的函数是:

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  const isSameKey = vnode1.key === vnode2.key;//key是否相同
  const isSameIs = vnode1.data?.is === vnode2.data?.is; //data是否相同
  const isSameSel = vnode1.sel === vnode2.sel; //挂载的元素是否像否

  return isSameSel && isSameKey && isSameIs; // 如果都相同,则返回true,否则返回false
}

如何对比新旧vnode

在上面的patch方法中,当我们确认两次的vnode相等之后,会去调用patchNode的方法。接下来我们来看看patchNode方法。

function patchVnode(
  oldVnode: VNode, //旧vnode
  vnode: VNode, //新vnode
  insertedVnodeQueue: VNodeQueue
) {
  //执行hook操作
  const hook = vnode.data?.hook;
  hook?.prepatch?.(oldVnode, vnode);

  const elm = (vnode.elm = oldVnode.elm)!; //把旧的vnode的elm赋值给新vnode的elm
  const oldCh = oldVnode.children as VNode[]; //旧vnode的children
  const ch = vnode.children as VNode[]; //新vnode的Children

  //如果新旧Vnode的children相同,则直接返回
  if (oldVnode === vnode) return;

  //hook操作
  if (vnode.data !== undefined) {
    for (let i = 0; i < cbs.update.length; ++i)
      cbs.update[i](oldVnode, vnode);
    vnode.data.hook?.update?.(oldVnode, vnode);
  }

  //isUndef判断是否为undefined
  //如果新vnode的text为undefined(新Vnode的children一般有值)
  if (isUndef(vnode.text)) {
    //如果新Vnode和旧Vnode的children都有值,则进行updateChildren操作
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
    } else if (isDef(ch)) {
      //如果只有新的children有值,则直接把把之前的vnode的text清空,然后添加上新Vnode的children
      if (isDef(oldVnode.text)) api.setTextContent(elm, "");
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      //如果只有旧的vnode children有值,则直接删掉旧的vnode的children,然后加上新Vnode的text的内容
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    } else if (isDef(oldVnode.text)) {
      //如果只有旧的vnode的text有值,则直接清空elm的text内容
      api.setTextContent(elm, "");
    }
  } else if (oldVnode.text !== vnode.text) {  //如果新旧Vnode的text不相等
    //如果只有oldchildren 不为空,则清空elm的children
    if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    }
    
    api.setTextContent(elm, vnode.text!);
  }
  hook?.postpatch?.(oldVnode, vnode);
}

patchVnode方法中的核心思想就是:对比新旧vnode的children,如果新旧vnode都存在children的话,则执行updateChildren方法。否则则执行相应的添加和清空操作!

对比新旧vnode里面的关键方法是:updateChildren

function updateChildren(
  parentElm: Node,
  oldCh: VNode[],
  newCh: VNode[],
  insertedVnodeQueue: VNodeQueue
) {
  //四个索引
  let oldStartIdx = 0;
  let newStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let newEndIdx = newCh.length - 1;

  //四个索引对应的节点
  let oldStartVnode = oldCh[0];
  let oldEndVnode = oldCh[oldEndIdx];
  let newStartVnode = newCh[0];
  let newEndVnode = newCh[newEndIdx];

  //
  let oldKeyToIdx: KeyToIndexMap | undefined;
  let idxInOld: number;
  let elmToMove: VNode;
  let before: any;

  //根据四个索引来做while 循环
  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 as string];
      
      //
      if (isUndef(idxInOld)) {
        // New element
        api.insertBefore(
          parentElm,
          createElm(newStartVnode, insertedVnodeQueue),
          oldStartVnode.elm!
        );
      } else {
        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];
    }
  }

这个updateChildren里面写的比较复杂,但是没关系,我们搞清楚这个方法的核心思路就可以了!

image.png 如上图所示: 首先先进行olCh和newCh的四个索引节点的对比,如果命中了,就执行相应的操作,并且改变索引。如果四个索引节点的对比均没有命中的话,则把newch中的节点依次与oldCh中的每个节点做对比,看看是否能和oldCh中其他位置的节点对应,如果能对应上,也执行相应的操作,如果一个都没有对应上,则将newCh中的该节点按照新节点来操作。

结束语

今天主要是学习snabbdom中diff算法的实现流程。但是我们需要注意一点,不同的框架中diff算法的实现方式可能会有不同,比如说vue3中的diff算法就不再是snabbdom中的这种。所以我们主要是了解下diff算法的流程。 那么,下次见,好好学习,天天向上!

image.png