diff算法--vue3性能提升的秘密武器

背景

diff算法是讨论vue源码始终绕不开的话题,也是面试官非常热衷提问的问题来源,通过对diff算法的考察,可以明显的看出候选人对vue原理的掌握程度。因此许多同学就会通过背八股文来强迫自己输入diff算法的知识,殊不知,只要面试官稍加深入,候选人的回答就捉襟见肘了。本文就通过对diff算法的一步步还原来帮助各位同学从根源上解决diff算法上的难题。

初见diff算法

首先我们得明白什么情况下会用到diff算法,以及为什么需要用到diff算法。

diff算法使用场景

在vue中,当我们修改响应式数据时,vue内部会执行render函数,从而形成新的虚拟DOM ,然后会将新的VNode同旧的VNode进行比较,这个新|旧VNode比较的过程在patch函数中进行,被称为"打补丁"。在比较新旧VNode的时候,针对其子节点会执行patchChildren函数,如果新旧VNode的子节点均为数组时,此时就会使用diff算法,用来减少DOM操作,提升性能

为什么是diff算法

前面讲到diff算法的作用就是在响应式数据更新期间,通过减少DOM操作来提升性能。为什么diff算法能够减少DOM操作呢?在pacth过程中,如果没有diff算法,那就只能卸载旧节点,然后在挂载新节点,由于新的VNode此时并没有真实的el节点,因此每次更新都必须先通过获取标签名创建真实的新节点,然后再挂载,整个过程是非常消耗性能的。而diff算法就是通过比对新旧VDOM,从中复用旧的真实节点,仅更新其内容,从而减少对DOM节点的操作。

核心策略

快速Diff

diff算法首先是进行头尾对比。即,先比较头部节点和尾部节点,跳过相同的前缀和尾缀节点,缩小比较范围。例如,新旧VDOM中存在相同节点(如,A-B-C变为A-D-C),则直接复用头部和尾部节点,仅遍历中间节点。

最长递增子序列(LIS)

最长递增子序列是vue3较vue2diff算法性能优化的又一大提升。Vue3通过构造source数组记录新节点在旧节点中的位置,并计算LIS确定最长无需移动的节点序列,仅对不在LIS中的节点进行移动,极大减少DOM操作次数 。例如,新旧节点顺序变化为A-B-CB-C-A时,仅移动节点A而非全部重新排列。

详细过程

假设旧节点为(ABCDEF),新节点为(ABECDHFG),如下图:

头尾比较

思路:

1、定义三个变量:i=0;e1=旧子节点个数;e2=新节点个数

2、从头部开始比较,i不断递增,直到新旧节点的不同或者一方被遍历完时停止

3、从尾部开始比较,e1和e2同时递减,直到新旧节点不同或者一方被遍历完时停止

js 复制代码
function isSameVnode (vnode1, vnode2) {//判断两个虚拟节点是否相同
  return (vnode1.type === vnode2.type) && (vnode1.key === vnode2.key)
}

const patchKeyedChildren = (oldChildren, newChildren, el) => {
  // 定义三个变量
  let i = 0
  let e1 = oldChildren.length - 1
  let e2 = newChildren.length - 1
  //从头开始比,相同就进行patch,有一方停止循环直接跳出
  while (i <= e1 && i <= e2) {
    const n1 = oldChildren[i]
    const n2 = newChildren[i]
    if (isSameVnode(n1, n2)) {
      //打补丁(上面只是类型和key相同,并不代表两个元素中属性或子级相同)
      patch(n1, n2, el)
    } else {
      //当两个元素不相同时就停止比较,因为要更新当前的旧元素
      break
    }
    i++
  }
  // 从尾部开始比较
  while (i <= e1 && i <= e2) {
    const n1 = oldChildren[e1]
    const n2 = newChildren[e2]
    if (isSameVnode(n1, n2)) {
      //比较类型和key,如果相同就递归比较他们的子级
      patch(n1, n2, el)
    } else {
      //当两个元素不相同时就停止比较,因为要更新当前的旧元素
      break
    }
    e1--
    e2--
  }
  //i要比e1大说明有新增,i和e2之间的元素就是要插入的
  if (i > e1) {
    if (i <= e2) {
      while (i <= e2) {
        //e2的下一个索引
        const nextPos = e2 + 1
        //如果nextPos小于newChildren长度,说明后面有元素,否则为null,从末尾插入元素
        const anchor = nextPos < newChildren.length ? newChildren[nextPos] : null
        patch(null, newChildren[i], el, anchor)//插入元素
      }
    }
  } else if (i > e2) { //i比e2大的时候,i到e1之间的不在新子级中,说明是要卸载
    if (i <= e1) {
      while (i <= e1) {
        unmount(oldChildren[i])
        i++
      }
    }
  }
}

