头图
这是 聊diff 的第二篇文章,聊聊vue2的diff思路.思路主要来自 vue-design 项目

【第一篇】和面试官聊聊Diff___React
【第二篇】和面试官聊聊Diff___vue2(本文)
【第三篇】和面试官聊聊Diff___Vue3

为了更好的阅读体验,建议从第一篇看起

我是一名前端的小学生。行文中对某些设计原理理解有误十分欢迎大家讨论指正😁😁😁,谢谢啦!当然有好的建议也谢谢提出来
image.png
(玩笑)

Let's start


vue2_diff

比对流程

本文注重的是patch过程,具体的细节和边界就没有考虑。

==另 外 注 意==

  • 三篇文章 diff 的讲解,为了方便展示 节点复用, 用了 children 保存内容,实际上这是不合理的,因为children不同还会递归补丁(patch)
  • diff也不是vue optimize的全部,只是其中一部分,例如compile时确定节点类型,不同类型 不同的 mount/patch 处理方式等等。
    Vue2.x的 diff 相对于 react 更好一些,避免了一些不必要的比对。

基本思路

现在比如说由若干个新老节点(preNodes / nextNodes )。

使用 nextStartIdxnextEndIdxnextStartNodenextEndNode 保存 ==新节点== 首尾索引和首尾节点。
使用 preStartIdxpreEndIdxpreStartNodepreEndNode 保存 ==老节点== 首尾索引和首尾节点。
  • 先将新旧节点首尾一一比对,遇到 undefined 跳过

    • 如果相同则 前移 / 后移 比对节点(首首相同,均后移;尾尾相同,均前移;首尾相同,新首后移,旧尾前移;尾首相同)。
    • 如果存在 首尾遍历了全部节点, 那么 删除多余节点(情况①)或新增节点(情况②)即可
  • 相同的都比对完毕后,将新节点(nextNodes)中未比对的每个节点与老节点(preNodes)中未比对节点(是一个keyindex 对应的 map)中所有节点比对

    • 如果存在相同,将老节点中相应节点重置为 undefined, 否者在 新生成节点中 添加/删除相应节点。

情况①(也可能是首尾/尾首比对),再继续循环, 会有 preStartIdx > preEndIdx, 表明有新增节点,要添加
image.png

情况②(也可能是首尾/尾首比对),再继续循环, 会有 nextStartIdx > nextEndIdx, 表明有多余节点,要删除
image.png
代码体现:

if(preStartIdx > preEndIdx) { // 有连续多余的新节点需增加
  const addStartIndex = nextStartIdx > 0 ? nextStartIdx-1 : 0;
  let insertIndex = newNodes.findIndex(node => nextNodes[addStartIndex].key === node.key)
  for(let i = nextStartIdx; i<= nextEndIdx; i++){
    newNodes.splice(insertIndex +1, 0, nextNodes[i])
    insertIndex++;
  }
}else if(nextStartIdx > nextEndIdx) {
  //有连续多余的旧节点,需删除(末尾开始删除)  123456 | 1256
  // const delStartIndex = preStartIdx > 0 ? preEndIdx -1 : 0;
  let deleteIndex = newNodes.findIndex(node => preNodes[preEndIdx].key === node.key)
  for(let i=preEndIdx; i>=preStartIdx; i--){
    if(preNodes[i] === undefined) continue;
    newNodes.splice(deleteIndex, 1);
    deleteIndex--;
  }
}

比如有以下节点:

// 旧节点
const preNodes = [
  {key: "k-1", children: "<span>old1</span>"},
  {key: "k-2", children: "<span>old2</span>"},
  {key: "k-3", children: "<span>old3</span>"},
  {key: "k-4", children: "<span>old4</span>"},
  {key: "k-5", children: "<span>old5</span>"},
  {key: "k-6", children: "<span>old6</span>"},
  {key: "k-7", children: "<span>old7</span>"},
  {key: "k-8", children: "<span>old8</span>"},
]
// 新节点
const nextNodes = [
  {key: "k-8", children: "<span>8</span>"},
  {key: "k-7", children: "<span>7</span>"},
  {key: "k-12", children: "<span>12</span>"},
  {key: "k-5", children: "<span>5</span>"},
  {key: "k-3", children: "<span>3</span>"},
  {key: "k-11", children: "<span>11</span>"},
  {key: "k-2", children: "<span>2</span>"},
  {key: "k-13", children: "<span>13</span>"},
  {key: "k-9", children: "<span>9</span>"},
  {key: "k-1", children: "<span>1</span>"},
]

