Vue中的Diff算法

概述

什么是DIff算法
主要针对:新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,用于比较的算法叫作Diff算法。

Diff算法的分类

  • 简单Diff算法
  • 双端Diff算法 vue2
  • 快速Diff算法 vue3

DOM-diff的过程

  1. 用JS对象模拟DOM(虚拟DOM)
  2. 把此虚拟DOM转成真实DOM并插入页面中(render)
  3. 如果数据发生了变化,生成新的虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff)
  4. 把差异对象应用到真正的DOM树上(patch)

Diff算法的特点

  • 比较只会在同层级进行, 不会跨层级比较
  • 深度优先遍历

前置知识

当数据发生改变,视图如何更新?
当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher(组件),订阅者们就会调用patch方法,给真实DOM打补丁,更新相应的视图。
在这里插入图片描述
只有三种类型的节点会被创建并插入到DOM中

在这里插入图片描述

  • 元素节点 vnode中是否有tag属性
  • 注释节点 vnode中isComment=true
  • 文本节点 如果上述判断都不是则是文本节点

patch方法

patch方法的作用
1.对比两个vnode之间的差异
2.修改DOM节点,创建新增的节点、删除已经废弃的节点、修改需要更新的节点

function patch(oldVnode, newVnode) patch方法的过程
1.当oldVnode不存在时,直接使用vnode渲染视图
2.当oldVnode和vnode都存在但并不是同一个节点时,使用vnode创建的DOM元素替换
3.当oldVnode和vnode是同一个节点时,使用更详细的对比找出新旧两个节点不一样的地方对真实的DOM节点进行更新。

比较是否为同一个节点

function sameVnode(oldVnode, newVnode) {
  return (
    oldVnode.key === newVnode.key && // key值是否一样
    oldVnode.tagName === newVnode.tagName && // 标签名是否一样
    oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
    isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
    sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
  )
}

更新节点的流程
在这里插入图片描述
更新子节点 --Diff算法
Diff算法只关注新旧虚拟节点都存在一组子节点的情况。

简单Diff算法

Diff算法只关注新旧虚拟节点都存在一组子节点的情况。

旧的一组子节点长度 oldLen
新的一组子节点长度 newLen

  1. 选择短的长度进行遍历,与另外一组节点进行比较,更新改变了的子节点
  2. 剩下多余的节点,newLen>oldLen说明有新子节点应该被挂载,oldLen>newLen说明有旧子节点应该被卸载
function patchChildren(n1,n2,container){
	if(typeof n2.children === 'string'){
		//...
	}else if(Array.isArray(n2.children)){
		const oldChildren = n1.children;
		const newChildren = n2.children;
		const oldLen = oldChildren.length;
		const newLen = newChildren.length;
		const commonLength = Math.min(oldLen,newLen);
		for(let i=0;i<commonLength;++i){
			patch(oldChildre[i],newChildren[i]);//逐步更新子节点 
		}
		if(newLen>oldLen){//说明有新子节点需要挂载
			for(int i=commonLength;i<newLen;++i){
				patch(null,newChildren[i],container);
			}
		}else if(newLen<oldLen){//说明有旧子节点需要卸载
			for(int i=commonLength;i<oldLen;++i){
				unmount(oldChildren[i]);
			}
		}
	}
	else{
		//...
	}
}

patch将vnode渲染成真实的DOM
1.对比vnode之间的差异
2.创建、删除、修改节点

  1. 引入key属性判断虚拟节点是否可以复用
    存在问题:只会比较相同下标的子节点是否相同来判断这个子节点是否可以复用,如果相同的子节点在数组中的顺序不同是不会被复用的。
    解决办法:通过DOM的移动来完成更新。
    那怎么判断哪些节点可以复用 --> 引入key属性来作为vnode的标识,key是唯一的
    只要两个虚拟节点的type属性和key属性都相同,就可以进行DOM复用

但是并不是DOM可复用就意味着不更新了,仍然是需要调用patch函数进行打补丁

const oldVnode = {type:'p',key:1,children:'text1'};
const newVnode = {type:'p',key:1,children:'text2'};

