vue2源码学习 (15) 虚拟DOM-5.patchVnode

48 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 19 天,点击查看活动详情

15. vue2源码学习 (15) 虚拟DOM-5.patchVnode

start

  • 上一节介绍的 patch 中有销毁旧节点,有直接创建元素。
  • 但是对相同的节点,会执行 patchVnode。整个 patchVnode 就是 diff算法的核心了。
  • 今天就来研究一下这 patchVnode

patchVnode

简化后的patchVnode

// \src\core\vdom\patch.js


// 两个节点 值得比较,则我们开始比较。
  function patchVnode(
    oldVnode, // 旧节点
    vnode, // 新节点
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    // 两个节点完全相同,直接 return
    if (oldVnode === vnode) {
      return;
    }
    
    // 更新节点的 elm 属性
    const elm = (vnode.elm = oldVnode.elm);
    
    // 1. 存储旧的子节点
    const oldCh = oldVnode.children;
    
    // 2. 存储新的子节点
    const ch = vnode.children;
 
    // 新节点存在文本
    if (isUndef(vnode.text)) {
      // 旧子节点存在  新子节点存在
      if (isDef(oldCh) && isDef(ch)) {
        // 3. 旧子节点 !== 新子节 开始更新子节点 ()
        if (oldCh !== ch)
          updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
      } else if (isDef(ch)) {
        // 4 .旧子节点不存在  新子节点存在 (前一步已经判断了两者是否同事存在)
        
        // 清空旧的节点的文本
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");

        // 添加新节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        // 5. 旧子节点存在,新子节点不存在,删除旧节点
        removeVnodes(oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {
        // 旧节点存在文本-清空 (走到这里说明:新旧子节点都不存在)
        nodeOps.setTextContent(elm, "");
      }
    } else if (oldVnode.text !== vnode.text) {
      // 设置文本内容为 新节点的文本;
      nodeOps.setTextContent(elm, vnode.text);
    }

  }

整理一下patchVnode的逻辑。能到 patchVnode ,说明 vnode 是相同的。

patchVnode主要逻辑是更新节点属性 、更新节点文本更新节点的子节点


子节点更新

首先分别获取到新旧子节点

  1. 旧子节点存在,新子节点存在,且两者不同,updateChildren
  1. 都存在新旧节点,就需要深入对比了。

  2. 如果两者相同,不需要深入对比,所以不做任何处理。

  1. 旧子节点不存在,新子节点存在, 创建新的节点。

这里会创建新节点。

也会

  1. 旧子节点存在,新子节点不存在,删除旧节点。
  2. 两个子节点都不存在,只需要处理节点的文本。

首先会走上述的四条分支,新旧子节点其中有一项不存在的情况,比较好处理,直接全量替换即可。但是针对新旧子节点都存在的情况,需要额外的比较updateChildren

updateChildren的主干逻辑

这里的逻辑直接看源码会有一点绕,也是我觉得很有意思的地方。

其实核心需求是这样的,我简化一下,做一下说明。

模拟目标

需求,有两个数组,两个数组里面存储多个对象,快速对比两个数组,相同swqswq

模拟数据

var newArr = [
  { key: 1, tag: 'div' },  // 新前
  { key: 2, tag: 'div' },
  { key: 3, tag: 'div' },
  { key: 4, tag: 'div' },  // 新后
]

var old = [
  { key: 3, tag: 'div' }, // 旧前
  { key: 2, tag: 'h1' },
  { key: 4, tag: 'div' },
  { key: 1, tag: 'div' }, // 旧后
]

为了高效的对比,使用了双指针的思路

新的子节点, 最前面的项简称:新前s最后面的项简称:新后e

旧的子节点, 最前面的项简称:旧前oldS最后面的项简称:旧后oldE

上述的四个名词,简单来说,就是四个变量,别存储着对应的索引,方便后续用来对比。 s是 start(开始)的简写;e是 end(结束)的简写;

具体的对比步骤

  1. 旧前 => 新前
  2. 旧后 => 新后
  3. 旧前 => 新后
  4. 旧后 => 新前
  5. 如果以上都匹配不到,再以新vnode(新前)为准,依次遍历老节点。
    • 找到相同的节点调用patchVnode
    • 没有相同的节点,直接创建新的元素;

为什么要声明四个变量,弄这么麻烦,直接拿到新数组的第一项,和旧数组的每一项去对比不好吗?

因为在使用Vue的场景中:在开头或结尾插入内容;单纯的修改某一项;这些场景可能出现的频率很高。加入了1-4的步骤可以避免重复遍历,对性能提升很大。

简易版实现

var newArr = [
  { key: 1, tag: 'div' },
  { key: 2, tag: 'div' },
  { key: 3, tag: 'div' },
  { key: 4, tag: 'div' },
]

var oldArr = [
  { key: 3, tag: 'div' },
  { key: 2, tag: 'h1' },
  { key: 4, tag: 'div' },
  { key: 1, tag: 'div' },
]

/* 1. 存储索引 */
// 新前的索引
var s = 0
// 新后的索引
var e = newArr.length - 1
// 旧前的索引
var oldS = 0
// 旧后的索引
var oldE = oldArr.length - 1

/* 2.存储对应的对象 */
// 新前对应的对象
var sNode = newArr[0]
// 新后对应的对象
var eNode = newArr[e]
// 旧前对应的对象
var oldSNode = oldArr[0]
// 旧后对应的对象
var oldENode = oldArr[oldE]

/* 2. 简易版的节点对比 */
function sameVnode(a, b) {
  return a.key === b.key && a.tag === b.tag && a.isComment === b.isComment
}

/* 3.多次对比,肯定是需要循环的,但是循环如何设计? */

/*  4. 使用 while条件语句, 只要:旧前小于等于旧后,新前小于等于新后。  (从两端向中间遍历) */
while (oldS <= oldS && s <= e) {
  /* 
  1.  旧前 `=>` 新前
  2.  旧后 `=>` 新后
  3.  旧前 `=>` 新后
  4.  旧后 `=>` 新前
  */
  // 简易版本的 sameVnode

  //  4.1  旧前 `=>` 新前
  if (sameVnode(oldSNode, sNode)) {
    // patchVnode
    // ++oldS, ++s  (为什么要加加?因为两端的索引需要向中间移动,当两端的索引重合,结束while遍历)
    // 更新 oldSNode, sNode
  } else if (sameVnode(oldENode, eNode)) {
    //  4.2  旧后 `=>` 新后
    // patchVnode
    // --oldE, --e
    // 更新 oldENode, eNode
  } else if (sameVnode(oldSNode, eNode)) {
    //  4.3  旧前 `=>` 新后
    // patchVnode
    // --oldS, --e
    // insertBefore
    // 更新 oldSNode, eNode
  } else if (sameVnode(oldENode, sNode)) {
    //  4.4  旧后 `=>` 新前
    // patchVnode
    // --oldE, ++s
    // insertBefore
    // 更新 oldENode, sNode
  } else {
    // 4.5 新节点和所有的子节点进行对比
  }
}

updateChildren源码

 // diff算法的核心逻辑
  function updateChildren(
    parentElm,
    oldCh,
    newCh,
    insertedVnodeQueue,
    removeOnly
  ) {
    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, idxInOld, vnodeToMove, refElm;

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions

    // removeOnly是一个特殊的标志,只能被 <transition-group>;
    // 确保被删除的元素保持在正确的相对位置
    // 在离开过渡时

    const canMove = !removeOnly;

    if (process.env.NODE_ENV !== "production") {
      checkDuplicateKeys(newCh);
    }

    // 循环数组,这里规定退出条件
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 旧前 为空,修改索引
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        // 旧后 为空
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 节点相同

        //1.  旧前 新前
        patchVnode(
          oldStartVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        );
        // ++a 表示先增加a,再使用a; a++标识先使用a,在增加a
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];

        //2.  旧后 新后
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(
          oldEndVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        );
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];

        //3.  旧前 新后
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // Vnode moved right
        patchVnode(
          oldStartVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        );
        canMove &&
          nodeOps.insertBefore(
            parentElm,
            oldStartVnode.elm,
            nodeOps.nextSibling(oldEndVnode.elm)
          );
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];

        //4.  旧后 新前
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        // Vnode moved left
        patchVnode(
          oldEndVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        );
        canMove &&
          nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 5.其他情况

        // ?
        if (isUndef(oldKeyToIdx))
          // 旧前 旧后 组成的  "节点的key":"节点的索引"
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);

        // 新节点是否有key,
        // 有key就去oldKeyToIdx中寻找,
        // 没有key从旧前到旧后,开始遍历,依次拿 每一项旧节点和新节点对比,对比成功直接返回对应的索引
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);

        if (isUndef(idxInOld)) {
          // 5.1 没有索引,说明是全新的节点,直接创建新的元素
          // New element
          createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
          );
        } else {
          // 5.2 有key,且相同元素
          vnodeToMove = oldCh[idxInOld];
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(
              vnodeToMove,
              newStartVnode,
              insertedVnodeQueue,
              newCh,
              newStartIdx
            );
            oldCh[idxInOld] = undefined;
            canMove &&
              nodeOps.insertBefore(
                parentElm,
                vnodeToMove.elm,
                oldStartVnode.elm
              );
          } else {
            // 有key,不相同元素,视为新元素。
            // same key but different element. treat as new element
            createElm(
              newStartVnode,
              insertedVnodeQueue,
              parentElm,
              oldStartVnode.elm,
              false,
              newCh,
              newStartIdx
            );
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }

    // 旧的先diff完, 剩下的就是:新项,添加到真实 dom 中
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        refElm,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );

      // 新的先diff完毕,旧的全部删除
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx);
    }
  }

可以看到updateChildren源码,主干逻辑和我们 简易版实现很相同。

注意,他这里会递归的调用 patchVnode

其他注意事项

  1. patchVnode主要逻辑是更新节点属性 、更新节点文本更新节点的子节点

  2. 遍历的思路,双指针的方式。两端的指针向中间移动。

  3. 对比的顺序 旧前+新前,旧后+新后,旧前+新后,旧后+新前

  4. 我们在使用 v-for的时候,为什么一直强调需要加key?

    • 在对比的时候,相同key,可以快速对比。
  5. 为什么不建议使用v-for的索引(index)当做key呢?

    • 因为当在开头差入数据的时候,key会因为index的变化而变化,所有的节点都会重新渲染,达不到复用的目的。

end

到这里 虚拟DOM相关的主线逻辑都梳理完毕了