经过补丁diff后,期望得到结果
image.png
可以看到老节点都得到了复用~~

用图来具体实现上面节点变换过程。

图例讲解

==注意==: 图中删除插入是我用数组代替节点插入 的操作( 例如insertbefore),其实正常 节点插入 时,就会检测插入节点是否存在,如果存在是不会插入重复节点的,只会将原来节点调换至新的位置。

例子1

这个例子就是把上面提到的图形解剖一步步呈现。一大波图来袭

### 说明

  • 使用 nextStartIdxnextEndIdxnextStartNodenextEndNode 保存 ==新节点== 首尾索引和首尾节点。
  • 使用 preStartIdxpreEndIdxpreStartNodepreEndNode 保存 ==老节点== 首尾索引和首尾节点。
  • 绿色表示新增节点,红色表示删除节点,湖蓝色表示复用节点。

初始状态
image.png

preStartIdx = 0;preEndIdx = 7;nextStartIdx = 0;nextEndIdx = 9;
尾首相同,将旧首节点(k-1)插入到旧尾节点(k-8)后面。且 preStartIdx + 1, nextEndIdx -1
image.png
preStartIdx = 1;preEndIdx = 7;nextStartIdx = 0;nextEndIdx = 8;
尾首相同,将旧尾节点( k-8)插入到旧首节点(k-2)前面。且 nextStartIdx + 1, preEndIdx -1
image.png

preStartIdx = 1;preEndIdx = 6;nextStartIdx = 1;nextEndIdx = 8;
尾首相同,将旧尾节点(k-7)插入到旧首节点(k-2)前面。且 nextStartIdx + 1, preEndIdx -1
image.png

preStartIdx = 1;preEndIdx = 5;nextStartIdx =2;nextEndIdx = 8;
两边(首尾)比对完成,开始中间比对(newNodes 未比对节点与 preNodes 未比对所有节点一一比对),k-12在老节点未比对区间无相同,增加到k-2前面
image.png

preStartIdx = 1;preEndIdx = 5;nextStartIdx = 3;nextEndIdx = 8;
找到 k-5 相同(复用),k-5 增加到 k-2 前面,删除原来( newNodes)的 k-5,老节点中 k-5置为undefined
image.png

preStartIdx = 1;preEndIdx = 5;nextStartIdx = 4;nextEndIdx = 8;
找到 k-3相同(复用),k-3增加到 k-2前面,删除原来( newNodes)的 k-3,老节点中 k-3 置为undefined
image.png

preStartIdx = 1;preEndIdx = 5;nextStartIdx = 5;nextEndIdx = 8;
k-11未找到相同节点,k-11 增加到 k-2 前面
image.png

preStartIdx = 1;preEndIdx = 5;nextStartIdx = 6;nextEndIdx = 8;
首首节点 k-2 相同,k-2 增加到 k-2 前面, 删除原来的 k-2
image.png

因为undefined会跳过,即preStartIdx = 3;preEndIdx = 5;nextStartIdx = 0;nextEndIdx = 9;
k-13 未找到相同节点,k-13 增加到 k-4 前面
image.png

preStartIdx = 3;preEndIdx = 5;nextStartIdx = 8;nextEndIdx = 8;
新节点前后索引相等,k-9 添加到 k-4 前面
image.png
满足 nextStartIdx >nextEndIdx 删除多余节点
image.png
最后结果
image.png

兄弟们,点个赞啊,累死我了,哎~~~(图搞了我两个多小时)

加深理解的例子

这个例子节点为;