乱序对比

经过上一轮的比较,已经完成了头尾相同的节点的更新,接下来就是比对中间乱序的节点了,这是diff算法的核心。

思路:

1、构建一个map--keyToNewIndexMap,其键为新节点的key,值为对应的元素索引

2、遍历旧节点数组中的每个元素,并查询是否可以在keyToNewIndexMap中查询到一样的节点,如果找不到,说新节点数组中没有该旧节点,直接卸载,否则进行打补丁更新

js 复制代码
const patchKeyedChildren = (oldChildren, newChildren, el) => {
 // ....省略上面代码
//-----乱序比对-----
// 从乱序的起点开始
  let s1 = i
  let s2 = i
  const keyToNewIndexMap = new Map()//用于保存新乱序子级中的元素的下标
  for (let i = s2; i <= e2; i++) {
    keyToNewIndexMap.set(newChildren[i].key, i)
  }
  //console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]
  
  //循环乱序旧子级,看看新子级存不存在该元素,存在就添加到列表中复用,否则删除
  for (let i = s1; i <= e1; i++) {//注意i从s1开始,也是就是乱序的第一个元素开始,减少循环次数
    const oldchild = oldChildren[i]//根据下标获取到旧乱序中的元素
    //查找新元素的map中能否找到旧元素
    let newIndex = keyToNewIndexMap.get(oldchild.key)
    if (newIndex == undefined) {//不存在该元素
      unmount(oldchild)//多余的删掉
    } else {
      patch(oldchild, newChildren[newIndex], el)//如果存在就比对子级差异
    }
  }
}

节点移动

经过乱序比对后,vue内部已经可以将多余的旧节点删除,并更新相同的节点。但是此时仍然存在两个问题:

1、新增的节点没有挂载,如H

2、对相同的节点虽然进行了复用更新,但只是在原位置上更新,没有移动。即,复用的节点的内容已经更新,但是位置还是原来旧节点的位置。

所以接下来的任务就是新增节点和移动节点

思路:

1、获取乱序的个数,以此为长度新建一个数组--newIndexToOladIndexMap,用于记录相同元素在旧节点数组中的位置。在循环旧节点数组时遇见相同元素就更新数组。0代表找不到相同元素,即,新增

2、倒序循环,从乱序中最后一个新元素开始,判断此时newIndexToOladIndexMap的值:为0,则新增;否则移动到对应的

js 复制代码
const patchKeyedChildren = (oldChildren, newChildren, el) => {
  //....省略上面代码
//-----乱序比对-----

  let s1 = i
  let s2 = i
  const keyToNewIndexMap = new Map()
  for (let i = s2; i <= e2; i++) {
    keyToNewIndexMap.set(newChildren[i].key, i)
  }
  //console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]

  //循环乱序旧子节点,看看新子节点存不存在该元素,存在就添加到列表中复用,否则删除
  const toBePatched = e2 - s2 + 1 //新乱序总个数
  //根据乱序个数创建数组并赋值为0,记录是否比对过映射表
  const newIndexToOldIndexMap = new Array(toBePatched).fill(0)//[0,0,0,0]
  for (let i = s1; i <= e1; i++) {//循环旧乱序子级
    const oldchild = oldChildren[i]//根据下标获取到旧乱序中的元素
    //查找新元素的map中能否找到旧元素
    let newIndex = keyToNewIndexMap.get(oldchild.key)
    if (newIndex == undefined) {
      unmount(oldchild)//多余的删掉
    } else {
      newIndexToOldIndexMap[newIndex - s2] = i + 1 //标识
      patch(oldchild, newChildren[newIndex], el)//如果存在就比对子级差异
    }
  }
  //到这只是新旧比对,没有移动位置
  //console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]
  //console.log(newIndexToOldIndexMap);//[5, 3, 4, 0]

  //需要移动位置
  for (let i = toBePatched - 1; i >= 0; i--) {//倒叙插入
    let index = i + s2 //找到当前元素在newChildren中的下标
    let current = newChildren[index] //找到newChildren元素
    //找到元素的下个元素作为参照物
    let anchor = index + 1 < newChildren.length ? newChildren[index + 1] : null 
    // newIndexToOldIndexMap[i]为0,表示旧节点中没有可复用的元素,即,新增,如H
    if (newIndexToOldIndexMap[i] === 0) {
      patch(null, current, el, anchor)//第一个元素传入null,表示要创建元素并根据参照物插入
    } else {
      hostInsert(current.el, el, anchor.el)//存在el直接根据参照物插入
    }
  }
}

