这是 聊diff 的第三篇文章,聊聊vue3的diff思路.思路主要来自 vue-design 项目

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

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

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

Let's start


Vue3_diff

过程分析

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

==另 外 注 意==

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

我先假设有如下节点, keyVnodekey, children 代表该节点的内容

// 以前的节点
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>"},
]
// 新节点,最后更新的结果
const nextNodes = [
  {key: "k-11", children: "<span>11</span>"},
  {key: "k-0", children: "<span>0</span>"},
  {key: "k-5", children: "<span>5</span>"},
  {key: "k-13", children: "<span>13</span>"},
  {key: "k-1", children: "<span>1</span>"},
  {key: "k-7", children: "<span>7</span>"},
  {key: "k-16", children: "<span>16</span>"},
  {key: "k-3", children: "<span>3</span>"},
  {key: "k-15", children: "<span>15</span>"},
  {key: "k-17", children: "<span>7</span>"},
  {key: "k-4", children: "<span>4</span>"},
  {key: "k-6", children: "<span>6</span>"}
]

diff 是基于新旧的 diff, 先要明确这个大前提,如果刚刚开始没有节点,则会先 mount 而不会 patch
最后期望的结果(老节点都得到了复用)
在这里插入图片描述

另外新产生节点(newNodes)是基于老节点的,于是

// 最终的节点数据,因为最终的节点是基于老节点的,这里做个模拟
let newNodes = JSON.parse(JSON.stringify(preNodes));

preNodes: 老节点;
nextNodes: 新节点;
newNodes: 新产生节点,最后用于渲染为真实dom.其实vue2早期就是先完全产生新节点,最后再渲染为真实dom.后面版本变为一次(patch)优化遍历时就更新相应的Dom.

中心思想提炼: 从老节点中找到与新节点中key相同的节点,进行复用。

详细思路:

1. 先找两端相同的节点(key相同),找到即再往中间找。

image.png

如图,J 从开头找, preEndIndexnextEndIndex 分别对应老节点和新节点的末尾索引。找到就增加 J 或者减少 preEndIndexnextEndIndex
image.png

代码如下

let j = 0;

let preEndIndex = preNodes.length - 1;
let nextEndIndex = nextNodes.length - 1;
let preVNode = preNodes[j];
let nextVNode = nextNodes[j];

while(preVNode.key === nextVNode.key){
  j++;
  preVNode = preNodes[j];
  nextVNode = nextNodes[j];
}
preVNode = preNodes[preEndIndex];
nextVNode = nextNodes[nextEndIndex];
while(preVNode.key === nextVNode.key){
  preVNode = preNodes[--preEndIndex];
  nextVNode = nextNodes[--nextEndIndex];
}

考虑到一种情况
image.png
上面的情况会出现老节点比对完了,新节点还存在,那么最后会造成 J > preEndIndex[情况1],同理,老节点未比对完,新节点已经比对完,那么会出现 J > nextEndIdnex[情况2].
针对这两种情况,

  • 都要避免循环,引入label解决。
  • 另外,情况1需要(从newNodes中)删除多余的节点,情况2需要(向newNodes)增加新节点中未遍历的节点。

于是把原来代码改一下:

