Vue2中DOM-Diff的菜鸟理解

568 阅读8分钟

背景

一直对Vue的核心DOM-Diff算法畏惧,觉得特别特别难,什么递归+双指针,四个命中,深度优先,时间复杂度O(n^3)->O(n)(O(n^2))等等,自己肯定不能掌握分毫,怎么会有zei(读第四声)种前端 但无意间看到了B站美女大佬的bubucuo的视频后,觉得不能怂,学就完事了。

先是看文章,反复对比源码官网鲨鱼哥windlany李永宁教程林三心

对比着看讲解视频、李永宁视频, 至于虚拟DOM什么的,各位大佬教程都有,文章也多,就不再赘述了。

DOM-Diff过程大致可分为3部分:

1. 初 patch 对比新旧节点存在情况

2. 次 patchVnode 深层次对比相同类型新旧节点( sameVnode 方法判断key/tag等等)

3. 终 updateChildren 对比更新新旧子节点( diff )

newVNode:新节点;oldVNode:旧节点;

初 patch 对比新旧节点存在情况

patch总结下来就3个情况,一切以newVNode为准,改造oldVNode:

  1. 新增Dom节点:newVNode存在,oldVNode不存在,在DOM中新增newVNode节点;

  2. 删除Dom节点:newVNode不存在,oldVNode存在,在DOM中删除oldVNode节点;

  3. 更新Dom节点:newVNode和oldVNode都存在,两节点不同则newVNode替换oldVNode;节点相同则patchVnode新旧节点;

patch

/**
 * @description 对比两节点
 * @param oldVNode:旧节点
 * @param newVNode:新节点
 * @param parentElm:父元素
 */
 //addVnodes、removeVnodes就不细说了
function path(oldVNode, newVNode, parentElm) {
  if (!oldVNode) {
    //旧的不存在,新的存在,按照新的给旧的加
    addVnodes(parentElm, null, newVNode, 0, newVNode.length - 1);//批量创建节点
  } else if (!newVNode) {
    //旧的不存在,新的不存在,按照新的给旧的删除
    removeVnodes(parentElm, oldVNode, 0, oldVNode.length - 1);//批量删除节点
  } else {
    //新旧都存在
    //判断是否为相同类型节点
    if (sameVnode(oldVNode, newVNode)) {
    //相同则进行深层次比较!!!
      pathcVnode(oldVNode, newVNode);
    } else {
      //不相同,在父节点下,删除老节点,增加新节点
      removeVnodes(parentElm, oldVNode, 0, oldVNode.length - 1);
      addVnodes(parentElm, null, newVNode, 0, newVNode.length - 1);
    }
  }
  return newVNode.elm;
}

sameVnode

/**
 * @description 判断节点是否相同
 * @param a:旧节点
 * @param b:新节点
 */
function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}

次 patchVnode 深层次对比相同类型新旧节点

总结下分几种情况作比较:

  1. 新旧节点是否完全一样(包含是静态节点),是则直接return;
  2. 新节点为文本节点,看旧节点是否为文本节点(2.1 和 2.2、3.1、3.2.2操作的结果一样,调用方法不一样而已):
    • 2.1 是:修改旧节点文本为新节点文本(旧的更新成新的);

    • 2.2 否:把旧节点更新成新节点一样(旧的更新成新的);

  3. 新节点为元素节点,看它有没有子节点:
    • 3.1 无:新节点既不是文本节点又无子节点,那么为新节点空节点,不管旧节点里面有啥,直接清空旧节点(旧的更新成新的);

    • 3.2 有:看旧节点是否有子节点:

      • 3.2.1 旧的没有子节点:把新子节点创建一份插入到旧节点中(旧的更新成新的);

      • 3.2.2 旧的有子节点:调用 updateChildren(oldCh, newCh),递归对比更新子节点; 上面是底下源码的判断情况总结,和源码有些许 判断顺序 的区别,但判断的情况操作一致,先理解文字,建议跟着上面拿笔写一遍伪代码,再照的代码看好理解点,其实不复杂,有几种情况操作都是一样的。本人是这么做的,字丑就不放上来了

patchVnode

/**
 * @description 深层次对比新旧节点
 * @param oldVNode:旧节点
 * @param newVNode:新节点
 **/
