Vue3源码学习--渲染器(简单diff,双端diff,快速diff)

182 阅读10分钟

diff算法用于实现dom的最小化更新,提升虚拟dom转换为真实dom的性能。目前主要有简单diff,双端diff,快速diff算法

通过一个例子阐述不同diff的实现

20230819105055243.png

  • 简单diff

简单diff的思路就是通过遍历新节点,在旧节点中找到新节点所在的索引,将该索引记录为一个值(k),继续遍历旧节点,如果该旧节点在新节点的位置大于刚才的索引(k),则将k更新为当前索引。如果小于k,则当前节点需要移动。

/*
  简单diff算法的输入参数,c1旧children, c2新children, container元素的父节点(真实dom),anchor锚点,patch渲染器的对比函数,
  unmount卸载节点函数,insert节点移动函数 依赖于insertBefore。
*/
export const simpleDiff = (c1, c2, container, anchor, patch, unmount, insert) => {
  const newChildren = c2;//新节点
  const oldChildren = c1;//旧节点
  let lastIndex = 0;//存储新节点在旧节点中的最大索引
  for (let i = 0; i < newChildren.length; i++) {//遍历新节点
    let find = false;//新节点在旧节点中是否能够找到
    const newChild = newChildren[i];
    for (let j = 0; j < oldChildren.length; j++) {//遍历旧节点
      const oldChild = oldChildren[j];
      if (newChild.key === oldChild.key) {//key一致则找打了
        find = true;//置为true
        patch(oldChild, newChild, container);// 新旧节点打补丁
        if (j < lastIndex) {//则需要移动,因为新节点在旧节点中的位置,刚才的小,说明新节点顺序发生改变。
          if (newChildren[i - 1]) { // 如果newChildren[i-1]为null则是第一个节点,第一个节点是不需要移动的
            const anchor = newChildren[i - 1].el.nextSibling;
            //找到新节点的前一个节点(虚拟节点),对应的真实dom的下一个兄弟节点
            
            //将新节点插入,锚点为anchor
            insert(newChild.el, container, anchor);
          }
        } else {//j比lastIndex大则更新lastIndex为最新j,主要是找到新节点在旧节点中的最大索引
          lastIndex = j;
        }
        break;
      }
    }
    if (!find) {//新节点在旧节点中没有找到,
      const prevNode = newChildren[i - 1];//找到新节点的前一个节点的虚拟dom
      const anchor = prevNode ? prevNode.el.nextSibling : container.firstChild;//找到真实dom
      patch(null, newChild, container, anchor);//将真实dom插入
    }
  }
  //卸载新节点没有出现的旧节点
  for (let i = 0; i < oldChildren.length; i++) {
    const oldChild = oldChildren[i];
    const isExists = newChildren.find((item) => item.key === oldChild.key);
    if (!isExists) {
      unmount(oldChild, null, null, true);//卸载
    }
  }
};

以例子为例

遍历新节点[1,3,4,2,7],遍历旧节点[1,2,3,4,6,5];1能够在旧节点中找到,patch后,lastIndex仍然为0,节点不需要移动,遍历至节点3,在旧节点中能找到,index为2,大于0,节点不需要移动,patch即可。lastIndex更新为2;遍历至节点4,在旧节点中能够找到,index为3,大于2,节点不需要移动,lastIndex更新为3;遍历至节点2,在旧节点中能够找到,index为1,小于3,该节点需要移动,找到该节点的锚点(它的上一个虚拟节点,对应的真实dom的下一个兄弟节点),简单当前节点插入(将2插入到4的后面)。遍历至节点7,在旧节点中找不到,将新节点插入,锚点为当前节点的上一个节点的真实dom得兄弟节点。遍历至节点5,index为5,大于3,节点无需移动,直接patch即可。最后遍历旧节点,发现旧节点中6在新节点中不存在,则将6卸载。至此简单diff算法执行完毕。

  • 双端diff

双端diff算法是相比较于简单diff算法性能更加优化的算法,在vue2中被采用实现新旧节点对比,其基本思路就是从旧节点的首尾和新节点的首尾对比。将新节点的首节点和旧节点的首节点对比,将新节点的首节点和旧节点的尾节点对比,将新节点的尾节点和旧节点的首节点对比,将新节点的尾节点和旧节点的尾节点对比。只要发现key相同则patch,移动。如果找不到则遍历旧节点寻找新节点的首节点,找到则patch,找不到则卸载。