// ....
outer: {
  while(preVNode.key === nextVNode.key){
    j++;
    if(j> preEndIndex || j > newEndIndex) {
      break outer;
    }
    preVNode = preNodes[j];
    nextVNode = nextNodes[j];
  }
  preVNode = preNodes[preEndIndex];
  nextVNode = nextNodes[nextEndIndex];
  while(preVNode.key === nextVNode.key){
    if(j> preEndIndex || j > nextEndIndex) {
      break outer;
    }
    preVNode = preNodes[--preEndIndex];
    nextVNode = nextNodes[--nextEndIndex];
  }
}
if(j > preEndIndex) {
  // 老节点遍历完了,新节点还存在,将新节点放入。
  for(let i = j; i< nextEndIndex; i++){
    const addedNode = nextNodes[i];
    // 注意: 框架内部是利用appendchild 更新dom.
    newNodes.splice(i,0,addedNode);
  }
}
else if(j > nextEndIndex){
  // 新节点遍历完了,老节点还存在,将老节点删除。
  const deleteLen = preEndIndex - j;
  // 注意: 框架内部是重写removeChild 更新dom.
  newNodes.splice(i,deleteLen);
}else {  //均还有不同节点时 }

大多数情况就像刚开始的例子一样: 中间都还存在不同的节点,需要移动和新增。这个情况是patch主要处理的地方,代码写在上面的 else 里面。

具体思路是怎样呢,

2. 产生一个老节点可复用节点的映射数组

先生成一个 每项为 -1 的数组noPatchedIndex,长度为 新节点未遍历的节点长度。

遍历未比对的 老节点和新节点(这里有个优化细节: 新节点不用遍历,因为结构的特殊性,直接生成key 对应 index 的对象 keyInIndex 例如{k-1: 0, k-2: 1, ...},后面直接取)。

未比对老节点中如果存在未比对新节点相同的节点那么在 noPatchedIndex 相应位置保存起来它在老节点中的索引。

另外判断时,新节点中不存在还需删除老节点,并且得出是否需要移动元素(索引数组 noPatchedIndex 中存在非递增排序,即数组中当前项不能大于之后的项)

大概这个意思
image.png

新生成节点。k-2在新节点(nextNodes)中不存在,所以被删除。
image.png

3. 处理需要移动的情况(复用节点处理)

大概有如下步骤:

  • ①找出 noPatchedIndex 最大递增子序列 lisArr索引数组
  • ②将未比对新节点与依次插入到新节点。

针对①,这里有一个函数 lis

lis([3,1,5,4,2]) //[1, 4] | 1,2 为最大递增子序列
lis([1,2,3]) //[0, 1, 2] | 1,2,3 为最大递增子序列
lis([0,-1,8,6,10,7]) //[1, 3, 5] | -1,6,7 为最大递增子序列
lis 具体实现请参考我的另一篇文章 [算法篇---寻找最大递增子序列]()

于是有
image.png

针对②,为什么要找最大递增子序列 lisArr呢,因为对于 lisArr 里面的项顺序是不用动的,新节点的未比对节点只需要在这些项前后插入即可。
具体实现就是遍历noPatchedIndexlisArr

  1. noPatchedIndex 项等于 -1,表示,老节点中不存在的项,需要新增
  2. noPatchedIndex 索引 与 lisArr 不相等时,需要移动老节点到响应的位置
  3. noPatchedIndex 索引 与 lisArr 相等时,不做操作。

需要注意:遍历都是从后向前遍历,目的是防止数组长度变换影响索引值进而影响节点取值,插入,删除。

为了直观的理解,下面来一波操作图:i jnoPatchedIndexlisArr

删除的为红色,新增的为绿色,复用的节点为灰色;
节点复用插入时调用的是DOM API [insertBefore]()这个是先回添加该节点如果存在重复是会删除原有节点的;
插入位置 为 节点 nextNodes[nopatchedIndex[lisArr[j]]] 对应在 oldNodes 的位置

1) i =10, j = 2 ; 节点 k-4 复用
image.png
2) i=9, j=1
image.png

3) i=8, j=1. 新增
image.png

4) i=7,j=1. k-3复用
image.png

5) i=6, j=0
image.png
6)i=5, j=0
image.png
7)i=4, j=0.插入(涉及到增加和删除),这里因为插入的是老节点,而原节点(2)在插入位置(0)后面,所以新增之后删除的索引位置要减一(代码中会有体现)
image.png

8)i=3,j=0新增
image.png

9)i=2, j=0.插入(涉及到增加和删除),这里因为插入的是老节点,而原节点(9)在插入位置(0)后面,所以新增之后删除的索引位置要减一(代码中会有体现)
image.png

10)i=1,j=0
image.png
11)i=0, j=0,新增
image.png
12) i= -1, 循环结束。

最后结果来看,k-5, k-1, k-2, k-4, k-6 得到了复用, 那么k-2 到哪去了呢,新节点nextNodes不含该节点自然在移动(move)前就删除了!前面提到了。
至此,diff过程结束了相信其实看图也可以看明白

哎,画图太累了🤣,现在真心对那么文章配有图解说的博主 瑞思拜🙏🙏🙏(respect!!!)。absolute!!!

最后奉上全部代码。

全部代码

// 老节点
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>"},
]
// 新节点
const nextNodes = [
  {key: "k-11", children: "<span>11</span>"},
  {key: "k-0", children: "<span>0</span>"},
  {key: "k-5", children: "<span>5</span>"},
  {key: "k-13", children: "<span>13</span>"},
  {key: "k-1", children: "<span>1</span>"},
  {key: "k-7", children: "<span>7</span>"},
  {key: "k-6", children: "<span>6</span>"},
  {key: "k-3", children: "<span>3</span>"},
  {key: "k-15", children: "<span>15</span>"},
  {key: "k-17", children: "<span>7</span>"},
  {key: "k-4", children: "<span>4</span>"},
  {key: "k-6", children: "<span>6</span>"}
]
// 最终的节点数据,因为最终的节点是基于老节点的,这里做个模拟
let newNodes = JSON.parse(JSON.stringify(preNodes));

//两个都从左边开始比对的索引
let j = 0;

let preEndIndex = preNodes.length - 1;
let nextEndIndex = nextNodes.length - 1;
let preVNode = preNodes[j];
let nextVNode = nextNodes[j];