const preNodes = [
  {key: "k-1", children: "<span>old1</span>"},
  {key: "k-2", children: "<span>old2</span>"},
  {key: "k-3", children: "<span>old3</span>"},
  {key: "k-4", children: "<span>old4</span>"},
  {key: "k-5", children: "<span>old5</span>"},
  {key: "k-6", children: "<span>old6</span>"},
  {key: "k-7", children: "<span>old7</span>"},
  {key: "k-8", children: "<span>old8</span>"},
  {key: "k-9", children: "<span>old9</span>"},
]
// /* 旧首==新尾  旧尾==新首 旧尾==新首 首首 旧首==新尾 尾尾*/
const nextNodes = [
  {key: "k-9", children: "<span>9</span>"},
  {key: "k-12", children: "<span>12</span>"},
  {key: "k-8", children: "<span>8</span>"},
  {key: "k-13", children: "<span>13</span>"},
  {key: "k-6", children: "<span>6</span>"},
  {key: "k-2", children: "<span>2</span>"},
  {key: "k-15", children: "<span>15</span>"},
  {key: "k-5", children: "<span>5</span>"},
  {key: "k-14", children: "<span>14</span>"},
  {key: "k-19", children: "<span>19</span>"},
  {key: "k-7", children: "<span>7</span>"},
  {key: "k-3", children: "<span>3</span>"},
  {key: "k-1", children: "<span>1</span>"},
]

为什么特殊呢,因为会发生首尾和中间比对交叉进行(其实本来就相互影响),
这个我就不去画图解释了,这个例子为加深理解所用。

代码实现

代码

直接贴代码了