/*
  双端diff算法的参数与简单diff算法的参数一致。
*/
export const doubleEndDiff = (c1, c2, container, anchor, patch, unmount, insert) => {
  const newChildren = c2;//新节点
  const oldChildren = c1;//旧节点
​
  //四个索引分别指向新旧节点的首尾。
  let oldStartIndex = 0;
  let oldEndIndex = oldChildren.length - 1;
  let newStartIndex = 0;
  let newEndIndex = newChildren.length - 1;
  //四个节点,分别为新旧首尾节点
  let oldStartNode = oldChildren[oldStartIndex];
  let oldEndNode = oldChildren[oldEndIndex];
  let newStartNode = newChildren[newStartIndex];
  let newEndNode = newChildren[newEndIndex];
​
  //开始循环
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (!oldStartNode) {//判断节点是否为undefined,是则跳过
      oldStartNode = oldChildren[++oldStartIndex];
    } else if (!oldEndNode) {//是否为undefiend,是则跳过
      oldEndNode = oldChildren[--oldEndIndex];
    } else if (oldStartNode.key === newStartNode.key) {//新旧首节点一致
      patch(oldStartNode, newStartNode, container);//直接patch,无需移动
      newStartNode = newChildren[++newStartIndex];
      oldStartNode = oldChildren[++oldStartIndex];
    } else if (oldEndNode.key === newEndNode.key) {//新旧尾节点一致
      patch(oldEndNode, newEndNode, container);//直接patch。无需移动
      newEndNode = newChildren[--newEndIndex];
      oldEndNode = oldChildren[--oldEndIndex];
    } else if (oldStartNode.key === newEndNode.key) {//旧的首节点和新的尾节点
      patch(oldStartNode, newEndNode, container);//patch
      insert(oldStartNode.el, container, oldEndNode.el.nextSibling);//移动,将旧的首节点移动至尾部。
​
      oldStartNode = oldChildren[++oldStartIndex];
      newEndNode = newChildren[--newEndIndex];
    } else if (oldEndNode.key === newStartNode.key) {//旧的尾节点和新的首节点
      patch(oldEndNode, newStartNode, container);//patch
      insert(oldEndNode.el, container, oldStartNode.el);//将旧的尾节点移动至头部
      newStartNode = newChildren[++newStartIndex];
      oldEndNode = oldChildren[--oldEndIndex];
    } else {//四个节点都不相同,则在旧节点中寻找新节点的首节点
      const indexOld = oldChildren.findIndex((node) => node && (node.key === newStartNode.key));
      if (indexOld > 0) {//找到
        const vnodeToMove = oldChildren[indexOld];
        patch(vnodeToMove, newStartNode, container);//patch打补丁后,将旧节点中需要移动的节点移动至头部
        insert(vnodeToMove.el, container, oldStartNode.el);
        oldChildren[indexOld] = undefined;//虚拟节点中找到的该节点只为undefined,无需再次处理
      } else {
        patch(null, newStartNode, container, oldStartNode.el);//没找到则挂载
      }
      newStartNode = newChildren[++newStartIndex];//更新索引
    }
  }
​
  // oldVNode已经对比完毕,但是新节点仍然存在则挂载
  if (oldEndIndex < oldStartIndex && newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      patch(null, newChildren[i], container, oldStartNode.el);
    }
  } else if (newEndIndex < newStartIndex && oldStartIndex <= oldEndIndex) {//反之则卸载
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      console.log(oldChildren[i]);
      if (oldChildren[i]) unmount(oldChildren[i], null, null, true);
    }
  }
};

以例子为例

比较新节点的头部和旧节点的头部,key一致,则直接patch无需移动,比较新旧的尾部,发现key一致,无需移动,再次比较发现新旧首尾(3,2,7,6)都不一致,在旧节点中寻找新节点(3),可以找到,将其移动至当前旧节点指针指向的节点的前面。将当前节点置为undefined,新节点的头指针移动,发现新旧首尾(4,27,6)都不一致,重复上一步,如果为undefined则跳过。最后遍历至7,发现旧节点找不到,则挂载7,指针移动后满足newEndIndex < newStartIndex && oldStartIndex <= oldEndIndex则卸载节点6。

  • 快速diff

快速diff算法被vue3用于对比更新新旧节点,主要采用一种最长递增子序列的思维,构造最小需要移动的序列。快速diff处理时,首先处理前置(从前向后遍历直至key不一致为止)和后置节点(从后向前遍历直至key不一致),然后判断旧节点的头指针索引大于尾指针索引且新节点的头指针索引小于等于尾指针索引,则需要新增节点。如果旧节点头指针索引小于等于旧节点尾指针索引且新节点的头指针索引大于尾指针索引,则需要卸载。否则获取需要patch的长度(经过前置后置节点处理后的新节点长度)。通过source记录新节点在旧节点中的位置,获取最长递增子序列,遍历新节点需要patch的节点(从前向后遍历),如果该节点的source[i]对应的值为-1则是新增节点,需要挂载。如果当前遍历索引i!==seq[s](seq为最长递增子序列),则需要移动。否则等于seq[s]则是不需要移动的节点。跳过。