思路:遍历新的子节点数组,挨个去旧的子节点数组中根据key属性找可以复用的子节点,然后需要调用patch函数进行补丁。
如果没有可以复用的,说明需要创建。

function patchChildren(n1,n2,container){
	if(typeof n2.children === 'string'){
		//...
	}else if(Array.isArray(n2.children)){
		const oldChildren = n1.children;
		const newChildren = n2.children;	
		for(let i=0;i<newChildren ;++i){
			const newVNode = newChildren[i];
			for(let j=0;j<oldChildren.length;j++){
				const oldVNode = oldChildren[i];
				if(newChildren.key === oldChildren.key){
					patch(oldVNode,newVNode,container);
					break; //在旧的中找到了,就去找下一个新的
				}
			}
		}

	}
	else{
		//...
	}
}

存在问题: 更新之后的真实DOM依旧保持旧的一组子节点的顺序,因为我们并没有改变顺序。

  1. 如何判断一个节点是否需要移动?
    思路:在旧children中寻找具有相同key值的过程中,不断更新记录遇见最大的索引值lastIndex。如果在寻找过程中,存在索引值比lastIndex小的节点,说明该oldVnode对应的真实DOM节点需要移动
function patchChildren(n1,n2,container){
	if(typeof n2.children === 'string'){
		//...
	}else if(Array.isArray(n2.children)){
		const oldChildren = n1.children;
		const newChildren = n2.children;
		let lastIndex = 0; //存储寻找过程中遇到的最大索引值		
		for(let i=0;i<newChildren ;++i){
			const newVNode = newChildren[i];
			for(let j=0;j<oldChildren.length;j++){
				const oldVNode = oldChildren[i];
				if(newChildren.key === oldChildren.key){
					patch(oldVNode,newVNode,container);
					//判断oldVNode对应的真实DOM是否需要移动
					if(j<lastIndex){
						//说明oldVNode对应的真实DOM是否需要移动
					}else{
					   lastIndex = j; //更新最大索引值
					}
					
					break; 
				}
			}
		}

	}
	else{
		//...
	}
}
  1. 如何移动?移动真实DOM
    我们怎么获取到真实DOM? 当一个虚拟节点被挂载后,其对应的真实DOM节点会存储在它vnode.el属性中。
    移动真实DOM到哪里?新children的顺序就是更新后真实DOM节点应该有的顺序。所以如果需要移动真实DOM,移动到新children中它前一个节点的真实DOM的后面就可以了。 –选择的插入方式是插入到前一个节点的真实DOM的下一个节点的前面
function patchChildren(n1,n2,container){
	if(typeof n2.children === 'string'){
		//...
	}else if(Array.isArray(n2.children)){
		const oldChildren = n1.children;
		const newChildren = n2.children;
		let lastIndex = 0; 
		for(let i=0;i<newChildren ;++i){
			const newVNode = newChildren[i];
			for(let j=0;j<oldChildren.length;j++){
				const oldVNode = oldChildren[i];
				if(newChildren.key === oldChildren.key){ //如果存在复用 newChildren.el = oldChildren.el
					patch(oldVNode,newVNode,container);
					if(j<lastIndex){
						const preVNode = newChildren[i-1];//获取前一个vnode
						if(preVNode){ 
							const anchor = preVNode.el.nextSibling; //真实DOM的下一个兄弟
							insert(newVNode.el,container,anchor); //封装的insertBefore
							/*
							新真实的DOM节点移动到preVNode的真实DOM的后面
							--> newVNode.el移动到preVNode.el的下一个兄弟节点的前面
							*/
						}
					}else{
					   lastIndex = j;
					}
					
					break; 
				}
			}
		}

	}
	else{
		//...
	}
}

insert函数封装了insertBefore

insert(el,parent,anchore=null){
		parent.insertBefore(el,anchor)		
}
  1. 添加新增节点
    之前考虑的都是数组长度相同的情况,现在假设新子节点添加了新元素。
    我们需要考虑:①找到新增节点 --key ②将新增节点挂载到正确位置 --前一个虚拟DOM对应的真实DOM的后面
