详解diff算法

992 阅读4分钟

详解diff算法

1. diff算法简介

​ 当我们修改页面的时候,如果要整个重新渲染DOM,那开销是非常大的,因此在Vue的官方文档中也指出了,它会采用一种虚拟DOM的算法,利用key去最大限度的减少动态元素。

image-20210508115605846.png

2. 虚拟DOM

​ 所谓的虚拟DOM就是用一种数据结构,保存DOM元素的信息。它能够告诉Vue你要渲染出来的元素是什么样的。

​ 使用模板编译,一个真实的DOM可以变为一个虚拟的DOM。

​ 真实的DOM:

<div>
	<p>我是p元素</p>
    <div>
        我是div元素
    </div>
</div>

​ 转化为虚拟的DOM:

const Vnode = {
    sel: 'div', //选择器
    data: {}, // 储存节点属性,比如key,prop,class等。
    children: [
     	{
            sel: 'p',
            text: '我是p元素',
            data: {}
        },
        {
            sel: 'div',
            text: '我是div元素',
            data: {}
        }
    ]
}

我们可以通过模板引擎将真实的DOM转化为虚拟DOM,同时也可以用createElement方法去根据虚拟DOM创建一个真实的DOM节点。之后会介绍createElm这个函数。

3. diff的比较方式

在采取diff算法时,只会针对同层的元素去精细化比较。

998023-20180519212338609-1617459354.png

原先是这样的结构

<div>
    <div>
        我是div元素
    </div>
    <p>
        我是p元素
    </p>
</div>

之后修改为

<div>
    <div>
        我不是div元素
    </div>
    <span>
        我不是p元素
    </span>
</div>

原先的p元素只会与之后的span元素进行比较,而不会和div进行比较,这是diff算法的一个特点。

4. diff流程图

vue 虚拟节点diff过程.png

5. diff源码(核心代码)

1. 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]();

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(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;
  };

​ patch函数就是diff算法的核心,patch接受两个参数,第一个参数是老节点,第二个参数是新节点。这个老节点可以是DOM元素或者虚拟节点,因为第一步就是判断老节点是否是虚拟节点,如果不是虚拟节点,那就会将其转化成虚拟节点。

第二步就是sameVnode函数进行判断,新老节点的sel,data.is, key是否一致,如果一致那就是相同节点,否则就是不同节点。

​ 如果是相同节点就会进入patchVnode函数进行比较,这就是精细化比较。

​ 如果不是相同节点,就会暴力创建节点,我们可以忽视关于insertedVnodeQueue以及hook,都是有在diff算法中保存节点状态的作用,并不是核心算法。

第三步使用createElm创建节点,同时在父节点不是null的时候,也就是老节点不为空,删除老节点。插入新节点。 插入用的其实就是document.insertBefore函数。

​ 简单的看一下createElm函数:

function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any;
    let data = vnode.data;
    if (data !== undefined) {
      const init = data.hook?.init;
      if (isDef(init)) {
        init(vnode);
        data = vnode.data;
      }
    }
    const children = vnode.children;
    const sel = vnode.sel;
    if (sel === "!") {
      if (isUndef(vnode.text)) {
        vnode.text = "";
      }
      vnode.elm = api.createComment(vnode.text!);
    } else if (sel !== undefined) {
      // Parse selector
      const hashIdx = sel.indexOf("#");
      const dotIdx = sel.indexOf(".", hashIdx);
      const hash = hashIdx > 0 ? hashIdx : sel.length;
      const dot = dotIdx > 0 ? dotIdx : sel.length;
      const tag =
        hashIdx !== -1 || dotIdx !== -1
          ? sel.slice(0, Math.min(hash, dot))
          : sel;
      const elm = (vnode.elm =
        isDef(data) && isDef((i = data.ns))
          ? api.createElementNS(i, tag, data)
          : api.createElement(tag, data));
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text));
      }
      const hook = vnode.data!.hook;
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode);
        if (hook.insert) {
          insertedVnodeQueue.push(vnode);
        }
      }
    } else {
      vnode.elm = api.createTextNode(vnode.text!);
    }
    return vnode.elm;
  }

代码量看上去很多,但是核心代码就只有一步,api.createElement(tag, data));这里就是调用document.createElement()去创建元素。