1、进行前后对比完,s1=2,s2=2,i=2,keyToNewIndexMap[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}],newIndexToOldIndexMap[0,0,0,0]

2、for (let i = s1; i <= e1; i++)遍历旧乱序,i为2,oldchild = oldChildren[2],旧节点为C

3、从keyToNewIndexMap[C]映射表中查找key是否有C,所以找到返回下标:newIndex=3

4、开始标识newIndexToOldIndexMap[newIndex - s2]=i+1;那就是newIndexToOldIndexMap[1]=3

5、此时keyToNewIndexMap{e:2,c:3,d:4:h5},newIndexToOldIndexMap[0,3,0,0],两个映射表对比,是不是表明了C被标识了

6、为什么要i+1呢?如果第一个元素就已经乱序了,那么i就为0;为0表示没有patch过,这样就无法辨别了,这里为了防止,所以+1

7、最后newIndexToOldIndexMap[5,3,4,0]映射keyToNewIndexMap[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}],这样我们是不是知道只有H需要创建真实节点了

快速diff的利器--最长递增子序列

按照上面的思路,其实我们已经可以将旧节点更新为新节点了。但是有一个问题,乱序中的每一个节点都会有一次操作,要么删除、要么新增、要么移动。删除和新增没得啥说的,必须得有一次操作,但是每个元素是否真的都需要一次移动呢?实际是没有必要的。比如上面我们只需要新增H,并将E移动到C的前面两步就可以完成乱序的操作。而要实现最简操作,就要引入我们的主角---最长递增子序列。最长递增子序列的作用就是记录不用移动的节点,从而减少DOM节点的操作。

思路:

1、初始化一个result数组存放结果索引,遍历数组:

  • 如果当前元素大于 result 最后一个元素对应的值,直接加入 result
  • 否则,使用二分查找+贪心算法在 result 中找到第一个比当前元素大的元素并替换,并使用一个p数组记录前驱节点索引。

此时result得到的递增子序列虽然长度是最长的,但是贪心算法会替换元素导致索引顺序与原始数组不一致,即顺序是错误的。

2、前驱追溯。vue通过从后往前遍历,通过result和p两个数组,修正result的顺序,通过记录每个节点的前驱,从后向前回溯,可以恢复正确的递增顺序。

至此vue3的diff算法相关的内容就都解释清楚了。

总结

最后回顾下vue3的快速diff算法的整个过程:

1、头尾对比,缩小diff的乱序比较范围

2、循环新节点序列,构建key和索引的Map--keyToNewIndexMap

3、仅循环旧节点的乱序子序列,通过寻找是否在keyToNewIndexMap中,构建newIndexToOladIndexMap,记录新节点在旧节点中的索引,没有找到则设为0。

4、更新节点,包括删除、新增、打补丁。

4、构建最长递增子序列,确定需要移动的节点

相关推荐
草巾冒小子35 分钟前
element-ui图片查看器
前端·vue.js·ui
光影少年1 小时前
vue3为什么不需要时间切片
前端·vue.js
Json_1 小时前
Vue 初识Hello word
前端·vue.js·深度学习
Shi_haoliu1 小时前
各种网址整理-vue,前端,linux,ai前端开发,各种开发能用到的网址和一些有用的博客
linux·前端·javascript·vue.js·nginx·前端框架·pdf
逆袭的小黄鸭1 小时前
理解 JavaScript 的 this:核心概念、常见误区与改变指向的方法
前端·javascript·面试
一城烟雨_1 小时前
vue-office 支持预览多种文件(docx、excel、pdf、pptx)预览的vue组件库
vue.js·pdf·excel
Json_1 小时前
Vue 内置组件 -slot讲解
前端·vue.js·深度学习
工业互联网专业1 小时前
基于springboot+vue的校园数字化图书馆系统
java·vue.js·spring boot·毕业设计·源码·课程设计·校园数字图书馆系统
祯民2 小时前
阿民解锁了"入职 30 天跑路"新成就
前端·面试
小米渣aa2 小时前
Vue & React
前端·javascript·vue.js