什么是虚拟DOM?

virtual DOM,用普通js对象来描述DOM结构,因为不是真实DOM,所以称之为虚拟DOM。

虚拟DOM在Vue中的应用
Vue的编译器在编译模板之后,会把这些模板编译成一个渲染函数。而函数被调用的时候就会渲染并且返回一个虚拟DOM的树。Vue的Virtual DOM Patching算法是基于Snabbdom的实现。
当我们有了这个虚拟的树之后,再交给一个Patch函数,负责把这些虚拟DOM真正施加到真实的DOM上。在这个过程中,Vue有自身的响应式系统来侦测在渲染过程中所依赖到的数据来源。在渲染过程中,侦测到数据来源之后就可以精确感知数据源的变动。到时候就可以根据需要重新进行渲染。当重新进行渲染之后,会生成一个新的树,将新的树与旧的树进行对比,就可以最终得出应施加到真实DOM上的改动。最后再通过Patch函数施加改动。
简单点讲,在Vue的底层实现上,Vue将模板编译成虚拟DOM渲染函数。结合Vue自带的响应系统,在应该状态改变时,Vue能够智能地计算出重新渲染组件的最小代价并应到DOM操作上。
Snabbdom虚拟DOM实现思路解读

以此html页面内容为例

body>
    <button id="btn">点击我改变</button>
    <div id="container"></div>
    <script src="/xuni/bundle.js"></script>
</body>
const container = document.getElementById('container');
const btn = document.getElementById('btn')
const myVnode1 = h('ul', {}, [
    h('li', {key: 'A'}, 'A'),
    h('li', {key: 'B'}, 'B'),
    h('li', {key: 'C'}, 'C'),
    h('li', {key: 'D'}, 'D'),
    h('li', {key: 'E'}, 'E')
]);
patch(container, myVnode1)
  1. 解读h函数
    h函数就是vue中的createElement方法,它是用来创建虚拟DOM,追踪DOM变化,最终返回虚拟DOM,即js封装好的节点对象。
    h函数的简略分析如下:

    /**  低配版本的h函数,这个函数必须接收3个参数,缺一不可
     相当于它的重载功能较弱
     也就是说,调用的时候形态必须是以下三种之一:
     形态①:h('div', {}, '文字')
     形态二:h('div', {}, [])
     形态三:h('div', {}, h())
    */
    export default function(sel, data, c) {
     // 检查参数的个数
     if (arguments.length !== 3) {
         throw new Error('h函数必须传入3个参数,低配版h函数');
     }
     // 检查参数c的类型
     if (typeof c === 'string' || typeof c === 'number') {
         // 说明现在调用h函数就是形态①
         return vnode(sel, data, undefined, c, undefined);
     } else if (Array.isArray(c)) {
         // 说明现在调用H函数就是形态②
         let children = []
         // 遍历c,收集children
         for (let i = 0; i < c.length; i++) {
             // c[i] 必须是一个对象,如果不满足
             if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) {
                 throw new Error('传入的数组参数中有项不是h函数')
             }
             // 这里不用执行c[i],因为测试语句中已经调用了,既是执行h函数了
             //此时只需要收集好就可以了
             children.push(c[i])
         }
         return vnode(sel, data,children, undefined, undefined)
     } else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
         // 说明是调用h函数形态③
         // 即传入的c是唯一的孩子,直接存入children
         let children = [c];
         return vnode(sel, data, children, undefined, undefined)
     } else {
         throw new Error('传入的第三个参数类型不对')
     }
    }

    vNode函数就是用来规范h中传入的参数,将传入的参数data中的属性key提出来,作为返回值对象中的一个属性,返回整合之后的对象

    // 函数的功能非常简单,就是把传入的5个参数组合成对象再返回
    export default function(sel, data, children, text, elm) {
     const key = data.key;
     return {sel, data, children, text, elm, key}
    }

    调用h函数后得到的myVnode1打印如下图:

  1. 解读patch函数
    也就是patch算法,在patch函数中传入新旧两个节点,比较两者的不同,如相同,则进一步进行精细化比较,否则,在老节点之前插入新节点,并且删除旧节点,这个过程也是将虚拟DOM渲染到真实DOM中,最终视图更新。
``patch.js
export default function (oldVnode, newVnode) {
    // 判断传入的第一个参数,是dom节点还是虚拟节点
    if (oldVnode.sel === '' || oldVnode.sel == undefined) {
        // 传入的第一个参数是dom节点,此时要包装为虚拟节点
        oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
    }
    // console.log(oldVnode)

    // 判断oldv和newv是不是同一个节点
    if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
        // 是则进行精细化比较
        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)
    }
}

其中createElement函数就是将虚拟dom转换为真正dom的处理方法。