判断选择器是否为!,如果是!那就是注释,就创建一个注释元素。

判断选择器是否为空,如果是空就进入else,直接创建一个文本元素。

最复杂的情况就是当选择器不为空,就是正常的节点元素,那就先解析data里的数据,然后创建elm,通过上文所说的document.createElement()。

当有children的时候,就把children里的每一项都当做一个新的Node,去递归的执行createElm函数就可以了,而children里的DOM元素被创建好了就要放在最初的elm元素之后,也就是上树。

2. patchVnode函数
function patchVnode(
    oldVnode: VNode,
    vnode: VNode,
    insertedVnodeQueue: VNodeQueue
  ) {
    const hook = vnode.data?.hook;
    hook?.prepatch?.(oldVnode, vnode);
    const elm = (vnode.elm = oldVnode.elm)!;
    const oldCh = oldVnode.children as VNode[];
    const ch = vnode.children as VNode[];
    if (oldVnode === vnode) return;
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i)
        cbs.update[i](oldVnode, vnode);
      vnode.data.hook?.update?.(oldVnode, vnode);
    }
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) api.setTextContent(elm, "");
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, "");
      }
    } else if (oldVnode.text !== vnode.text) {
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      api.setTextContent(elm, vnode.text!);
    }
    hook?.postpatch?.(oldVnode, vnode);
  }

在patchVnode当中,分为以下几步。

第一步判断

​ 判断新老节点是否为一个对象,是的话就不用做任何操作。

​ 如果新节点的text不是空的,且新老节点的text不一致,那就会移除老节点,同时将新节点的text插入进去。

​ 如果新老节点的text一致,那同样不用做任何操作。

​ 最复杂的就是当新节点的text不为空,这就意味着新节点有children,可能是一个复杂结构。

进行第二步判断

​ 如果新老节点都有children,那么就要执行整个diff算法中最复杂的updatechildren函数。

​ 否则就根据不同情况将老节点的text修改为新节点的text。

3. updateChildren函数
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 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];
      }
    }
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
      if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
        addVnodes(
          parentElm,
          before,
          newCh,
          newStartIdx,
          newEndIdx,
          insertedVnodeQueue
        );
      } else {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
      }
    }
  }

​ updateChildren函数可以说是非常复杂,它用了4个指针,分别是新老节点的首末位元素,对应的用了4个虚拟节点,也就是4个指针指向的节点。

下文我用新前,新后,旧前,旧后来指代这4个新旧节点的首末指针。

​ 在循环当中,用了4种比较方式:

​ 1、新前与旧前比较,两个指针所指节点相同则两个指针+1,继续遍历。

​ 2、新后与旧后比较,两个指针所指节点相同则两个指针-1,继续遍历。

​ 3、新后与旧前比较,两个指针所指节点相同新后指针-1,旧前指针+1,旧前的节点插入到旧后之后的下一个节点,继续遍历。

​ 4、新前与旧后比较,两个指针所指节点相同新前指针+1,旧后指针-1,旧后的节点插入到旧前之前的前一个节点,继续遍历。

​ 而如果都没有命中,就将所有旧节点全部用哈希表存储下来。

​ 此时处理的是新前节点,从储存的哈希表中寻找,新前节点是否在老的节点当中。

如果在:那么就要移动这个旧节点,把这个节点移动到旧前节点之前。

如果不在:那么只需要先将新前节点插入到目前处理的旧前节点之前即可。

​ 由于处理的是新前节点,因此无论如何,这个节点都处理完毕了,那么就要后移新前指针。

而在任何处理节点的过程当中,你都需要递归的去执行patchVNode去打补丁。因为children里的元素,也有可能还有children。算法只是把其当做了一个整体去插入移动。

最后,在循环结束后,如果新前与新后节点之中还有元素,那就要一个个的插入到新后+1的位置之前。

而如果在旧前与旧后之间还有元素,那就直接将他们删除即可。

其实总结下来就是: 所有移动插入的过程,都是以新节点为基准,让老节点以最小的代价变为新的节点,因此在移动的过程当中,如果觉得很难记住,哪个节点要移动到哪个节点,只需要比对新节点的相对位置,然后将旧节点对应的节点移动到新节点的相对位置上即可。