vue 核心原理之虚拟 DOM 和 Diff 算法的核心原理及实现

一、虚拟DOM和Diff 算法的核心原理
  1. diff算法可以进行精细化比对,实现最小量更新。
  2. 虚拟DOM:用JavaScript对象
    描述DOM的层次结构。DOM
    中的一切属性都在虚拟DOM中有对应的属性。
  3. diff是发生在虚拟DOM上的。新虚拟DOM和老虚拟DOM进行diff(精细化比较),算出应该如何最小量更新,最后反映到真正的DOM上。
  4. snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom。它的git地址是 https://github.com/snabbdom/snabbdom
  5. snabbdom 渲染函数,h 函数,如下所示:
  • h函数用来产生虚拟节点(vnode)
  • h函数可以嵌套使用,从而得到虚拟DOM
  • h函数用法很活
  1. diff 算法,如下所示:
  • 最小量更新的关键是 keykey是这个节点的
    唯一标识,告诉diff算法,在更改前后它们是同一个DOM节点。
  • 只有是同一个虚拟节点,才进行精细化比较,否则就是暴力删除旧的、插入新的。对于同一个虚拟节点,选择器相同且key相同。
  • 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,精细化比较不diff你,而是暴力删除旧的、然后插入新的。
  1. 同一个节点,旧节点的key要和新节点的key相同,并且旧节点的选择器要和新节点的选择器相同。
  2. diff处理新旧节点不是同一个节点时,如下所示:
  • patch 函数被调用,判断 oldVnode 是虚拟节点还是DOM节点。
  • 如果是虚拟节点,判断 oldVnodenewVnode 是不是同一个节点。如果是DOM节点,将 oldVnode 包装为虚拟节点,然后判断oldVnodenewVnode 是不是同一个节点。
  • 如果是同一个节点,精细化比较。如果不是同一个节点,暴力删除旧的、插入新的。
  1. 创建节点时,所有子节点需要递归创建的。
  2. diff处理新旧节点是同一个节点时,如下所示:
  • 判断oldVnodenewVnode是不是内存中的同一个对象。如果是就什么都不做。如果不是,判断 newVnode 有没有 text 属性。
  • 如果有 text 属性,判断newVnodetextoldVnode是否相同。如果没有 text 属性,意味着 newVnodechildren,判断 oldVnode 有没有 Children
  • 如果 oldVnodeChildren,最复杂的情况,就是新老vnode都有 children,此时就进行最优雅的 diff算法。如果 oldVnode 没有 Children,清空 oldVnode 中的 text,并且把 newVnode 中的 children 添加到 DOM 中。
  • 如果newVnodetextoldVnode相同,什么都不做。如果不同,把 elm 中的 innerText 改变为 newVnodetext,即使oldVnodechildren属性而没有text 属性,那么也没事儿,innertText 一旦改变为新的 text,老children 就没了。
  1. 对于新旧子节点的新增情况,新创建的节点(newVnode.children[i].elm)插入到所有未处理的节点(oldVnode.children[um].elm)之前,而不是所有已处理节点之后。
  2. 对于经典的diff算法优化策略,有四种命中查找,命中一种就不再进行命中判断了。如果都没有命中,就需要用循环来寻找了。移动到oldStartIdx之前。策略如下所示:
  • 新前与旧前
  • 新后与旧后
  • 新后与旧前,此种发生了,涉及移动节点,那么新前指向的节点,移动的旧后之后
  • 新前与旧后,此种发生了,涉及移动节点,那么新前指向的节点,移动的旧前之前
  1. 对于新增情况,旧子节点和新子节点,while(新前<=新后&&旧前<=就后){ }。如果是旧节点先循环完毕,说明新节点中有要插入的节点。
  2. 对于删除情况,旧子节点和新子节点,while(新前<=新后&&旧前<=就后){ }。如果是新节点先循环完毕,如果老节点中
    还有剩余节点,说明他们是要被删除的节点。
  3. 对于多删除的情况,旧子节点和新子节点,while(新前<=新后&&旧前<=就后){ }。如果是新节点先循环完毕,如果老节点中还有剩余节点(旧前和新后指针中间的节点),说明他们是要被删除的节点。
  4. 对于复杂的情况,旧子节点和新子节点,当新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面。当新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面。
