虚拟dom与diff算法

451 阅读6分钟

全篇是基于snabbdom进行的相关介绍。大家可以参考开源地址:github.com/snabbdom/sn…

本篇也是笔者重学虚拟dom和diff算法的过程,感觉有了一些不一样的认识,虽然还是之前学过的内容,但是感觉比第一次要领会得更加深刻,笔者是一个喜欢通过博客来学习的一些知识的人,但是有时候我会发现有的知识并不是那么好理解,所以就会采用视频的方式去学习,本篇的内容就是看了视频学习的,再此非常感谢尚硅谷提供的关于虚拟DOM与diff算法的视频。由于笔者的经验不是特别的多,所以关于本篇的内容,可能存在很多缺陷,甚至错误,希望各位小伙伴在阅读的过程中要多加甄别,也非常欢迎小伙伴们与我沟通相关问题。

1. 虚拟DOM

虚拟DOM是一个javascript对象,是对原始的DOM进行的抽象;由于是一个javascript对象,所以可以运行在服务端和浏览器端,实现跨端功能,同时结合diff算法,实现了DOM的局部更新,避免了大量的回流重绘,在一定程度上提高了性能。

创建虚拟节点(vnode)

一个vnode对象包含的内容有:

// 这里面定义一个vnode函数,虚拟节点
/**
 * @param {string} sel 元素标签名
 * @param {object} data 标签属性
 * @param {array|string|object} children 子节点
 * @param {object} elm 真实的DOM对象
 * @param {string} text 字符串节点
 * @return {object}
 *
 */
export default function vnode(sel, data, children, elm, text) {
  const key = data === undefined ? undefined : data.key;
  return { sel, data, children, elm, text, key };
}

使用h函数创建虚拟DOM:

const vnode = h('ul', {}, [
  h('li', {}, '测试1'),
  h('li', {}, '测试2'),
  h('li', {}, '测试3')
]);
// h函数的内部回去调用vnode函数,生成对应的vnode

2. diff算法

diff 算法 本质上是一个 对比的方法。其核心就是在: “旧 DOM 组”更新为“新 DOM 组”时,如何更新才能效率更高。因为diff算法主要实在patch函数中执行的,所以下面介绍它的基本流程

2.1 patch函数执行过程

2.2 精细化比较

image.png

2.3 diff核心

在新旧节点中,都有children元素时,就要开始比较子元素节点是否相同,如果相同就继续进行patchVnode操作;

在进行比较时,分别采用头尾指针(在新旧节点中都有头尾指针)的方式进行比较,那么根据这四个不同的指针,就出现了下面这四种不同的比较(如下图);当然也存在四个比较都没有命中时,需要进行额外处理的情况;那么在进行遍历比较时,这里使用循环遍历,遍历的条件就是:

oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx

下面依次列举一些情况来进一步理解diff算法

  • 新增情况

image.png

  • 删除

image.png

  • 移动

image.png

  • 复杂比较

当之前的四种比较都无法满足条件时,就进行复杂比较

image.png

3. diff算法代码实现

通过render函数创建出虚拟DOM之后,就要开始进行新旧虚拟DOM的比较(patch);

众所周知,这个比较过程是在patch函数中实现的,内容也是进行了很多处理,并最终生成一个更新后的真实DOM;

涉及到的主要方法有:

  • sameVnode: 判断是否为相同的虚拟节点,这是同层级的比较
  • createElm: 根据虚拟DOM创建真实的DOM元素
  • patchVnode:在同层级节点相同的前提下,进行下一步比较
  • updateChildren:在新旧节点都有子元素时,进行的比较更新,这也是diff算法的核心过程

sameVnode

主要比较四个地方:

  1. 元素标签
  2. 元素的key属性
  3. 如果是自定义元素,那么就有一个is属性,使用这个属性进行比较
  4. 如果前面都能满足,那么就可以比较一下text类型
    function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
      const isSameKey = vnode1.key === vnode2.key; // key值
      //  https://developer.mozilla.org/zh-CN/docs/Web/API/Web_components
      const isSameIs = vnode1.data?.is === vnode2.data?.is; // for custom elements v1, 在vue中,那个component动态组件,就有一个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;
    }