function pathcVnode(oldVNode, newVNode) {
  //新旧vnode是否完全一样,若是,退出程序
  if (oldVNode === newVNode) {
    return;
  }
  //将老 vnode 上的真实节点同步到新的 vnode 上,否则,后续更新的时候会出现 vnode.elm 为空的现象
  const elm = (newVNode.elm = oldVNode.elm);

  //newVNode 与 oldVNode 是否都是静态节点?如是,退出程序
  if (
    isTrue(newVNode.isStatic) &&
    isTrue(oldVNode.isStatic) &&
    newVNode.key === oldVNode.key &&
    (isTrue(newVNode.isCloned) || isTrue(newVNode.isOnce))
  ) {
    return;
  }

  const oldCh = oldVNode.children; //旧子节点
  const newCh = newVNode.children; //新子节点

  // newVNode 有 text 属性?若没有:
  if (isUndef(newVNode.text)) {
  
    //newVNode 为元素节点
    //newVNode 的子节点与 oldVNode 的子节点是否都存在?
    if (isDef(oldCh) && isDef(newCh)) {
    
      //如都存在,判断子节点是否相同,不同则更新子节点!!!
      if (oldCh !== newCh) updateChildren(elm, oldCh, newCh);
      
      //是否只有 newVNode 子节点存在
    } else if (isDef(newCh)) {
      /* 
        判断 oldVNode 是否有文本?
        无:则把 newVNode 的子节点添加到真实 DOM 中
        有: 则清空 DOM 中的文本,再把 newVNode 的子节点添加到真实 DOM 中
      */
      if (isDef(oldVNode.text)) nodeOps.setTextContent(elm, "");//清空旧节点
      // vnode 的子节点添加到真实 DOM 中
      addVnodes(elm, null, newCh, 0, newCh.length - 1, insertedVnodeQueue);
      
      //若只有 oldVNode 的子节点存在
    } else if (isDef(oldCh)) {
    
      //清空DOM中的子节点
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      
      //若 newVNode 和 oldVNode 都没有子节点,但是 oldVNode 中有文本
    } else if (isDef(oldVnode.text)) {
    
      //清空 oldVnode 文本
      nodeOps.setTextContent(elm, "");
      
      //!! 上面两个判断一句话概括就是,如果 newVNode 中既没有text,也没有子节点,则为空节点,那么对应的 oldVnode 中有什么就清空什么
    }

    //若 newVNode 有文本,newVNode 的text属性与 oldVNode 的text属性是否相同?
  } else if (oldVNode.text !== newVNode.text) {
  
    //若不相同:则用 newVNode 的text替换真是DOM的文本
    nodeOps.setTextContent(elm, newVNode.text);
  }
}

终 updateChildren 对比更新新旧子节点( Diff )

新旧子节点 Diff 分为两大种,5小种情况(1.1-1.2不考虑进去):

  1. 用新旧子节点(前后)双指针去'非常规'循环,预测可能出现的情况,主要看(1.3-1.6的四种):
    • 1.1 旧前子节点不存在,新前指针++,右移;

    • 1.2 旧后子节点不存在,旧后指针--,左移;

    • 1.3 新前对比旧前,如果一样,patchVnode两节点 新旧前指针++,右移;

    • 1.4 新后对比旧后,如果一样,patchVnode两节点 新旧后指针--,左移;

    • 1.5 新后对比旧前,如果一样,patchVnode两节点 将旧前节点移到旧后节点后面(未处理节点之后,若上一次也是这种情况命中,则上一次算已处理节点),新后指针--,左移,旧前指针++,右移;

    • 1.6 新前对比旧后,如果一样,patchVnode两节点 将旧后节点移到旧前节点前面(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),新前指针++,右移,旧后指针--,左移;

  2. 以上都不满足,则只能常规双层循环,旧子节点的key-index构成一个Map,拿新子节点挨个去旧子节点中查找是否有相同key的节点:
    • 2.1 旧子中没有找到相同的key,则将当前新前子节点创建一份插入到旧前子节点之前(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),新前指针++,右移;

    • 2.2 旧子中找到了相同的key,看两个节点除了key一样,是否类型也一样(sameVnode):

      • 2.2.1 不一样:将当前新前子节点创建一份插入到旧前子节点之前(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),新前指针++,右移;

      • 2.2.2 一样:patchVnode 两节点,将旧节点中找到的这个节点移动到旧前子节点之前(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),将旧子节点原位置的节点置为undefined,防止改变原旧子节点数组,破坏初始的映射表位置,新前指针++,右移;