/**真正创建节点,将vnode创建为dom,插入到pivot之前
*/
export default function createElement (vnode) {
    // console.log('目的是把虚拟节点', vnode, '真正变为dom');
    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++) {
            // 得到当前这个children
            let ch = vnode.children[i];
            // console.log(ch)
            // 创建出它的dom,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点
            let chDOM = createElement(ch)
            domNode.appendChild(chDOM)
        }
        // vnode.elm = domNode
    }
    // 补充elm属性
    vnode.elm = domNode
    // 返回elm,elm是一个纯dom
    return vnode.elm;
}


调用createElement函数之后生成真正的dom,最后dom节点通过insertBefore方法指定到旧节点之前上树。

  1. 精细化比较-解读patchVnode函数
    此种情况的前提是新旧节点都存在,且新旧节点唯一标识key相同,标签名sel相同。
    patchVnode函数中主要比较新旧虚拟节点中是否有text属性,是否有子节点children
    1)若新节点中有text,与旧节点相比较,若不同,直接替换;即使旧节点没有text而是有children,也直接替换,若相同不做处理;
    2)若新节点中有子节点children,此时需要判断旧节点有无子节点children,若旧节点也有子节点,则需要更进一步子节点更新策略updateChildren,若旧节点没有子节点,则清空老节点的内容,遍历新的子节点,创建dom,上树