二、虚拟DOM和Diff 算法的实现
  1. vnode,函数的功能非常简单,就是把传入的5个参数组合成对象返回,代码如下所示:
export default function(sel, data, children, text, elm) {
    const key = data.key;
    return {
        sel, data, children, text, elm, key
    };
}
  1. createElement,真正创建节点。将vnode创建为DOM,是孤儿节点,不进行插入,如下所示:
  • 创建一个DOM节点,这个节点现在还是孤儿节点,判断有子节点还是有文本。
  • 它内部是文字就赋值。它内部是子节点,就要递归创建节点,得到当前这个children。 创建出它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点,上树。
  • 补充elm属性,返回elmelm属性是一个纯DOM对象。
  1. createElement,代码如下所示:
export default function createElement(vnode) {
    let domNode = document.createElement(vnode.sel);
    if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
        domNode.innerText = vnode.text;
    } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
        for (let i = 0; i < vnode.children.length; i++) {
            let ch = vnode.children[i];
            let chDOM = createElement(ch);
            domNode.appendChild(chDOM);
        }
    }
    vnode.elm = domNode;
   
    return vnode.elm;
};
  1. h 函数,这个函数必须接受3个参数,缺一不可,相当于它的重载功能较弱,如下所示:
  • 调用的时候形态必须是下面的三种之一,形态① h('div', {}, '文字')。形态② h('div', {}, [])。形态③ h('div', {}, h())
  • 检查参数的个数,检查参数c的类型,说明现在调用h函数是形态①。
  • 说明现在调用h函数是形态②,遍历c,收集children,检查c[i]必须是一个对象,如果不满足。这里不用执行c[i],因为你的测试语句中已经有了执行。此时只需要收集好就可以了。循环结束了,就说明children收集完毕了,此时可以返回虚拟节点了,它有children属性的。
  • 说明现在调用h函数是形态③,传入的c是唯一的children。不用执行c,因为测试语句中已经执行了c
  1. h 函数,代码如下所示:
import vnode from './vnode.js';

export default function (sel, data, c) {
    if (arguments.length != 3)
        throw new Error('对不起,h函数必须传入3个参数,我们是低配版h函数');
    if (typeof c == 'string' || typeof c == 'number') {
        return vnode(sel, data, undefined, c, undefined);
    } else if (Array.isArray(c)) {
        let children = [];
        for (let i = 0; i < c.length; i++) {
            if (!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel')))
                throw new Error('传入的数组参数中有项不是h函数');
            children.push(c[i]);
        }
        return vnode(sel, data, children, undefined, undefined);
    } else if (typeof c == 'object' && c.hasOwnProperty('sel')) {
        let children = [c];
        return vnode(sel, data, children, undefined, undefined);
    } else {
        throw new Error('传入的第三个参数类型不对');
    }
};
  1. patchVnode,函数的功能是对比同一个虚拟节点,如下所示:
  • 判断新旧vnode是否是同一个对象,判断新vnode有没有text属性。
  • vnodetext属性,如果新虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可。如果老的elm中是children,那么也会立即消失掉。
  • vnode没有text属性,有children,判断老的有没有children。老的有children,新的也有children,此时就是最复杂的情况。
  • 老的没有children,新的有children。清空老的节点的内容,遍历新的vnode的子节点,创建DOM,上树。
  1. patchVnode,代码如下所示:
import createElement from "./createElement";
import updateChildren from './updateChildren.js';

export default function patchVnode(oldVnode, newVnode) {
    if (oldVnode === newVnode) return;
    if (newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
        console.log('新vnode有text属性');
        if (newVnode.text != oldVnode.text) {
            oldVnode.elm.innerText = newVnode.text;
        }
    } else {
        console.log('新vnode没有text属性');
        if (oldVnode.children != undefined && oldVnode.children.length > 0) {
            updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
        } else {
            oldVnode.elm.innerHTML = '';
            for (let i = 0; i < newVnode.children.length; i++) {
                let dom = createElement(newVnode.children[i]);
                oldVnode.elm.appendChild(dom);
            }
        }
    }
}
  1. patch,对比,如下所示:
  • 判断传入的第一个参数,是DOM节点还是虚拟节点。传入的第一个参数是DOM节点,此时要包装为虚拟节点。
  • 判断oldVnodenewVnode是不是同一个节点。插入到老节点之前,删除老节点。
  1. patch,代码如下所示:
import vnode from './vnode.js';
import createElement from './createElement.js';
import patchVnode from './patchVnode.js'

export default function patch(oldVnode, newVnode) {
    if (oldVnode.sel == '' || oldVnode.sel == undefined) {
        oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
    }

    if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
        console.log('是同一个节点');
        patchVnode(oldVnode, newVnode);
    } else {
        console.log('不是同一个节点,暴力插入新的,删除旧的');
        let newVnodeElm = createElement(newVnode);
        
        if (oldVnode.elm.parentNode && newVnodeElm) {
            oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
        }
        oldVnode.elm.parentNode.removeChild(oldVnode.elm);
    }
};
  1. updateChildren,判断是否是同一个虚拟节点,并且实现更新,如下所示:
  • 旧前、新前、旧后、新后、旧前节点、旧后节点、新前节点、新后节点。
  • 开始大while了,首先不是判断①②③④命中,而是要略过已经加undefined标记的东西。新前和旧前,新后和旧后。
  • 新后和旧前,当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面。移动节点,只要你插入一个已经在DOM树上的节点,它就会被移动。
  • 新前和旧后,当④新前和旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面。移动节点,要你插入一个已经在DOM树上的节点,它就会被移动。
  • 四种命中都没有命中,制作keyMap一个映射对象,这样就不用每次都遍历老对象了。从oldStartIdx开始,到oldEndIdx结束,创建keyMap映射对象。
  • 寻找当前这项(newStartIdx)这项在keyMap中的映射的位置序号。判断,如果idxInOldundefined表示它是全新的项。被加入的项(就是newStartVnode这项)现不是真正的DOM节点。
  • 如果不是undefined,不是全新的项,而是要移动。把这项设置为undefined,表示我已经处理完这项了。移动,调用insertBefore也可以实现移动。指针下移,只移动新的头。
  • 继续看看有没有剩余的。循环结束了start还是比old小。遍历新的newCh,添加到老的没有处理的之前。
  • insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM。批量删除oldStartoldEnd指针之间的项。
  1. updateChildren,代码如下所示:
