Vue源码剖析(二):实现DIFF算法

DIFF算法应该是Vue框架中比较核心的一点了,框架采用DIFF算法对抽离的新旧虚拟DOM进行比较,尽可能复用页面也有的真实DOM,找出不同的DOM进行补丁或者添加新的DOM操作。总之,DIFF算法的原则就是尽可能少的操作DOM,即是操作DOM,也尽可能的进行优化。

  • 首先先简述下具体的实现思路:
    在这里插入图片描述
  • 如图,虚拟DOM的对比主要分五种情况:
  1. 当新旧虚拟DOM的首元素相同是,将新旧DOM的首指针加一,向后移动一次继续比较新旧DOM的当前首指针指向的元素,如果不同执行下一种比较
  2. 当新旧虚拟DOM不满足第一种比较时,进行尾部元素的对比,如果尾元素相同(可重用),则将新旧DOM尾指针减一,继续进行比较,否则进入下一种比较
  3. 当前两种比较均不满足时,将旧的DOM的第一个元素和新的DOM的最后一个元素比较,如果相同,执行相应的操作后,旧的DOM首指针加一,新的DOM的尾指针减一,继续下次比较,否则执行下一种比较
  4. 当前三种比较都不满足时,则将旧DOM的尾部元素和新的DOM的头部元素比较,相同则执行相应的操作后,将旧的DOM尾指针减一,新的DOM的头指针加一,继续比较
  5. 如果前四种比较都不满足,则表示整个新DOM和旧的DOM差异较大,则从新的DOM队列开始,依次取出相应的元素,同时在旧的DOM中寻找是否有相同的元素,如果有则执行移动位置的操作,若没有,则创建一个新的节点插入到旧的DOM的首部
  • 五种比较的结束条件是新旧DOM中有一个的首指针大于了尾指针,则循环比较结束,此时可能有一个队列并未循环完毕,如果是新DOM,则将为比较到的DOM元素创建插入到老的真实DOM的尾部,如果是老的DOM未比较完毕,则将未比较的老的DOM元素删除。 (整个DIFF算法比较的时间复杂度为O(n))
现在开始编码实现DIFF算法:

(算法实现的过程会在代码中以注释的形式说明)

// 1. 这里先说明一下我们这里的虚拟DOM中包含的内容
{
       _type: VNODE_TYPE, // 标记当前元素为一个虚拟DOM元素
       type,     // 元素类型,及标签名称
       key,      // 标签上传入的key值,用于判断新旧DOM是否相同
       props,    // DOM元素的属性列表
       children, // 当前元素的子元素列表,若没有,则为空
       text,     // 当前元素的文本内容,如果是文本节点则是文本信息,否则为undefined
       domElement  // 指向当前DOM挂载对应的真实DOM的指针,通过它可以对真实DOM进行操作
  }
// 定义一些辅助方法:
/**
* 判断两个DOM元素是否相同(并非完全相同,只要标签和key一样便可以进行重用,便是相同)
*/
export function isSameNode(oldVnode, newVnode) {
    // 如果连个虚拟DOM的节点的key一样,并且类型也一样,说明是同一种节点,可以进行深度比较
    return oldVnode.key === newVnode.key && oldVnode.type === newVnode.type;
}

/**
 * 根据一个虚拟dom节点创建真实dom节点,这里创建节点会执行后一次递归,将当前虚拟DOM下的所有的子元素一并创建
 * @param {object} vnode 虚拟DOM节点
 */
 function createDOMElementFromVnode(vnode) {
    let { type, children } = vnode;
    if (type) {
        // 创建真实dom元素,同时挂载到vnode上的domElement上
        let domElement = vnode.domElement = document.createElement(type);
        // updateProperties()函数的作用:更新当前节点的属性列表,直接将vnode传入即可,后面将会定义
        updateProperties(vnode);
        if (Array.isArray(children)) {
        	// 执行递归,将当前元素下的所有子节点一并创建
            children.forEach(child => domElement.appendChild(createDOMElementFromVnode(child)))
        }
    } else {
    	// 到这一步,说明节点是一个文本节点,则直接创建一个文本节点挂载到虚拟DOM的domELememt属性上即可
        vnode.domElement = document.createTextNode(vnode.text)
    }
    return vnode.domElement
}