createElm

根据虚拟DOM创建真实的DOM元素,一般要创建真实DOM的直接原因就是新旧虚拟节点不一致;

在这个过程中存在递归创建的过程(如果节点有子元素)

下述内容省略了部分代码

    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 */  
        
        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));
        }
       /** some handle */
      } else if (vnode.children) {
        vnode.elm = (
          api.createDocumentFragment ?? documentFragmentIsNotSupported
        )();
        for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
        // 循环递归创建元素
        for (i = 0; i < vnode.children.length; ++i) {
          const ch = vnode.children[i];
          if (ch != null) {
            api.appendChild(
              vnode.elm,
              createElm(ch as VNode, insertedVnodeQueue)
            );
          }
        }
      } else {
        vnode.elm = api.createTextNode(vnode.text!);
      }
      return vnode.elm;
    }

patchVnode

在同层比较时,如果节点相同,那么就进行更加精细的比较;

在snabbdom源码中,还是进行了很完善的处理

    function patchVnode(
      oldVnode: VNode,
      vnode: VNode,
      insertedVnodeQueue: VNodeQueue
    ) {
      const hook = vnode.data?.hook;
      hook?.prepatch?.(oldVnode, vnode);
      const elm = (vnode.elm = oldVnode.elm)!; // 变量后面跟!表示变量不会是undefined 或 null

      // 如果新旧节点相同(对象地址相同),无须比较
      if (oldVnode === vnode) return;
      if (
        vnode.data !== undefined ||
        (isDef(vnode.text) && 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 (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, ""); // 如果旧节点中有text, 则置空
          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); // 新旧节点text不相同,直接删除旧节点中的所有
        }
        api.setTextContent(elm, vnode.text!);// 重置旧节点中的text
      }
      hook?.postpatch?.(oldVnode, vnode);
    }

updateChildren

diff算法核心

    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; // 键值和索引的映射; key --> index
      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 {
          // 创键key --> index 的映射关系
          if (oldKeyToIdx === undefined) {
            oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
          }
          idxInOld = oldKeyToIdx[newStartVnode.key as string]; // 在old节点中查询新节点的key 对应old节点的索引
          if (isUndef(idxInOld)) {
            // 没有找到对应的节点,在oldStartVnode前面补一个节点
            // New element
            api.insertBefore(
              parentElm,
              createElm(newStartVnode, insertedVnodeQueue),
              oldStartVnode.elm!
            );
          } else {
            // 此时是两个首部指针位置,对应的内容不一样,然后开始在旧节点中遍历查询和当前newStartVnode一样的key的节点;
            // 如果查询到就直接移动节点到oldStartVnode前面
            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; // 置位undefined
              // 直接移动到指定的位置
              api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
            }
          }
          newStartVnode = newCh[++newStartIdx]; // 向前移动新节点的指针
        }
      }

      if (newStartIdx <= newEndIdx) {
        //说明新节点中有新增元素
        // 注意这里写的是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);
      }
    }

patch

同层比较,内部递归

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);  
    }  
  
    // 判断是否为相同vnode  
    if (sameVnode(oldVnode, vnode)) {  
  
        patchVnode(oldVnode, vnode, insertedVnodeQueue);  
    } else {  
        elm = oldVnode.elm!;  
        parent = api.parentNode(elm) as Node;  
        // 元素不同时,直接创建新DOM
        createElm(vnode, insertedVnodeQueue);  
  
        if (parent !== null) {  
            // 插入页面DOM结构中
            api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));  
            // 删除旧DOM
            removeVnodes(parent, [oldVnode], 0, 0);  
        }  
    }  
     /** some handle */
    return vnode;  
};

到底啦~👏 非常感谢您能看到这里,您的支持就是我最大的动力。🌈