Vue之diff算法

前言

Vue通过双向绑定来实现数据驱动视图更新,当数据更新会触发Dep对象notify通知所有的订阅者Watcher对象,Watcher对象最后会调用run来执行Watcher对象的getter方法。
其中需要注意的是在挂载阶段创建的一个Watcher对象的getter就是用于updateComponent,其中最主要方法就是调用Vue.prototype._update,而该实例方法主要进行调用patch函数进行节点的diff比较。

patch函数逻辑

通过阅读Vue源码可知,patch函数的逻辑实际上主要就是比较新旧vnode创建相关的DOM,主要逻辑分为如下2点:

  1. 判断新的vnode是否存在,不存在做相关处理
  2. 判断oldVnode是否存在,不存在则意味着第一次挂载
新的vnode不存在

新的vnode不存在时,意味着当前页面应该是空的,此时执行的逻辑有2点:

  1. 判断oldVnode是否存在,存在调用其destorr生命周期销毁掉
  2. 直接return退出整个patch函数
oldVnode是否存在

oldVnode是否存在决定了处理逻辑的不同,当oldVnode不存在时,即意味着第一次挂载,处理逻辑只需要:

依据当前虚拟节点vnode来生成真实DOM,即调用createElm

当oldVnode存在时,实际上需要判断是SSR还是客户端渲染已作进一步的处理。

  var isRealElement = isDef(oldVnode.nodeType);
  if (!isRealElement && sameVnode(oldVnode, vnode)) {
     // patch existing root node
     patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
   } else {
   	// 其他的处理逻辑,实际上主要是调用createElm创建真实DOM
   }

本文关注的是patchVnode的具体逻辑,这里是diff算法的核心逻辑。

在具体关注diff算法前,看下sameVnode的逻辑,Vue diff处理的前提是:

客户端渲染 + 相同节点

那何谓相同节点?直接看源码:

  function sameVnode (a, b) {
    return (
    	a.key === b.key && (
	      (
	        a.tag === b.tag &&
	        a.isComment === b.isComment &&
	        isDef(a.data) === isDef(b.data) &&
	        sameInputType(a, b)
	      ) || (
	      	// 这里可以暂不考虑相关逻辑
	        isTrue(a.isAsyncPlaceholder) &&
	        a.asyncFactory === b.asyncFactory &&
	        isUndef(b.asyncFactory.error)
	      )
	    )
	)
  }

从上面代码可以得到Vue判断相同节点的逻辑:

  1. 虚拟节点vnode对象的key值相同
  2. 相同标签 + 是否是注释节点 + data都存在或都不存在 + 如果是input标签必须相同

diff比较算法

diff比较算法的核心逻辑都是在patchVnode函数中,具体逻辑如下:
在这里插入图片描述

Vue diff算法是按层来处理每一个节点的,而这里需要注意的逻辑就是子节点的处理,这里是关键:

  • 如果新旧vnode都存在子节点且不相同,会调用updateChildren来处理子节点的diff比较
  • 如果仅新vnode存在子节点,调用addVnodes添加相关节点
  • 如果仅旧vnode存在子节点,调用removeVnodes移除所有子节点内容
  • 如果都不存在子节点但旧vnode存在文本内容而新vnode不存在文本内容,设置新vnode内容为空字符串

updateChildren函数是实现层序比较的关键,而实际上层序diff的实现也是由于updateChildren内部调用了patchVnode函数,形成了递归调用。

updateChildren函数

该函数的逻辑实际上使用迭代来实现新旧节点数组的遍历比较,以做到尽可能复用节点的DOM即最小化DOM的创建。
其主要逻辑可以简述为:

/*
	- oldStartIndex、oldEndIndex:处理旧节点数组遍历的双指针参数
	- newStartIndex、newEndIndex:处理新节点数组遍历的双指针参数
*/
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
	if (isUndef(oldStartVnode)) {
		oldStartVnode = oldCh[++oldStartIdx];
     } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
     } else if (sameVnode(oldStartVnode, newStartVnode)) {
     	// patchVnode相关操作
     } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // patchVnode相关操作
     } else if (sameVnode(oldStartVnode, newEndVnode)) {
     	// patchVnode相关操作
     } else if (sameVnode(oldEndVnode, newStartVnode)) {
     	// patchVnode相关操作
     } else {
     	// 非上述情况的比较逻辑
     }
}
// 针对不同情况新增新元素或删除旧元素
if (oldStartIdx > oldEndIdx) {
	// 针对新节点数组元素addVnodes操作
} else if (newStartIdx > newEndIdx) {
   	// 针对旧节点数组元素removeVnodes
}

子组件的比较看似逻辑比较多,实际上逻辑可以分为2类:

  • 特殊逻辑:新旧首首比较、新旧尾尾比较、新首旧尾比较、新尾旧首比较
  • 正常逻辑顺序比较

实际上diff算法目的非常简单:

就是找到两个数组中相同节点值并复用它

其实现思路的核心在于:

新节点数组就是当前页面上需要显示的,以此为依据来对比新旧数组

假设自己去实现的话,常规的做法可以有:

// 实现1:以新节点数组为基础遍历,查找旧节点数组是否存在相同节点对象
for (let i = 0; i < newArray.length; i++) {
	for (let j = 0; j < oldArray.length; j++) {
		// 判断是否相同
	}
}
// 

进一步优化使用双指针方式来处理,可以减少遍历次数:

let newStartIndex = 0;
let newEndIndex = newArray.length - 1;
let newStartVnode, newEndVnode;
for (;newStartIndex <= newEndIndex;newStartIndex++, newEndIndex--) {
	newStartVnode = newArray[newStartIndex];
	newEndVnode = newArray[newEndIndex];
	for (let j = 0; j < oldArray.length; j++) {
		// 相关比较处理
	}
}

实际上这里还是存在优化空间的,即内部循环也可以采用双指针形式来实现,由此可以延伸到Vue diff算法的实现:新旧节点数组都采用双指针方式来遍历,而额外逻辑是如何更加高效的找到相同节点,为了更加高效Vue diff算法做了大概下面2点的优化:

  • 每一次遍历优先对首尾元素进行两两比较
  • 如果首尾两两比较判断不是相同节点,则会依据旧节点数组生成一个map来保存一定范围的组件key集合,便于进一步复用相同旧节点对象

Vue Diff算法去阅读理解上会比较抽象,而抽象的来源个人感觉是如何控制4个指针参数的变化来保证不会有比较上的遗漏。
从源码上去了解4个下标参数有如下5点的处理:

  • sameVnode(oldStartVnode, newStartVnode)

    新节点数组首元素与旧节点数组首元素比较,如果元素相同此时只处理newStartIndex和oldStartIndex,都是递增操作

  • sameVnode(oldEndVnode, newEndVnode)

    新节点数组尾元素与旧节点数组尾元素比较,如果元素相同此时只处理newEndIndex和oldEndIndex,都是递减操作

  • sameVnode(oldStartVnode, newEndVnode))

    新节点数组尾元素与旧节点数组首元素比较,如果元素相同此时newEndIndex递减,oldStartIndex递增

  • sameVnode(oldEndVnode, newStartVnode))

    新节点数组首元素与旧节点数组尾元素比较,如果元素相同此时oldEndIndex递减,newStartIndex递增

  • 正常逻辑的处理

    首尾元素比较没有相同,则按照正常的比较逻辑,以当前newStartIndex对应的下标开始顺序比较,递增newStartIndex

通过上面的拆解可以有一个大概的思路了,接下来通过一个案例来描述Vue Diff算法大概的过程:

// 旧节点数组old -> 新节点数组new
[a, b, c, d, i, g] -> [b, a, a, f, d]