//参数保持一致
export const quickDiff = (c1, c2, container, anchor, patch, unmount, insert) => {
  if (c1.length < 1 && c2.length < 1) {
    return;
  }
  const newChildren = c2;
  const oldChildren = c1;
​
  let e1 = c1.length - 1;//新节点长度
  let e2 = c2.length - 1;//旧节点长度
  // 前置节点处理
  let j = 0;
​
  while (j <= e1 && j <= e2) {
    const oldNode = oldChildren[j];
    const newNode = newChildren[j];
    if (isSameVNodeType(oldNode, newNode)) {//key一致且元素type一致
      patch(oldNode, newNode, container);
    } else {
      break;
    }
    j++;//前置索引跟进
  }
  // 后置节点处理
  while (j <= e1 && j <= e2) {
    const oldNode = oldChildren[e1];
    const newNode = newChildren[e2];
    if (isSameVNodeType(oldNode, newNode)) {
      patch(oldNode, newNode, container, anchor);
    } else {
      break;
    }
    e1--;//后置索引跟进
    e2--;//后置索引跟进
  }
​
  const oldEndIndex = e1;
  const newEndIndex = e2;
  if (j > oldEndIndex && j <= newEndIndex) { // 新增
    const anchorIndex = newEndIndex + 1;
    const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null;//获取锚点
    while (j <= newEndIndex) {
      patch(null, newChildren[j++], container, anchor);
    }
  } else if (j > newEndIndex && j <= oldEndIndex) {//卸载旧节点
    while (j <= oldEndIndex) {
      unmount(oldChildren[j++], null, null, false);
    }
  } else {
    const count = newEndIndex - j + 1;// 以新节点为标准(经过前置后置处理后的节点list(例子中为[3,4,2,7]))
    if (count < 0) {
      return;
    }
    const source = new Array(count);
    source.fill(-1);//用于存储新节点在旧节点中的位置
​
    let moved = false;//是否需要移动,判断方式与简单diff相同
    let pos = 0;
​
    const newStartIndex = j;
    const oldStartIndex = j;
    const keyIndex = {};//记录新节点key的索引
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      keyIndex[newChildren[i].key] = i;
    }
​
    let patched = 0;
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      const oldNode = oldChildren[i];
      if (patched < count) {
        const k = keyIndex[oldNode.key];// 旧节点在新节点中的位置
        if (typeof k !== 'undefined') {//说明旧节点在新节点里可以找到
          const newNode = newChildren[k];
          patch(oldNode, newNode, container);
          source[k - newStartIndex] = i;//填充source
          patched++;//记录需要patch的节点数,超过则卸载
          if (k < pos) {//顺序发生改变
            moved = true;
          } else {
            pos = k;
          }
        } else {
          unmount(oldNode, null, null, true);
        }
      } else {
        unmount(oldNode, null, null, true);
      }
    }
​
    if (moved) {
      // source是新节点在旧节点的位置信息
      const seq = getSequence(source);//获取最长递增子序列
      let s = seq.length - 1;
      let i = count - 1;// count是新children中需要处理的几点
      for (i; i >= 0; i--) { // 遍历新节点中经过前置,后置处理后的list
        if (source[i] === -1) {//为-1则新节点在旧节点中找不到,则需要挂载
          const pos = i + newStartIndex;
          const newNode = newChildren[pos];
          const nextPos = pos + 1;
​
          const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
​
          patch(null, newNode, container, anchor);
        } else if (i !== seq[s]) {//不相等则该节点需要移动
          const pos = i + newStartIndex;
          const newNode = newChildren[pos];
          const nextPos = pos + 1;
          const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
          insert(newNode.el, container, anchor);
        } else {//跳过
          s--;
        }
      }
    }
  }
};

以例子为例

处理前置节点1,j更新为0,处理后置节点,newEndIndex更新为4,oldEndIndex更新为4。获取新节点需要patch得数量,count=newEndIndex - newStartIndex+1得出结果为4。从newStartIndex开始遍历至newEndIndex,构造keyIndex为

let keyIndex={3:1,4:2,2:3,7:4};

遍历旧节点,2在新节点出现过,patch后,source填充为[-1,-1,1,-1]。pos赋值为3,继续遍历至3新节点出现过,patch后,source填充为[2,-1,1,-1]。由于k为1,moved置为true,遍历至4,发现在新节点中出现过,patch出后,source填充为[2,3,1,-1]。遍历至6,新节点无法找到,则卸载6。

然后获取source的最长递增子序列为[2,3],这里记录索引为[0,1]。最后遍历新节点需要patch得节点,从后往前遍历。索引为count-1 ~ 0

遍历至节点7,source为-1,则需要挂载。遍历至节点2发现为source不等于-1,且当前节点索引不在最长递增子序列中,则该节点需要移动。获取当前节点的下一个节点(虚拟节点),再获取其真实dom,将当前节点2添加至7的前面。,遍历至4发现当前索引在最长递增子序列里,跳过,3同样是如此。至此快速diff算法实现完毕。

本文通过一个例子讲三种diff算法进行了简单介绍,方便理解diff算法的核心原理。本文相关代码在

Bug-codergb