function patchChildren(n1,n2,container){
	if(typeof n2.children === 'string'){
		//...
	}else if(Array.isArray(n2.children)){
		const oldChildren = n1.children;
		const newChildren = n2.children;
		let lastIndex = 0; 
		for(let i=0;i<newChildren ;++i){
			const newVNode = newChildren[i];
			let find=false; //find表示是否在旧子节点中找到了可以复用的
			for(let j=0;j<oldChildren.length;j++){
				const oldVNode = oldChildren[i];
				if(newChildren.key === oldChildren.key){ 
					find = true;//找到了
					patch(oldVNode,newVNode,container);
					if(j<lastIndex){
						const preVNode = newChildren[i-1];
						if(preVNode){ 
							const anchor = preVNode.el.nextSibling; 
							insert(newVNode.el,container,anchor); 
						}
					}else{
					   lastIndex = j;
					}
					break; 
				}
			}
			//说明没找到 
			if(!find){
				const preVNode = newChildren[i-1];
				const anchor = null;
				if(preVNode) anchore =  preVNode.el.nextSibling; 
				else anchore = container.firstChild; //说明是第一个子节点,使用容器元素的firstChild作为锚点
				//挂载新的newNode节点
				patch(null,newVNode,container,anchor) //container 虚拟子节点的真实父元素?
			}
		}

	}
	else{
		//...
	}
}

patch函数

//anchor为锚点元素
function patch(n1,n2,container,anchor){
	//....
	if(typeof type === 'string'){
		if(!n1){ //挂载n2到container容器的anchor锚点之前
			mountElement(n2,container,anchor)
		}esle{
			patchElement(n1,n2);
		}
	}
	//else .....
}

function mountElement(vnode,container,anchor){
	//...
	insert(el,container,anchor)
}
  1. 移除不存在的节点
    思路:当更新结束后,遍历旧的子节点,去新的一组子节点中寻找具有相同key值的节点,如果找不到说明该旧子节点没有被复用,应该删除该节点。
function patchChildren(n1,n2,container){
	if(typeof n2.children === 'string'){
		//...
	}else if(Array.isArray(n2.children)){
		const oldChildren = n1.children;
		const newChildren = n2.children;
		let lastIndex = 0; 
		for(let i=0;i<newChildren ;++i){
			//	.....
		}
		for(let i=0;i<oldChildren.length;i++){
			const oldVnode = oldChildren[i];
			const has = newChildren.find(vnode=> vnode.key === oldVnode.key); //寻找旧子节点是否在新的子节点中被复用
			if(!has){//删除该节点
				unmount(oldVnode);
			}
		}
	}
	else{
		//...
	}
}

总结

为了避免过多的DOM元素进行销毁和重建,简单Diff算法的思路是:通过key找到可以复用的节点,尽可能地通过移动DOM来完成更新。

  1. 如何通过key找到可以复用的节点
    先遍历newChildren,每一个新子节点根据key去oldChildren中找key相同的虚拟节点,如果找到了调用patch函数进行补丁。
  2. 如何移动真实DOM?
    先维护一个可复用的真实DOM最大索引值lastIndex。如果发现该节点可以复用,比较当前旧虚拟节点的索引与lastIndex, 索引<lastIndex旧节点对应的真实DOM需要移动。移动到上一个虚拟节点对应的真实DOM的后面。
    在这里插入图片描述
  3. 新增元素的处理
    使用一个find来标记新节点是否是复用节点,如果1完了之后还没有找到,那么就是新节点,将新节点挂载。
  4. 删除旧子节点
    遍历oldChildren,去newChildren中寻找key一样的虚拟节点,如果没有找到,说明该节点需要被卸载。

双端Diff算法 --vue2

简单Diff算法的缺点是: 对DOM的移动操作并不是最优的
双端算法的优势: 减少DOM移动的次数

双端Diff算法的思想:同时对新旧两组子节点的两个端点进行比较,并移动真实DOM

while oldStartIdx <=oldEndlex && newStartIdx<=newEndIdx
每一轮的比较分为4个步骤
在这里插入图片描述
假设①找到了复用,先调用patch函数进行打补丁,并不用对真实DOM进行移动操作。
更新索引值newStartIdx++、oldStartIdx++

假设②找到了复用,先调用patch函数进行打补丁,并不用对真实DOM进行移动操作。
更新索引值newEndIdx-- 、oldEndIdx--

