深入学习vue系列 —— Diff

380 阅读6分钟

要了解Diff的源码,首先要搞明白Diff中出现的一些函数,这里称为辅助函数


节点操作函数

Diff在比较节点时,更新DOM会使用到的一些函数

主要有以下三个函数:

  • insert => 把节点插入到某个位置

  • creatElm => 创建DOM节点

-createChildren => 创建DOM节点,但是处理的是一个数组,并会创建DOM节点和文本节点

逐一介绍以上三个函数的使用方法

insert

这个函数的作用个就是插入节点,但是插入节点也会分为两种情况。

  • 没有参考兄弟节点,直接插入父节点的子节点末尾

  • 有参考兄弟节点,则插入在兄弟节点前面

function insert(parent, elm, ref){
	if(parent){
		// 判断时候有兄弟节点
    	if(ref){
        	if(ref.parentNode === parent){
            	parent.insertBefore(elm, ref)
            }
        }else{
    		parent.appendChild(elm)
    	}
    }
}

createElm

创建节点,创建完节点之后,会调用insert去插入节点

function createElm(vnode, parentElm, refElm){
	var children = vnode.children;
    var tag = vnode.tag;
    // 判断创建文本节点还是普通节点。vnode.tag为undefined 创建文本节点
    if(tag) {
    	vnode.elm = document.createElement(tag);
        // 先把子节点插入vode.elm,然后再把vnode.elm 插入parent
       	// 该DOM节点可能存在子节点,甚至子孙节点。所调用createChildren来创建
        createChildren(vnode, children)
        // 插入DOM节点
        insert(parentElm, vnode.elm, refElm)
    		
    }else{
    	vnode.elm = document.createTextNode(vnode.text);
        insert(parentElm, vnode.elem, refElm)
    }
}

createChildren

这个方法处理子节点,必然是用遍历递归的方法逐个处理。以下两种情况。

  • 如果子节点是数组,则遍历执行createElm逐个处理

  • 如果子节点的text属性有数据,则表示这个vnode是个文本节点,直接创建文本节点,然后插入到父节点中

function createChildren(vnode, children){
	if(Array.isArray(children)){
    	for(var i = 0;i < children.length; ++i){
        	createElm(children[i], vnode.elm, null)
        }
    }else if(
    	typeof vnode.text === 'string' ||
        typeof vnode.text === 'number' ||
        typeof vnode.text === 'boolean' ||
    ){
    	vnode.elm.appendChild(
        	document.createTextNode(vnode.text)
        )
    }
}

服务Diff工具函数

以下几个函数时专门用来服务Diff的

  • createKeyToOldIdx

  • sameVnode

createKeyToOldIdx

接收一个children数组,生成key与index索引对应的一个map表

function createKeyToOldIdx(children, beginIdx, endIdx){
	var i, key;
    var map = {}
	for(i = beginInx; i<= endIdx; ++i){
    	key = children[i].key;
        if(key){
        	map[key] = i
        }
    }
    return map
}

这个函数在Diff中的作用是:

判断某个新vnode是否在这个旧vnode数组中,并且拿到它的位置。就是拿到新vnode的key,然后去这个map表中去匹配,是否有响应的节点,有的话,返回这个节点的位置。

使用旧vnode数组生成一个map对象obj,当obj[newVnode.key]存在的时候,说明新旧子节点数组都存在这个节点。并且拿到该节点在旧节点数组中的位置。

sameVnode

它的作用是判断两个节点是否相同(关键数据性是否一样)

function sameVnode(a, b){
	return (
    	a.key === b.key &&
        a.tag === b.tag && 
        !!a.data === !!b.data &&
        sameInputType(a, b)
    )
}

function sameInputType(a, b){
	if(a.tag !== 'input') return true
    var i;
    var types = [
    	'text', 'number', 'password',
		'search', 'email', 'tel', 'url'
	]
    var typeA = (i = a.data) && (i = i.attrs) && i.type;
    var typeB = (i = b.data) && (i = i.attrs) && i.type;
    // input 的类型一样,或者都属于基本input类型
    return(
    	typeA === typeB ||
    	types.indeOf(typeA) > -1 &&
        types.indeOf(typeB) > -1
    )
}

判断的依据主要是三点,tag, key, 是否存在data'。这里判断的节点只是相对于节点本身,也就是说,data和children不一样。两个节点还是可能一样。