outer: {
  while(preVNode.key === nextVNode.key){
    j++;
    if(j> preEndIndex || j > newEndIndex) {
      break outer;
    }
    preVNode = preNodes[j];
    nextVNode = nextNodes[j];
  }
  preVNode = preNodes[preEndIndex];
  nextVNode = nextNodes[nextEndIndex];
  while(preVNode.key === nextVNode.key){
    if(j> preEndIndex || j > nextEndIndex) {
      break outer;
    }
    preVNode = preNodes[--preEndIndex];
    nextVNode = nextNodes[--nextEndIndex];
  }
}
if(j > preEndIndex) {
  // 老节点遍历完了,新节点还存在,将新节点放入。
  for(let i = j; i< nextEndIndex; i++){
    const addedNode = nextNodes[i];
    // 注意: 框架内部是利用appendchild 更新dom.
    newNodes.splice(i,0,addedNode);
  }
}
else if(j > nextEndIndex){
  // 新节点遍历完了,老节点还存在,将老节点删除。
  const deleteLen = preEndIndex - j;
  // 注意: 框架内部是重写removeChild 更新dom.
  newNodes.splice(i,deleteLen);
}
else {
  //保存是否需要移动
  let moved = false;

  const preStart = j;
  const nextStart = j;
  let pos = 0;
  //保存新节点key-index 的map, 避免多次循环
  const keyInIndex = {}; //{ k-1: 1, k-2: 2, k-3:3,...  }
  //新节点未比对的节点长度
  const newLength = nextEndIndex - nextStart + 1;
  for(let i = nextStart; i< newLength; i++) {
    keyInIndex[nextNodes[i].key] = i
  }
  const oldLength = preEndIndex - preStart + 1;
  //防止老节点比新节点多时,删除已找到的重复的节点
  let patched = 0;
  // 产生新节点能复用的老节点的索引数组
  const noPatchedIndex = Array(newLength).fill(-1); //-1状态保存
  for(let i = preStart; i< oldLength; i++) {
    const preNode = preNodes[i];
    if(patched <= nextEndIndex) {
      //保存老节点key在新节点中对应的index
      const k = keyInIndex[preNode.key];
      if(typeof k !== 'undefined'){
        let idx = k - preStart;
        noPatchedIndex[idx] = i;
        patched++;
        // 筛选出需要往前调换的元素
        if(k < pos) moved = true;
        else pos = k;
      }else {
        // 动态查找删除项索引
        const deleteIndex = newNodes.findIndex(node => node.key === preNodes[i].key)
        newNodes.splice(deleteIndex,1);
      }
    }else {
      newNodes.splice(i,1)
    }
  }
  
  //处理需要移动的情况
  if(moved){
    const newNodesCopy = JSON.parse(JSON.stringify(newNodes))
    //最大递增子序列索引
    const lisArr = lis(noPatchedIndex);
    let j = lisArr.length - 1;
    //遍历新节点中未比对的节点,从后面遍历,防止更新过程index非预期变化。
    for(let i=newLength - 1; i>=0; i--){
      const current = noPatchedIndex[i];
      // 更新的实际位置
      const pos = i+nextStart;
      let insertPos = newNodes.findIndex(node => node.key === nextNodes[nextStart+lisArr[j]].key);
      if(current === -1){// -1即为新增的情况
        // 注意 [1,2,3].splice(0,4) => [4,1,2,3]
        newNodes.splice(insertPos+1, 0, nextNodes[pos]);
        continue;
      }else if(lisArr[j] !== i) {//可以复用非递增节点的情况
        /*
          insertBefore 作用: 如果给定的子节点是对文档中现有节点的引用,insertBefore() 会将其从当前位置移动到新位置
          以下的操作就是实现 insertBefore 方法。
        */
        //移动元素在新节点的位置
        let oldPos = newNodes.findIndex(node => node.key === nextNodes[pos].key);

        //需要删除插入的节点对应原来的节点
        //新节点中插入老节点对应的位置
        newNodes.splice(insertPos+1, 0, newNodes[oldPos]);
        // 判断插入节点的位置在被插入位置的前面还是后面,如果是后面就加1
        oldPos = insertPos > oldPos ? oldPos : oldPos+1;
        newNodes.splice(oldPos, 1)
      }else {
        j--;
      }
    }
  }
}
console.log('newNodes: ', newNodes);
// 寻找最大递增子序列 索引
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
/*
  [3,1,5,4,2] => [1,2]
*/
function lis(arr) {
  const p = arr.slice();
  const result = [0]; // 索引数组
  let i;
  let j;
  let u;
  let v;
  let c;
  const len = arr.length;
  for (i = 0; i < len; i++) {
    const arrI = arr[i];
    if (arrI !== 0) {
      // 取最后一个元素
      j = result[result.length - 1];
      if (arr[j] < arrI) {
        p[i] = j;
        result.push(i);
        continue;
      }
      u = 0;
      v = result.length - 1;
      //result长度大于1时
      while (u < v) {
        // 取中位数
        c = ((u + v) / 2) | 0;
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c; //result中位数大于等于 当前项。v取中位数
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1];
        }
        result[u] = i;
      }
    }
  }
  u = result.length;
  v = result[u - 1];
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

总结

本文中例子只是为了更好理解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

另外,大佬们正在翻译 vue3的 英文文档 docs-next-zh-cn


以上。
image.png


ethanYin
205 声望0 粉丝

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