假设③找到了,此时先调用patch函数进行打补丁,本来在头部的DOM节点现在需要到尾部去了,所以将oldStartIdx对应的真实DOM移动到oldEndIdx对应的真实DOM后面
更新索引值newEndIdx--、oldStartIdx+++

假设④找到了,此时先调用patch函数进行打补丁,需要将旧节点oldEndIdx对应的真实DOM移动到oldStartIndx虚拟节点所对应的真实DOM的前面。
更新索引值 newStartIdx++、oldEndIdx--

如果都没有找到
⑤ newStartVNode去oldChildren中找是否有可复用的节点。
–> 找到了,先调用patch继续打补丁,再将旧的子节点对应的DOM移动到oldStartIdx的前面。旧的子节点对应的真实DOM已经移动了,把旧的子节点置undefined(所以每次开始的时候会判断oldStartIdx和oldEndIdx指向的旧子节点是否被处理过,如果处理过直接跳过),newStartIdx++

新节点的处理
–> 没有找到,说明是该节点是新节点,将新节点挂载到头部,newStartIdx++

⑥ 如果出循环之后,oldStartIdx>oldEndIdx && newStartIdx <=newEndIdx,oldStartIdx>oldEndIdx表示旧子节点复用的节点已经处理完毕,说明[newStartIdx,newEndIdx]之间都是新子节点,逐一进行挂载到oldStartIdx对应的真实DOM节点前

在这里插入图片描述