import patchVnode from './patchVnode.js';
import createElement from './createElement.js';

function checkSameVnode(a, b) {
    return a.sel == b.sel && a.key == b.key;
};

export default function updateChildren(parentElm, oldCh, newCh) {
    console.log('我是updateChildren');
    console.log(oldCh, newCh);

    let oldStartIdx = 0;
    let newStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let newEndIdx = newCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];

    let keyMap = null;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        console.log('★');
        if (oldStartVnode == null || oldCh[oldStartIdx] == undefined) {
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (oldEndVnode == null || oldCh[oldEndIdx] == undefined) {
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (newStartVnode == null || newCh[newStartIdx] == undefined) {
            newStartVnode = newCh[++newStartIdx];
        } else if (newEndVnode == null || newCh[newEndIdx] == undefined) {
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
            console.log('①新前和旧前命中');
            patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
            console.log('②新后和旧后命中');
            patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            console.log('③新后和旧前命中');
            patchVnode(oldStartVnode, newEndVnode);
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            console.log('④新前和旧后命中');
            patchVnode(oldEndVnode, newStartVnode);
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        } else {
            if (!keyMap) {
                keyMap = {};
                for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                    const key = oldCh[i].key;
                    if (key != undefined) {
                        keyMap[key] = i;
                    }
                }
            }
            console.log(keyMap);
            const idxInOld = keyMap[newStartVnode.key];
            console.log(idxInOld);
            if (idxInOld == undefined) {
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
            } else {
                const elmToMove = oldCh[idxInOld];
                patchVnode(elmToMove, newStartVnode);
                oldCh[idxInOld] = undefined;
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
            }
            newStartVnode = newCh[++newStartIdx];
        }
    }

    if (newStartIdx <= newEndIdx) {
        console.log('new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStartIdx之前');
        for (let i = newStartIdx; i <= newEndIdx; i++) {
            parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
        }
    } else if (oldStartIdx <= oldEndIdx) {
        console.log('old还有剩余节点没有处理,要删除项');
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
            if (oldCh[i]) {
                parentElm.removeChild(oldCh[i].elm);
            }
        }
    }
};
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值