/**
* 此方法的作用: 用于更新正是DOM上的属性,使得真实DOM可以重用,不一样之处只需要打补丁即可
* 实现思路:
* 1. 由于DOM的属性中样式属性较为特殊,则单独处理: 如果旧的DOM中有的样式在新的DOM中没有的,清除旧的DOM样式, 其余的只需要覆盖添加即可
* 2. 如果在新的DOM中没有旧的DOM上的属性,直接将属性从DOM删除
* 3. 将新的DOM属性列表的元素添加到真实DOM上,如果存在,则被覆盖,不存在则添加
*
* 参数: 1: 当前更新的虚拟DOM元素, 2. 旧的DOM的属性列表,如果是初次挂载,则不用传
*/
function updateProperties(vnode, oldProps = {}) {
    let newProps = vnode.props;  // 新的属性对象
    let domElement = vnode.domElement; // 真实dom
    let oldStyle = oldProps.style || {};  // 旧的样式列表
    let newStyle = newProps.style || {};  // 新的样式列表
    // 如果style属性在旧的样式对象里有,在新的中没有,则需要将老的样式对象清除
    for (let oldAttrName in oldStyle) {
        if (!newStyle[oldAttrName]) {
            domElement.style[oldAttrName] = ''
        }
    }
    // 将老的属性对象中有的,新的属性对象中没有的属性从真实DOM上删掉
    for (let oldPropsName in oldProps) {
        if (!newProps[oldPropsName]) {
            delete domElement[oldPropsName];
        }
    }

    // 添加新的属性到真实DOM上:(覆盖 & 新增)
    for (let newPropsName in newProps) {
        if (newPropsName === "style") {
            let styleObject = newProps.style; // 拿到新的样式对象往旧的样式对象中添加
            for (let newAttrName in styleObject) {
                domElement.style[newAttrName] = styleObject[newAttrName]
            }
        } else {
            domElement[newPropsName] = newProps[newPropsName]
        }
    }
}

// 创建子元素节点的key到索引值之间的映射
// 作用: 用于之后DIFF比较的时候依靠key值取得当前子元素在父元素中的索引位置,便于准确的插入到相应的地方
function createKeyToIndexMap(children){
    let map = {};
    for(let i = 0; i < children.length; i++){
        let key = children[i].key;
        if(key){
            map[key] = i;
        }
    }
    return map;
}