结束循环的标识

  1. 新前指针 > 新后指针:删除旧前指针——旧后指针之间的节点;
  2. 旧前之前 > 旧后指针:新增新前指针——新后指针之间的节点,插入到旧前节点之前;
/**
 * @description diff算法核心 采用双指针 + 循环的方式 对比新旧vnode的子节点
 * @param parent:父元素节点
 * @param oldCh:旧子节点
 * @param newCh:新子节点
 */
function updateChildren(parent, oldCh, newCh) {
  let oldStartIdx = 0; //旧前下标
  let oldStartVnode = oldCh[0]; //旧前节点
  let oldEndIdx = oldCh.length - 1; //旧后下标
  let oldEndVnode = oldCh[oldEndIdx]; //旧后子节点

  let newStartIdx = 0; // 新前下标
  let newStartVnode = newCh[0]; //新前节点
  let newEndIdx = newCh.length - 1; // 新后下标
  let newEndVnode = newCh[newEndIdx]; // 新后节点

  // 创建oldCh的key-index映射表  类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
  function makeIndexByKey(children) {
    let map = {};
    children.forEach((item, index) => {
      map[item.key] = index;
    });
    return map;
  }
  // 只有当新旧子的双指针的起始位置不大于结束位置的时候  才能循环 一方停止了就需要结束循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 因为暴力对比过程把移动的旧子节点置为 undefined 如果不存在 vnode 节点 直接跳过
    if (!oldStartVnode) {
      oldStartVnode = oldCh[++oldStartIdx];
      
    } else if (!oldEndVnode) {
      oldEndVnode = oldCh[--oldEndIdx];
      
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 新前和旧前对比 依次向后移动指针
      patchVnode(oldStartVnode, newStartVnode); //递归比较子节点及孙辈节点
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
      
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      //新后和旧后对比 依次向前移动指针
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
      
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      // 旧前和新后相同 把旧前节点移动到旧后之后,未处理节点之后
      patchVnode(oldStartVnode, newEndVnode);
      //insertBefore可以移动或者插入真实dom
      parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); 
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
      
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // 旧后和新前相同 把旧后移动到新前之前,未处理节点之前
      patchVnode(oldEndVnode, newStartVnode);
      parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
      
    } else {
      // 上述四种情况都不满足 那么需要暴力对比,双层循环
      // 根据旧的子节点的key和index的映射表 从新的开始子节点进行查找
      // 如果可以找到就进行移动操作 如果找不到则直接进行插入
      
      let map = makeIndexByKey(oldCh); // 生成的映射表
      let moveIndex = map[newStartVnode.key];//在旧子节点中找到key与 newStartVnode 相同的节点
      
      if (!moveIndex) {
        // 旧的中找不到  直接 newStartVnode 插入到旧前之前
        parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
        
      } else {
        let moveVnode = oldCh[moveIndex]; //找得到就拿到老的节点
        
        //找到的节点和新前节点相同
        if (sameVnode(moveVnode, newStartVnode)) {
          patchVnode(moveVnode, newStartVnode);
          oldCh[moveIndex] = undefined; //这个是占位操作 避免数组塌陷  防止旧节点移动走了之后破坏了初始的映射表位置
          parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到旧前之前

          //找到的节点和新前节点不同
        } else {
          parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
        }
      }
      newStartVnode = newCh[++newStartIndex];//新前指针++
    }
  }

  if (oldStartIdx > oldEndIdx) {//旧的循环完毕,将新的中剩余的节点插入到旧前之前
    before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
    addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)

  } else if (newStartIdx > newEndIdx) {//新的循环完毕,旧中删除剩余的节点
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

上面的 diff 过程可以举例两个数组,按照逻辑走一下,印象深刻好理解点;注意未处理之前的节点、未处理之后的节点;

不对的地方,望掘金大佬们评论指出来,一起探讨,小弟感激不尽;