开始执行diff比较:

  1. oldStartIndex = 0,oldEndIndex = 5,newStartIndex = 0,newEndIndex = 4

    此时a和b节点不满足首尾sameVNode的比较,所以执行正常顺序逻辑(这里会查找旧节点数组是否有b来复用,如果复用b后此时需要注意有一个额外逻辑,将old数组中b值对应下标对应的值设置为undefined),newStartIndex++,old数组变成了[a, undefined, c, d, i, g]

  2. oldStartIndex = 0, oldEndIndex = 5,newStartIndex = 1,newEndIndex = 4

    此时是old数组中第一个元素a 和 new数组中第2个元素a比较,满足首首相同的条件,此时oldStartIndex++,newStartIndex++

  3. oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 2,newEndIndex = 4

    此时old数组中第2个元素b 和 new数组中第3个元素a比较,不满足首尾的sameVnode条件,所以执行正常顺序逻辑(会再次复用a,此时需要注意有一个额外逻辑,将old数组中a值对应下标对应的值设置为undefined),此时newStartIndex++,而old数组变成了[undefined, undefined, c, d, i, g]

  4. oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 3,newEndIndex = 4

    此时old数组中第2个元素b和new数组中第4个元素f比较,不满足首尾sameVnode条件,执行正常顺序逻辑(查找旧数组中是否有f,没有则新创建一个节点f),newStartIndex++

  5. oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 4,newEndIndex = 4

    此时old数组中第2个元素b和new数组中第4个元素d比较,不满足首尾的sameVnode条件,所以执行正常顺序逻辑(查找旧节点数组中是否存在d,存在复用不存在则新建,存在的话会将old数组中d对应下标对应的值设置为undefined),newStartIndex++,此时old数组变成了[undefined, undefined, c, undefined, i, g]

  6. 循环结束

当实例循环结束时,相应的数组下标停留在:

  • oldStartIndex = 1,oldEndIndex = 5
  • newStartIndex = 5,newEndIndex = 4

当迭代都执行完后此时Vue Diff算法还有一个额外的逻辑执行:

if (oldStartIdx > oldEndIdx) {
	refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
	removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}

依据迭代结束后newStartIndex > newEndIndex,会执行旧节点数组相关节点移除动作:

上面实例迭代结后旧节点数组为[undefined, undefined, c, undefined, i, g]

可以看到都是没有使用的节点,执行removeVnodes函数,该函数的逻辑如下:

function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
  	var ch = vnodes[startIdx];
  	// 节点存在
    if (isDef(ch)) {
    	// 标签的话执行销毁逻辑
    	if (isDef(ch.tag)) {
        	removeAndInvokeRemoveHook(ch);
        	invokeDestroyHook(ch);
        } else { // Text node
           	removeNode(ch.elm);
        }
     }
   }
}

至此完成一层的迭代处理,而多层结构就是通过递归调用patchVnode来实现的,重复上面整个过程。

总结

Vue中的diff算法的核心逻辑就是同层比较VNode来实现复用VNode,其中key的作用是用在sameVnode方法中,用于标识是否是相同的虚拟节点:

function sameVnode (a, b) {
    return (
      a.key === b.key &&
      a.asyncFactory === b.asyncFactory && (
        (
          a.tag === b.tag &&
          a.isComment === b.isComment &&
          isDef(a.data) === isDef(b.data) &&
          sameInputType(a, b)
        ) || (
          isTrue(a.isAsyncPlaceholder) &&
          isUndef(b.asyncFactory.error)
        )
      )
    )
  }

从上面的逻辑中可知,新旧vnode只有key值相同,sameVnode的才会是true。而在Vue diff算法中,只有sameVnode为true才会进行下一层子节点的diff比较。
实际上如果不定义key值,vnode的key都是undefined,必然都是相等的。那么定不定义key值有什么意义呢?从两个方面思考:

  • 定义key值有什么好处,对于前后是不同的组件,通过key值比较就可以快速知道是进行下一层diff还是不diff直接创建真实DOM
  • 不定义key值,实际上内部会默认都是undefined,同层diff时就会导致相同标签的都被认为是sameVnode,必然会进行下一层diff,这样就会导致整个过程执行了许多不必要的处理逻辑,对于层次比较深的组件就必然产生不可忽视的性能问题

所以key值应该是固定唯一的来优化整个diff比较,才能很好的复用元素。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值