sameInputType是对特殊情况input额外处理。



对Diff的深入理解

首先我们先回忆一下Diff是何时被调用的?_update方法的第一个参数是一个VNode对象,在内部会将该VNode对象与之前旧的VNode对象进行patch

也就是说但使用patch方法的时候核心就是Diff算法

diff算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n),是一种相当高效的算法。

Diff 作用:

减少更新量,找到最小差异部分DOM,只更新差异部分DOM

Diff 做法:

Vue 只会对新旧节点中 父节点是相同节点 的 那一层子节点 进行比较

而下图中,只有两次比较,就是因为在 蓝色方 比较中,并没有相同节点,所以不会再进行下级子节点比较

Diff 比较逻辑:

  • 能不移动,尽量不移动

  • 没得办法,只好移动

  • 实在不行,新建或删除

Diff 简单例子:

比如下图存在这两棵 需要比较的新旧节点树 和 一棵 需要修改的页面 DOM树

第一轮比较开始

因为父节点都是 1,所以开始比较他们的子节点 按照我们上面的比较逻辑,所以先找 相同 && 不需移动 的点 毫无疑问,找到 2 拿到比较结果,这里不用修改DOM,所以 DOM 保留在原地 第二轮比较开始

然后,没有 相同 && 不需移动 的节点 了 只能第二个方案,开始找相同的点 找到 节点5,相同但是位置不同,所以需要移动 拿到比较结果,页面 DOM 树需要移动DOM 了,不修改,原样移动 第三轮比较开始

继续,哦吼,相同节点也没得了,没得办法了,只能创建了 所以要根据 新Vnode 中没找到的节点去创建并且插入 然后旧Vnode 中有些节点不存在 新VNode 中,所以要删除 于是开始创建节点 6 和 9,并且删除节点 3 和 4

Diff涉及的一个重要函数就是

createPatchFunciton

var patch = createPatchFunction();

Vue.prototype.__patch__ =  patch
function createPatchFunciton(){
	return function patch(
    	oldVnode, vnode, parentElm, refElm
    ){
    	// 没有旧节点,直接生成新节点
        if(!oldvNode){
        	createElm(vnode, parentElem, refElm)
        }else{
        	// 判断新旧节点是否相同
        	if(sameVnode(oldVnode, vnode)){
            	patchVnode(oldVnode, vnode)
            }else{
				// 替换存在的元素
            	var oldElm = oldVnode.elm;
                var _parentElm = oldElm.parentNode
                // 创建新节点
                creatElm(vnode, _parentElm, oldElm.nextSibling);
                // 销毁旧节点
                if(_parentElm){
                	removeVnodes([oldVnode], 0, 0)
                }
            }
        }
        return vonde.elm
    }
}

上述代码解析:

这个函数的作用就是比较新旧节点有什么不同,在对应的状态时处理对应的函数。

处理流程:

  • 没有旧节点

没有旧节点,说明页面刚开始初始化的时候,直接全部新建节点。

  • 旧节点和新节点自身一样(不包含子节点)

sameVnode函数判断节点是否相同,对比两个节点的tag和key。不确定children是否相同,此时调用patchVnode函数处理。具体处理内容后面分析patchVnode。

  • 旧节点和新节点自身不一样 创建新节点。删除旧节点。

patchVnode函数解析

function patchVnode(oldVnode, vnode){
	if(oldVnode === vnode) return
    var elm = vnnode.elm = oldVnode.elm;
    var oldCh = oldVnode.children;
    var ch = vnode.children
    // 更新children;
    if(!vnode.text){
    	//新旧节点都存在children
    	if(oldCh && ch){
        	if(oldCh ! == ch){
            	updateChildren(elm, oldCh, ch);
            }
        // 新节点存在children
        }else if(ch){
        	if(oldVnode.text) elm.textContent = '';
            for(var i = 0; i<= ch.length - 1; ++i){
            	createElm(ch[i], elm, null)
            }
        // 旧节点存在children
        }else if(oldCh){
        	for(var i = 0; i <= oldCh.length - 1; ++i){
            	oldCh[i].parentNode.removeChild(el);
            }
        }else if(oldVnode.text){
        	elm.textContent = ''
        }
    }else if(oldVnode.text !== vnode.text){
    	elm.textContent = vnode.text;
    }
}

上述代码解析:

这个函数的主要作用就是遍历比较处理子节点

处理流程:

  • Vonde为文本节点

    • 新节点与旧节点不相等 elm.textContent = vnode.text

    • 新节点没有文本节点 elm.textContent = ''

    textContent为真实DOM的属性。

  • Vonde存在子节点

    • 新节点存在子节点 旧节点不存在子节点(只有新节点)

    只有新节点没有旧节点,没有什么可以比较的,调用createElm方法新建DOM节点,并插入到父节点中。

    • 旧节点存在子节点 新节点不存在子节点(只有旧节点)

    只有旧节点,没有新节点,说明更新后的页面,旧节点全部都不见了,那么需要做的就是把旧节点直接删除。

    • 新节点与旧节点都存在子节点而且不一样

    调用Diff核心方法 updateChildren遍历,新子节点和旧子节点一个个去比较。一样就不更新,不一样就更新。

updateChildren函数解析

function updateChildren(parentElm, oldCh, newCh){
	let oldStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    
    let newStarIdx = 0;
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];
    
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm;
    
    // 不断地更新oldIndex 和 oldVnode, newIndex 和 newVnode
    while(
    	oldStartIdx <= oldEndIdx &&
        newStartidx <= newEndIdx
    ){
    	if(!oldStartVonde){
        	oldStartVnode = oldCh[++oldStartIdx];
        }else if(!oldEndVnode){
			oldEndVnode = oldCh[--oldStartIdx]       
            
        // 旧头 与 新头 比较
        }else if(sameVnode(oldStartVnode, newStartVnode)){
        	// 继续处理这两个相同节点的子节点,或者更新文本
        	patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
            
        // 旧尾 与 新尾 比较
        }else if(sameVnode(oldEndVnode, newEndVnode)){
        	patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldStartIdx];
            newEndVnode = newCh[--newStartIdx];
            
        // 旧头 与 新尾 比较
        }else if(sameVnode(oldStartVnode, newEndVnode)){
        	patchVnode(oldEndVnode, newEndVnode);
            // oldStartVnode 放到oldEndVnode 后面,还要找到oldEndValue后面的节点
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newStartIdx];
        
        // 旧尾 与 新头 比较
        }else if(sameVnode(oldEndVnode, newStartVnode)){
        	patchVnode(oldEndVnode, newEndVnode);
            // oldEndVnode 放到  oldStartVnode 前面
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            oldEndVnode = oldCh[--oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
 
 		// 单个新子节点 在 旧子节点数组中 查找位置
        }else{
        	// oldKeyToIdx是一个吧Vnode的key和index转换的map
            if(!oldKeyToIdx){
            	oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            }
            // 使用newStartVnode 去oldMap中寻找相同节点,默认可以存在
            idxInOld = oldKeyToIdx[newStartVnode.key]
            // 新孩子中,存在一个新节点,老节点中没有,需要新建
            if(!idxInOld){
            	// 把newStartVnode插入oldStartVnode的前面
            	createElm(newStartVnode, parentElm, oldStartVnode.elm)
            }else{
            	// 找到oldCh中和newStartVnode一样的节点
                vnodeToMove = oldCh[idxInold];
                if(samevnode(vnodeToMove, newStartVnode)){
                	patchVnode(vnodeToMove, newStartVnode);
                    // 删除这个index
                    oldCh[idxInOld] = undefined
                    // 把vnodeToMOve 移动到 oldStartVnode前面
                    parentElm.insertBefore(vnodeToMOve.elm, oldStartVnode.elm)
                // 只能创建一个新节点插入到 parentElm的子节点中
                }else{
                	createElm(newStartVnode, parentElm, oldStartVnode.elm)
                }
            }
            // 这个新子节点更新完毕,更新newStartIdx,开始比较下一个
            newStartVnode = newCh[++newStartIdx];
        }
    }
    // 处理剩下的节点
    if(oldStartIdx > oldEndIdx){
    	var newEnd = newCh[newEndIdx + 1]
        refElm = newEnd ? newEnd.elm : null;
        
        for(;newStartIdx <= newEndIdx; ++newStartIdx){
        	createElm(newCh[newStartIdx], parentElm, refElm)
        }
    // 说明新节点比对完了,老节点可能还有,需要删除剩余的老节点
    }else if(newStartIdx > newEndIdx){
		for(;oldStartIdx <= oldEndIdx; ++oldStartId){
        	oldCh[oldStartIdx].parentNode.removeChild(el);
        }
    }
}