移除不存在的节点
如果出循环之后,`oldStartIdx<=oldEndIdx && newStartIdx > newEndIdx,newStartIdx > newEndIdx 表示新子节点已经处理完毕,说明[oldStartIdx,oldEndIdx ]之间都是需要移除的节点,逐一进行卸载

快速Diff算法 --vue3

快速Diff算法:增加预处理,对于相同的前置节点和后置节点,由于它们在新旧两组子节点中的相对位置不变,所以不需要移动节点,但是仍然需要打补丁。 --更新key相同的节点

借鉴纯文本Diff算法的思路
纯文本Diff算法的思路
1.if(text1 ===text2)return 比较两段文本是否全等
2.处理两段文本相同的前缀和后缀,对于内容相同的部分,是不需要进行核心Diff操作。所以真正需要进行Diff操作操作的是vue和react

TEXT1: I use vue for app development
TEXT2: I use react for app development

快速Diff算法的预处理思路
对于相同的前置节点和后置节点,由于它们在新旧两组子节点中的相对位置不变,所以不需要移动节点,但是仍然需要打补丁。 --更新key相同的节点

实现-使用两个循环,一个处理前置节点j指针 ,一个处理后置节点oldEnd和newEnd
1.前置节点,从头开始对比新旧子节点,key相同就打补丁,key不同就退出循环
2.后面节点,从尾开始往前对比新旧子节点,key相同就打补丁,key不同就退出循环

循环结束后可能出现的三种情况
1.oldEnd<j &&newEnd >=j,oldChildren已经遍历完,但是newChildren还没有遍历完,说明[j,newEnd]之间的都是需要挂载的节点。把他们依次挂载到newChildren[newEnd+1].el真实DOM之前(调用patch)
2.oldEnd>=j && newEnd<j,newChildren已经遍历完,但是oldChildren还没有遍历完,说明[j,oldEnd]之间是需要卸载的节点。依次卸载。

更复杂情况
3.oldEnd>=j && newEnd>=j newChildren和oldChildren都没有遍历完,需要判断是否有节点需要移动,应该如何移动?
策略是:最长递增子序列算法

  • 创建一个source数组:source数组存储去掉前置节点和后置节点后的newChildren数组中的节点在oldChildren数组中对应节点的位置
    作用:** ①判断是否是新节点 ②推出递增子序列seq ③保存可复用的新节点的映射关系**
  • 利用简单Diff算法判断是否移动的逻辑
    先维护一个可复用的真实DOM最大索引值lastIndex。如果发现该节点可以复用,比较当前旧虚拟节点的索引与lastIndex, 索引<lastIndex旧节点对应的真实DOM需要移动。移动到上一个虚拟节点对应的真实DOM的后面。
  • 根据source数组推出递增子序列seq数组:优化用于排除不需要移动的节点
    seq数组存储的是source数组中最长递增子序列元素的索引,表示在newChildren中在前面的节点,在oldChildren中仍然在前面,说明该oldVNode对应的真实DOM不需要移动。
    作用:①判断哪些节点需要移动

vue2和vue3 Diff算法的区别

区别vue2vue3
核心算法双端比较算法去头尾的最长递增子序列算法
使用Diff的范围全量 Diff静态标记 + 非全量 Diff (静态标记指标记静态节点,不需要进行Diff)

当数据发生改变,视图如何更新?

可以提出更新的过程是异步的,可能出现数据已经变化,但是视图还没有更新的情况,引出nextTick

当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher(组件),订阅者们就会调用patch方法,给真实DOM打补丁,更新相应的视图。

patch方法先判断oldVnode与newVnode节点
在这里插入图片描述
当新旧vnode的子节点都是一组节点时,使用核心Diff算法

vue2的核心Diff算法
同时对新旧两组子节点的两个端点进行比较

while(新头索引<=新尾索引 && 旧头索引<=旧尾索引 ){[新头][旧头][新尾][旧尾][旧头][新尾][旧尾][新头]
	if(上述4个情况任意一个找到了)调用patch打补丁,移动真实DOM。更新对应的索引
	else {
		⑤遍历newChildren去oldChildren中找复用节点
		//先判断oldVnode是否为undefined,为undefined表示已经处理过了,直接跳过
		if(没找到)说明是新节点patch挂载
		else 找到了,patch打补丁,移动真实DOM,oldChildren中的该oldVnode置undefined,
		新头索引++
	}
}
//出循环的时候
如果oldChildren先遍历完,newChildren还没有遍历完。说明newChildren剩下的都是新子节点,依次挂载[新头索引,新尾索引]的节点
if(oldStartIdx>oldEndIdx && newStartIdx <=newEndIdx)
如果newChildren先遍历完,oldChildren还没有遍历完,说明oldChildren剩下的都是需要卸载的节点,依次卸载[旧头索引,旧尾索引]的节点
if(oldStartIdx<=oldEndIdx && newStartIdx >newEndIdx)

vue3的核心Diff算法
预处理+基于最长子序列的移动优化

同时对新旧两组子节点的两个端点进行比较

while(①新头旧头是否key同){
	调用patch打补丁,新头++,旧头++
}
while(②新尾旧尾是否key同){
	调用patch打补丁,新尾--,旧尾--
}
//循环结束后的三种情况
if(oldEnd<j &&newEnd >=j,oldChildren)已经遍历完,但是newChildren还没有遍历完,说明[j,newEnd]之间的都是需要挂载的节点,依次挂载。
if(oldEnd>=j && newEnd<j)newChildren已经遍历完,但是oldChildren还没有遍历完,说明[j,oldEnd]之间是需要卸载的节点,依次卸载。
if(oldEnd>=j && newEnd>=j){newChildren和oldChildren都没有遍历完,说明可以开始找复用节点

source[]:存储去掉前置节点和后置节点后的newChildren数组中的节点在oldChildren数组中对应节点的位置。 -1表示oldChildren数组中无对应节点,该newVNode是新节点。
作用: ①判断是否是新节点 ②推出递增子序列seq ③保存可复用的新节点的映射关系

简单Diff算法判断是否移动的逻辑
先维护一个可复用的真实DOM最大索引值lastIndex。如果发现该节点可以复用,比较当前旧虚拟节点的索引与lastIndex, 索引<lastIndex旧节点对应的真实DOM需要移动。移动到上一个虚拟节点对应的真实DOM的后面。
	if(索引<lastIndex){ 需要移动
		seq[]递增子序列,如果上一步的DOM需要移动,这里进一步优化,排除不需要移动的节点。
		seq数组存储的是source数组中最长递增子序列元素的索引,表示在newChildren中在前面的节点,在oldChildren中仍然在前面,说明该oldVNode对应的真实DOM不需要移动。
		作用:①判断哪些节点需要移动,oldVNode和seq[i]相等说明不需要移动,反之需要移动
	}
}
Logo

前往低代码交流专区

更多推荐