function vue2_diff(){
   const preNodes = [
     {key: "k-1", children: "<span>old1</span>"},
     {key: "k-2", children: "<span>old2</span>"},
     {key: "k-3", children: "<span>old3</span>"},
     {key: "k-4", children: "<span>old4</span>"},
     {key: "k-5", children: "<span>old5</span>"},
     {key: "k-6", children: "<span>old6</span>"},
     {key: "k-7", children: "<span>old7</span>"},
     {key: "k-8", children: "<span>old8</span>"},
   ]

   const nextNodes = [
     {key: "k-8", children: "<span>8</span>"},
     {key: "k-7", children: "<span>7</span>"},
     {key: "k-12", children: "<span>12</span>"},
     {key: "k-5", children: "<span>5</span>"},
     {key: "k-3", children: "<span>3</span>"},
     {key: "k-11", children: "<span>11</span>"},
     {key: "k-2", children: "<span>2</span>"},
     {key: "k-13", children: "<span>13</span>"},
     {key: "k-9", children: "<span>9</span>"},
     {key: "k-1", children: "<span>1</span>"},
   ]

   let preStartIdx = 0;
   let nextStartIdx = 0;
   let preStartNode = preNodes[0];
   let nextStartNode = nextNodes[0];
   let preEndIdx = preNodes.length - 1;
   let nextEndIdx = nextNodes.length - 1;
   let preEndNode = preNodes[preEndIdx];
   let nextEndNode = nextNodes[nextEndIdx];

   const newNodes = JSON.parse(JSON.stringify(preNodes));

   function isSameNode(node1, node2){
     return node1.key === node2.key;
   }

   while(preStartIdx <= preEndIdx && nextStartIdx <= nextEndIdx){
     if(preStartNode === undefined){
       console.log('undefined: ', preStartNode);
       preStartNode = preNodes[++preStartIdx];
     }else if(preEndNode === undefined){
       console.log('undefined: ', preEndNode);
       preEndNode = preNodes[--preEndIdx]
     }else if(isSameNode(preStartNode, nextStartNode)){
       // 新旧首首相同
       preStartNode = preNodes[++preStartIdx];
       nextStartNode = nextNodes[++nextStartIdx];
     }else if(isSameNode(preEndNode, nextEndNode)) {
       // 新旧尾尾相同
       preEndNode = preNodes[--preEndIdx];
       nextEndNode = nextNodes[--nextEndIdx];
     }else if(isSameNode(preStartNode, nextEndNode)) {
       //执行 将旧首节点插入到旧尾节点后面
       const insertPos = newNodes.findIndex(node => node.key === preEndNode.key);
       const deletePos = newNodes.findIndex(node => node.key === preStartNode.key);
       // 添加
       newNodes.splice(insertPos+1, 0, preStartNode);
       // 删除
       const delIndex = insertPos < deletePos ? deletePos + 1 : deletePos;
       newNodes.splice(delIndex, 1);
       
       preStartNode = preNodes[++preStartIdx];
       nextEndNode = nextNodes[--nextEndIdx];
     }else if(isSameNode(preEndNode, nextStartNode)) {
       //执行 将旧尾节点插入(insertBefore)到旧首节点前面
       const insertPos = newNodes.findIndex(node => node.key === preStartNode.key);
       const deletePos = newNodes.findIndex(node => node.key === preEndNode.key);
       // 添加
       newNodes.splice(insertPos, 0, preEndNode);
       // 删除
       const delIndex = insertPos < deletePos ? deletePos + 1 : deletePos;
       newNodes.splice(delIndex, 1);
       nextStartNode = nextNodes[++nextStartIdx];
       preEndNode = preNodes[--preEndIdx];
     }else {
       const oldKeyInIndex = {};
       for(let i=preStartIdx; i<=preEndIdx; i++){
         if(!preNodes[i]) continue; // 处理undefined情况
         oldKeyInIndex[preNodes[i].key] = i;
       }
       const moveNodeIndex = oldKeyInIndex[nextStartNode.key]
       if(moveNodeIndex !== undefined){
         //找到即 insertBefore,将当前节点插入到旧开始节点在newNodes的位置的后面
         const insertPos = newNodes.findIndex(node => node.key === preStartNode.key);
         const deletePos = newNodes.findIndex(node => node.key === preNodes[moveNodeIndex].key);

         newNodes.splice(insertPos, 0, preNodes[moveNodeIndex]);
         newNodes.splice(deletePos+1, 1);
         preNodes[moveNodeIndex] = undefined;
       }else {//添加
         const insertPos = newNodes.findIndex(node => preStartNode.key === node.key);  
         newNodes.splice(insertPos, 0, nextStartNode);
       }
       nextStartNode = nextNodes[++nextStartIdx];
     }
   }
   if(preStartIdx > preEndIdx) { // 有连续多余的新节点需增加
     const addStartIndex = nextStartIdx > 0 ? nextStartIdx-1 : 0;
     let insertIndex = newNodes.findIndex(node => nextNodes[addStartIndex].key === node.key)
     for(let i = nextStartIdx; i<= nextEndIdx; i++){
       newNodes.splice(insertIndex +1, 0, nextNodes[i])
       insertIndex++;
     }
   }else if(nextStartIdx > nextEndIdx) {
     //有连续多余的旧节点,需删除(末尾开始删除)  123456 | 1256
     // const delStartIndex = preStartIdx > 0 ? preEndIdx -1 : 0;
     let deleteIndex = newNodes.findIndex(node => preNodes[preEndIdx].key === node.key)
     for(let i=preEndIdx; i>=preStartIdx; i--){
       if(preNodes[i] === undefined) continue;
       newNodes.splice(deleteIndex, 1);
       deleteIndex--;
     }
   }
   console.log('vue2 diff: ', newNodes);
  }

总结

本文中例子只是为了更好理解diff思路, patch过程与真实情况还有些差异

  • 重复节点问题。新老节点有重复节点时,本文diff函数没处理这种情况。
  • 仅是用数组模拟了Vnode,真实的Vnode 不止 key和children,还有更多的参数
  • 比对相同节点时,仅比对了 key, 真实其实还涉及到 class(类名) 、attrs(属性值)、孩子节点(递归)等属性比对;另外上面的children也要比对若不同也要递归遍历
  • 插入、删除、添加节点我用的数组。其实应该用 insertbeforedeleteadd。这些方法均是单独封装不能采用相对应的 Dom Api,因为 vue 不止用在浏览器环境。
  • ...
Vue@3.2 已经出来了,React@18也快了,哎,框架学不完的。还是多看看不变的东西吧(js, 设计模式, 数据结构,算法...)

哎哎哎,,同志,看完怎么不点赞,别看别人就说你呢,你几个意思?
image.png

参考

站在别人肩膀能看的更远。

【推荐】vue-design
【掘金小册】剖析Vue.js内部运行机制
【Vue patch源码地址】vue-next


以上。
image.png


ethanYin
205 声望0 粉丝

如果你想做,下一秒就去。