上述代码解析:

该函数处理的是新子节点和旧子节点,循环遍历逐个比较。

  • 使用while循环遍历
  • 新旧节点数组都配置守卫两个索引

以两边向中间保卫的形式来遍历。只要其中一个数组遍历完,则结束遍历。

处理流程:

源码处理的流程分为两个:1、比较新旧子节点。2、比较完毕,处理剩下的节点

  • 比较新旧子节点

一、比较更新计划步骤

  • 首先考虑,不移动DOM

  • 其次考虑,移动DOM

  • 最后考虑,新建 / 删除 DOM

  • 能不移动,尽量不移动。 不行就移动,实在不行就新建

二、五种比较逻辑如下

  • 旧头 == 新头 sameVnode(oldStartVnode, newStartVnode)

当两个新旧的两个头一样的时候,并不用做什么处理

**更新索引位:**新旧节点索引指向后移。

  • 旧尾 == 新尾 sameVnode(oldEndVnode, newEndVnode)

和 头头 相同的处理是一样的

尾尾相同,直接跳入下个循环

**更新索引位:**新旧节点索引指向前移

  • 旧头 == 新尾 sameVnode(oldStartVnode, newEndVnode)

同样不符合 不移动DOM,也只能 移动DOM 了

把 oldStartVnode 的 dom 放到 oldEndVnode 的后面

但是因为没有把dom 放到谁后面的方法,所以只能使用 insertBefore

parentElm.insertBefore( oldStartVnode.elm, oldEndVnode.elm.nextSibling);

**更新索引位:**旧节点索引指向后移,新节点索引指向前移。

  • 旧尾 == 新头 sameVnode(oldEndVnode, newStartVnode)

同样不符合 不移动DOM,也只能 移动DOM 了

把 oldEndVnode DOM 直接放到 当前 oldStartVnode.elm 的前面

parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);

**更新索引位:**旧节点索引指向前移,新节点索引指向后移。

  • 单个查找

生成旧子节点数组以 vnode.key 为key 的 map 表这个map 表的作用,就主要是判断存在什么旧子节点拿到新子节点数组中一个子项,判断它的key是否在上面的map中

oldKeyToIdx[newStartVnode.key]

判断 新子节点是否存在旧子节点数组中不存在,则新建DOM

createElm(newStartVnode, parentElm, oldStartVnode.elm);

直接创建DOM,并插入oldStartVnode 前面

存在,继续判断是否 sameVnode 找到这个旧子节点,然后判断和新子节点是否 sameVnode

如果相同,直接移动到 oldStartVnode 前面

如果不同,直接创建插入 oldStartVnode 前面

  • 比较完毕,处理剩下的节点

新子节点遍历完了 newStartIdx > newEndIdx

新子节点遍历完毕,旧子节点可能还有剩所以我们要对可能剩下的旧节点进行 批量删除!

旧子节点遍历完了 newStartIdx > newEndIdx

旧子节点遍历完毕,新子节点可能有剩剩余的新子节点不存在 旧子节点中,所以全部新建

整体流程

而且我们的比较处理的宗旨是

1、能不移动,尽量不移动

2、没得办法,只好移动

3、实在不行,新建或删除

现在Vue 需要更新,存在下面两组新旧子节点,需要进行比较,来判断需要更新哪些节点

1、头头比较,节点一样,不需移动,只用更新索引 更新索引,newStartIdx++ , oldStartIdx++

开始下轮处理

2、一系列判断之后,【旧头 2】 和 【 新尾 1】相同,直接移动到 oldEndVnode 后面

更新索引,newEndIdx-- ,oldStartIdx ++

开始下轮处理

3、一系列判断之后,【新头 2】 和 【 旧尾 1】相同,直接移动到 oldStartVnode 前面

更新索引,oldEndIdx-- ,newStartIdx++

开始下轮比较

4只剩一个节点,走到最后一个判断,单个查找 找不到一样的,直接创建插入到 oldStartVnode 前面

更新索引,newStartIdx++

此时 newStartIdx> newEndIdx ,结束循环

5 批量删除可能剩下的老节点

此时看 旧 Vnode 数组中, oldStartIdx 和 oldEndIdx 都指向同一个节点,所以只用删除 oldVnode-4 这个节点