// 对比同一个虚拟节点
export default function patchVnode (oldVnode, newVnode) {
    // 判断新旧vnode是否是同一个对象
    if (oldVnode === newVnode) return;
    // 判断新vnode没有没text属性
    if (newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length === 0)) {
        console.log('新vnode有text属性')
        if (newVnode.text !== oldVnode.text) {
            // 如果新虚拟节点中的text和老的虚拟节点text不同,那么直接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立即消失
            oldVnode.elm.innerText = newVnode.text
        }
    } else {
        // 新vnode没有text属性,有children
        console.log('新vnode没有text属性')
        if (oldVnode.children != undefined && oldVnode.children.length > 0) {
            // 老的有children,新的也有children,就是最复杂的情况,就是新老都有children
            updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
        } else {
            console.log('oldVnode', oldVnode)
            // 老的没有children,新的有children
            updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
            // 清空老的节点的内容
            oldVnode.elm.innerHtml = '';
            // 遍历新的vnode的子节点,创建dom,上树
            for (let i = 0; i < newVnode.children.length; i++) {
                let dom = createElement(newVnode.children[i]);
                oldVnode.elm.appendChild(dom)
            }
        }
    }
}
  1. 子节点更新策略updateChildren函数解读
    上一步中提到的新老节点都有子节点时,则需要进一步的子节点比较,大体来说就是更新节点,新增节点,删除节点,移动节点,这个函数中利用旧节点开始标记,结束标记,新节点开始标记,结束标记四个指针位置的移动进行指针所指位置上的元素比较来处理什么情况下更新节点,什么情况下新增节点。

    export default function updateChildren (parentElm, oldCh, newCh) {
     console.log('我是updateChildren,oldCh', oldCh)
     console.log('newCh', 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了
     while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
         // 首先不是判断①②③④命中,而是要略过已经加undefined标记的东西
         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(createElement(oldStartVnode))
             // 如何移动节点?只要你插入一个已经在dom树上的节点,它就会被移动
             parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
             oldStartVnode = oldCh[++oldStartIdx]
             newEndVnode = newCh[--newEndIdx]
         } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
             // 新前与旧后
             console.log('④新前与旧后命中');
             patchVnode(oldEndVnode, newStartVnode)
             // 当④新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
             // 如何移动节点?只要你插入一个已经在dom树上的节点,它就会被移动
             parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
             oldEndVnode = oldCh[--oldEndIdx]
             newStartVnode = newCh[++newStartIdx]
         } else {
             // 都没有找到
             // 寻找key的map
             if (!keyMap) {
                 keyMap = {};
                 // 从oldStartIdx开始,到oldEndIdx结束,
                 for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                     const key = oldCh[i].key;
                     if (key != undefined) {
                         keyMap[key] = i
                     }
                 }
             }
             console.log(keyMap)
             // 寻找当前这项(newStartIdx)这项在keyMap中的映射的位置序号
             const idxInOld = keyMap[newStartVnode.key];
             console.log(idxInOld);
             if (idxInOld == undefined) {
                 // 判断,如果idxInOld是undefined表示它是全新的项
                 // 被加入的项(就是newStartVnode这项)现在不是真正的dom节点
                 parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
             } else {
                 // 如果不是undefined,不是全新的项,而是要移动
                 const elmToMove = oldCh[idxInOld];
                 patchVnode(elmToMove, newStartVnode); // 老的在前,新的在后
                 // 把这项设置为undefined,表示已经处理完这项了
                 oldCh[idxInOld] = undefined;
                 // 移动,调用insertBedore也可以实现移动
                 parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
             }
             // 指针下移,只移动新的头
             newStartVnode = newCh[++newStartIdx];
             // newStartIdx++;
         }
     }
     // 继续看看有没有剩余的,循环结束了start还是比old小
     if (newStartIdx <= newEndIdx) {
         console.log('new还有剩余节点没有处理,要加项。要把所有的剩余节点插入到oldSartVnode之前')
         // 这时需要看当前的子节点,作为插入的标杆
         // let before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
         // console.log(before)
         // 遍历新的newch,添加到老的没有处理的之前
         for (let i = newStartIdx; i <= newEndIdx; i++) {
             // insertBefore方法可以自动识别null,如果是null就会自动排到队尾去,和appendChild是一致的了
             // newCh[i] 现在还没有真正的dom,所以要调用createElement()函数变为dom
             parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
         }
     } else if (oldStartIdx <= oldEndIdx) {
         console.log('old还有剩余节点没有处理,说明需要删除节点,(要删除项)')
         // 批量删除oldStart和oldEnd指针之间的项
         for (let i = oldStartIdx; i <= oldEndIdx; i++) {
             if (oldCh[i]) {
                 parentElm.removeChild(oldCh[i].elm)
             }
    
         }
     }
    }
    function checkSameVnode (a, b) {
     return a.sel === b.sel && a.key === b.key
    }

    新前新的没有处理的虚拟节点的开头,旧前就是旧的没有处理的虚拟节点;
    新后就是新的没有处理的虚拟节点的最后一个,旧后就是旧的没有处理的虚拟节点的最后一个;
    以上代码总结来说就是子节点更新策略的四种命中方式:
    ①新前与旧前
    ②新后与旧后
    ③新后与旧前
    ④新前与旧后
    命中一种就不再进行命中判断了
    节点的操作分为以下几种,通过几个例子说明:
    1)新增的情况

    新前节点与旧前节点进行对比是不是同一个节点,如果是同一个节点,那就不是新增也不是删除,而是更新,不需要进行节点移动操作,而是更新节点,此时命中①;旧前与新前同时指针下移一位,再次比较,新前等于新后或旧前等于旧后循环结束
    While(新前 <= 新后 && 旧前<=就后) {
    }
    如果是旧节点先循环完毕,而新节点还有剩余,则说明新节点中剩余节点是需要被新增的
    2)删除的情况

    A,B旧前与新前比较,相同,此时旧前指针指向D,新前指针指向D,没有命中,此时开启新后与旧后(命中②)比较,旧后的D与新后的D比较,命中之后,两个后节点往上走,此时旧后指向C,新后指向B,破坏了循环规则(前<后),结束循环,此时新节点循环完毕
    如果是新节点先循环完毕,如果旧节点中还有剩余节点,说明它们是要被删除的节点
    3)多删除的情况

    旧前与新前开始对比到B,此时旧前指针指向C,新前指针指向D,没有命中,此时开启新后与旧后(命中②)比较,旧后的E与新后的D比较,没有命中,此时开启新后与旧前的比较(命中③),新后的D与旧前的C比较,没有命中,此时开启新前与旧后(命中④)比较,新前的B与旧后的C比较,仍然没有命中
    如果是新节点先循环完毕,如果老节点中还有剩余节点(旧前和新后指针中间的节点,说明他们是要被删除的节点)
    4)删除且重新排序

    当④新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点老节点的旧前前面
    5)倒序重新排列

    当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点老节点的旧前后面
    如果都没有命中,就需要用循环来寻找了,循环旧节点,如果新节点中没有,就将旧节点中此个设为undefined
    四判定还有剩余项:
    ①如果是旧节点先循环完毕,说明新节点中有要插入的节点
    ②如果是新节点先循环完毕,如果老节点中还有剩余节点,说明他们是要被删除的节点
    以上就是diff算法分析我个人的学习与解读了,总结下来就是:先通过h函数创建虚拟dom,然后调用patch函数进行新旧虚拟DOM的粗略比较是否为同一个节点,若是暴力插入新的,删除旧的节点;不是同一个节点则进行精细化比较patchVnode,以新节点为参照,先看新节点是不是单纯的dom节点(即有text无children),若是则更新旧的节点内容为新的节点text属性值;若不是(即有children),再看旧的节点是否有子节点,若没有,则将新节点的子节点循环插入到旧节点中;若旧节点也有子节点,那么对于新旧所有的子节点开启新一轮的精细化比较updateChildren,即子节点更新策略(利用js的指针思想以及四种命中方式),比较完之后将需要更新的节点上树,追加到真实的DOM上。
    完整代码:虚拟dom与diff算法

03-05 21:25