// 开始新旧DOM对比:
// 参数: 旧的虚拟DOM,新的虚拟DOM
function patch(oldVnode, newVnode) {
    //1.  如果新的虚拟DOM和老的虚拟DOM的根元素的type不同,直接重建所有子元素
    if (oldVnode.type !== newVnode.type) {
        oldVnode.domElement.parentNode.replaceChild(createDOMElementFromVnode(newVnode), oldVnode.domElement)
    }
    // 如果当前节点是一个文本节点, 直接更新文本内容
    if (typeof newVnode.text !== "undefined") {
        return oldVnode.domElement.textContent = newVnode.text;
    }

    // 如果类型一样,深入向下比较: 1. 比较属性, 2. 比较子节点
    // 将老的父节点保存,同时对老的dom节点的属性进行补丁,减少dom节点创建的性能消耗
    let domElement = newVnode.domElement = oldVnode.domElement;
    // 传入新的dom节点和老的props属性,将老的dom节点的属性进行更新
    updateProperties(newVnode, oldVnode.props)

    let oldChildren = oldVnode.children;  // 取得老的dom节点的子节点
    let newChildren = newVnode.children;  // 取得新的dom节点的子节点
    if (oldChildren.length > 0 && newChildren.length > 0) {
        // 比较新旧子节点: 核心,该方法会在下面定义,实现思路就是上面所述的DIFF五种比较
        updateChildren(domElement, oldChildren, newChildren)
    } else if (oldChildren.length > 0) {
    	// 旧的节点上有子节点,新的没有,直接删除
        domElement.innerHTML = "";
    } else if (newChildren.length > 0) {
    	// 旧的父节点上没有子节点,新的上有,直接创建挂载节点到当前的父节点上(及之前保存的domElement)
        for (let i = 0; i < newChildren.length; i++) {
            domElement.appendChild(createDOMElementFromVnode(newChildren[i]))
        }
    }
}
/**
* 核心代码:比较新旧DOM树的子节点
* 参数1: 当前比较的父节点的真实DOM节点指针
* 参数2: 老的虚拟DOM子节点
* 参数3: 新的虚拟DOM子节点
*/
function updateChildren(parentDomElement, oldChildren, newChildren) {
    // 思路: 创建四个指针,分别指向老的开始索引,老的结束索引, 新的开始索引, 新的结束索引
    // 比较时从两边向中间进行比较,跟新相同的节点的属性,或者插入新的节点
    let oldStartIndex = 0, oldStartVnode = oldChildren[0]; // 老的开始索引和开始节点
    let oldEndIndex = oldChildren.length - 1, oldEndVnode = oldChildren[oldEndIndex]; // 老的结束索引和结束节点

    let newStartIndex = 0, newStartVnode = newChildren[0]; // 新的开始索引和开始节点
    let newEndIndex = newChildren.length - 1, newEndVnode = newChildren[newEndIndex]; // 新的结束索引和结束节点

    let oldKeyToIndexMap = createKeyToIndexMap(oldChildren);  // 创建老的节点队列元素的key和index之间的映射
	
	// 开始循环,直到新老虚拟DOM的任意一方遍历完毕及结束
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
        /**
         * 五种比较:
         * 1. 将老的开始和新的开始比较判断是否一样,一样将新老队列开始索引加一继续,否则进入下一种比较
         * 2. 将老的结束和新的结束比较,如果一样,将新老队列结束索引减一继续,否则进入下一种
         * 3. 将老的开始和新的结束比较,如果一样,老的开始加一,新的结束减一继续,否则进入下一种
         * 4. 将老的结束和新的开始比较,如果一样,老的结束减一,新的开始加一继续,否则做其他操作
         * 
         * 5. 如果都不满足,则表示节点混乱,则从新的队列的开始,在老的节点中找是否有相同的节点
         *    如果没有,则创建这个节点并插入到老的开始前,如果有,则取到老的真实的节点移动到老的开始前
         */
        if(!oldStartVnode){
        	// 跳过开始为undefined节点,因为在后面会将移动了位置的节点置为undefined
            oldStartVnode = oldChildren[++oldStartIndex];
        }else if(!oldEndVnode){
        	// 同理: 跳过结束索引指向为undefined节点
            oldEndVnode = oldChildren[--oldEndIndex];
        }else if (isSameNode(oldStartVnode, newStartVnode)) {
            // 老的开始和新的开始比较,递归调用patch更新老的真实dom的属性
            patch(oldStartVnode, newStartVnode)
            oldStartVnode = oldChildren[++oldStartIndex]; // 老的开始索引加一
            newStartVnode = newChildren[++newStartIndex]; // 新的开始索引加一
        } else if (isSameNode(oldEndVnode, newEndVnode)) {
            // 老的结束节点和新的结束节点比较,相同则调用patch更新
            patch(oldEndVnode, newEndVnode);
            oldEndVnode = oldChildren[--oldEndIndex]; // 老的结束索引减一
            newEndVnode = newChildren[--newEndIndex]; // 新的结束索引减一
        } else if (isSameNode(oldEndVnode, newStartVnode)) {
            // 将老的结束和新的开始比,如果相同,则将老的结束DOM移动到真实DOM的开始位置
            patch(oldEndVnode, newStartVnode);

            // dom移动: 将老的结束节点移动到老的开始位置
            parentDomElement.insertBefore(oldEndVnode.domElement, oldStartVnode.domElement)

            oldEndVnode = oldChildren[--oldEndIndex]; // 老的结束索引减一
            newStartVnode = newChildren[++newStartIndex]; // 新的开始索引加一
        } else if (isSameNode(oldStartVnode, newEndVnode)) {
            // 将老的开始和新的结束比,将老的开始的DOM移动到真实DOM的尾部
            patch(oldStartVnode, newEndVnode);

            // 进行Dom的移动:将老的真实DOM的开始移动到真实DOM的尾部
            parentDomElement.insertBefore(oldStartVnode.domElement, oldEndVnode.domElement.nextSibling)

            oldStartVnode = oldChildren[++oldStartIndex]; // 老的开始索引加一
            newEndVnode = newChildren[--newEndIndex];     // 新的结束索引减一
        }else{
            let oldIndexByKey = oldKeyToIndexMap[newStartVnode.key];
            if(!oldIndexByKey){
                // 从新的头开始,在老的DOM中找节点,如果没找到,则创建一个新的DOM插入老的开始索引的前面
                parentDomElement.insertBefore(createDOMElementFromVnode(newStartVnode), oldStartVnode.domElement);
            }else{
                // 如果找的了新的相同的DOM节点,则将其位置移动到老的开始索引的前面
                let oldVnodeToMove = oldChildren[oldIndexByKey]; // 从老的DOM子元素列表中取得需要移动的元素
                if(oldVnodeToMove.type !== newStartVnode.type){
                	// 增加一层判断,需要移动的节点和新的DOM的类型是否相同(因为在寻找的时候只是通过key进行的寻找,可能会有变动)
                	// 如果节点的类型不同,则重新创建新的节点插入
                    parentDomElement.insertBefore(createDOMElementFromVnode(newStartVnode), oldStartVnode.domElement)
                }else{
                	// 更新老的真实DOM的属性
                    patch(oldVnodeToMove, newStartVnode)
                    oldChildren[oldIndexByKey] = undefined; // 将移动了的节点置为undefined,下次比较到时直接跳过
                    parentDomElement.insertBefore(oldVnodeToMove.domElement, oldStartVnode.domElement);
                }
            }
            newStartVnode = newChildren[++newStartIndex];
        }
    }

    if (newStartIndex <= newEndIndex) {
        // 到这一步,说明是老的队列处理完了,新的没有处理完
        // 此时将新的虚拟DOM中剩下的加入到当前父节点的最后

        // 将这个节点插入到新的结束的索引对应的前一个元素的前面
        let beforeDOMElement = newChildren[newEndIndex + 1] === null ? null : newChildren[newEndIndex + 1].domElement;
        for (let i = newStartIndex; i <= newEndIndex; i++) {
            parentDomElement.insertBefore(createDOMElementFromVnode(newChildren[i]), beforeDOMElement);
        }
    }
    if(oldStartIndex <= oldEndIndex){
    	// 执行到这一步,说明是新的DOM树遍历完毕,需要将老的DOM中未作处理的多余的真实DOM节点删除
        for(let i = oldStartIndex; i <= oldEndIndex; i++){
            // 删除多余的老的节点
            parentDomElement.removeChild(oldChildren[i].domElement)
        }
    }

}

至此,Vue中的DIFF算法简单实现就OK了,主要流程是在updateChildren()这个方法里面,根据五种比较进行DOM的更新。 好了,大家 加油!!!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值