vue底层之虚拟dom和diff算法

155 阅读6分钟

虚拟DOM和diff算法

首先diff算法是解决改变DOM结构并且使开销最小化的一种方法。在vue中经常会发生DOM结构的改变,这个时候如果全部销毁重建则开销很大,效率低。

diff算法是基于虚拟DOM上进行的,虚拟DOM是DOM结构的数据化(所有DOM的信息,包括文本结构等都储存在这个对象中),因为DOM不利于进行计算等操作,所以转成这种对象形式的DOM来进行diff算法。由于是改变,所以有老和新的虚拟DOM。diff将以最小化开销把老虚拟DOM变成新虚拟DOM。

这部分内容不涉及将DOM转成虚拟DOM,这是属于模板编译的范畴。

snabbdom

import { init } from 'snabbdom/init';
import { classModule } from 'snabbdom/modules/class';
import { propsModule } from 'snabbdom/modules/props';
import { styleModule } from 'snabbdom/modules/style';
import { eventListenersModule } from 'snabbdom/modules/eventlisteners';
import { h } from 'snabbdom/h';

// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);

// 创建虚拟节点
const myVnode1 = h('a', {
    props: {
        href: 'http://www.atguigu.com',
        target: '_blank'
    }
}, '尚硅谷');

const myVnode2 = h('div', '我是一个盒子');

const myVnode3 = h('ul', [
    h('li', {}, '苹果'),
    h('li', '西瓜'),
    h('li', [
        h('div', [
            h('p', '哈哈'),
            h('p', '嘻嘻')
        ])
    ]),
    h('li', h('p', '火龙果'))
]);

console.log(myVnode3);

// 让虚拟节点上树
const container = document.getElementById('container');
patch(container, myVnode3);

可以发现h函数有三个参数(标签指定,属性对象(可省略),子虚拟节点),h函数用于生成一个虚拟节点(看参数可以发现不是由DOM直接生成),第三个参数是接收子虚拟节点,所以当然可以直接接收h嵌套,也可以接收子虚拟节点数组,也可以是文本。

三种形式:

//第一种 接收数组,代表着可以嵌套
h('selector',{},[h('selector',{},'1'),h('selector',{},'2'),h('selector',{},'3')])
//第二种 直接接受子虚拟节点
h('selector',{},h('selector',{},'4'))
//第三种 接受文本作为内容
h('selector',{},'text')

snabbdom的h函数

mySnabbdom.h  = function(sel,data,c) {
    //只接受三个参数类型
    if (arguments.length !== 3)
    throw TypeError('h函数只接受三个参数!');
    if (typeof c == 'string' ||typeof c == 'number'){
         return mySnabbdom.vnode(sel,data,undefined,c,undefined)
    }
    else if(typeof c == 'object' && c.hasOwnProperty('sel')){
        let children = [];
        children.push(c);
        return mySnabbdom.vnode(sel,data,children,undefined,undefined);
    }
    else if (Array.isArray(c)){
        let children = [];
        for (i=0;i<c.length;i++){
            let vnode = c[i];
            if (typeof vnode !== 'object' && !c.hasOwnProperty('sel'))
            throw TypeError('数组的项必须是vnode节点!');
            children.push(vnode);
        }
        return mySnabbdom.vnode(sel,data,children,undefined,undefined);
    }
    else{
        throw TypeError('参数类型有误!');
    }
}

diff算法原理

diff特点:

  • 只diff同一层次的虚拟节点
  • 虚拟节点的key很重要,在diff中代表着同一标识
  • 只对同一个虚拟节点,才会进行精细化比较(diff),判断的标准是(sel相同且key相同)

如果不进行diff,则暴力拆除旧的,直接插入新的。

总流程:

调用patch(oldVnode,newVnode): oldVnode是否为虚拟dom?不处理:通过vnode包装成虚拟dom -> oldVnode与newVnode是否为同一虚拟节点? 进行diff精细化比较(难点) : 暴力拆除旧节点,直接插入新节点。

diff精细化比较流程(patchVnode方法):

现在已经确定两个都为同一虚拟节点,首先判断新虚拟节点是否有子虚拟节点数组? 进行下一步的精细化比较 : 没有则说明要渲染成单标签DOM,直接将新节点的text覆盖老节点的innerText(无论老节点之前是怎么样都会被渲染成这样的单节点dom)

进一步的精细化比较: 老虚拟节点是否有子虚拟节点数组? 进行最终的精细化比较 (diff核心): 老虚拟节点有text属性但是没有子虚拟数组,新虚拟节点有虚拟节点数组,所以需要将老虚拟节点text先清空再把虚拟数组渲染成dom再添加到老虚拟节点挂载的dom上。

diff核心(updateVnode):

分为三部分: 参数初始化;进行循环处理; 对没遍历完的数组进行处理

参数初始化: 初始化老数组,新数组前后共四个指针 ; 把老数组的key缓存,值为索引。

循环处理:五种情况

  1. 旧前与新前对应(sameVnode): 将两个虚拟节点的内容进行递归调用patchVnode(这里都已经是同一虚拟节点所以调用patchVnode),对应指针移动
  2. 旧后与新后对应 (sameVnode): 将两个虚拟节点的内容进行递归调用patchVnode(这里都已经是同一虚拟节点所以调用patchVnode),对应指针移动
  3. 旧前与新后对应 (sameVnode) : 将两个虚拟节点的内容进行递归调用patchVnode(这里都已经是同一虚拟节点所以调用patchVnode);这里需要将节点移动,因为是对应的新后,将新后移动至未处理节点之前。对应指针移动
  4. 旧后与新前对应 (sameVnode) : 将两个虚拟节点的内容进行递归调用patchVnode(这里都已经是同一虚拟节点所以调用patchVnode);这里需要将节点移动,因为是对应的新前,将新后移动至未处理节点之后。对应指针移动
  5. 没有匹配: 这里是基于新前节点,将之前的老数组map缓存调用:如果匹配上了就把匹配的老节点移动至未处理节点之前,并且将匹配上的老节点所在的数组赋空值,代表已处理 ; 如果没有匹配上,代表这是新的节点,创建成DOM节点后插入到未处理节点之前。

对没遍历完的数组进行处理:

老数组没遍历完: 新数组遍历完处理完毕,将老数组的没遍历到的节点删除

新数组没遍历完: 剩下的节点是没有匹配到的,依次添加到未处理节点之前

mysnabbdom实现

window.snabbdom = {};
let mySnabbdom = window.snabbdom;
mySnabbdom.vnode = (sel, data, children, text, elm) => {
    //sel为标签,data为dom属性,children为子节点,text文本,elm为上树的dom节点,key为唯一标识在data中
    let key = data.key;
    return { sel, data, children, text, elm, key }
}
mySnabbdom.h = function (sel, data, c) {
    //只接受三个参数类型
    if (arguments.length !== 3)
        throw TypeError('h函数只接受三个参数!');
    if (typeof c == 'string' || typeof c == 'number') {
        return mySnabbdom.vnode(sel, data, undefined, c, undefined)
    }
    else if (typeof c == 'object' && c.hasOwnProperty('sel')) {
        let children = [];
        children.push(c);
        return mySnabbdom.vnode(sel, data, children, undefined, undefined);
    }
    else if (Array.isArray(c)) {
        let children = [];
        for (i = 0; i < c.length; i++) {
            let vnode = c[i];
            if (typeof vnode !== 'object' && !c.hasOwnProperty('sel'))
                throw TypeError('数组的项必须是vnode节点!');
            children.push(vnode);
        }
        return mySnabbdom.vnode(sel, data, children, undefined, undefined);
    }
    else {
        throw TypeError('参数类型有误!');
    }
}

//patch方法,修补,内部使用diff算法,第一个参数既可以是真实dom,也可以是虚拟dom,第二个参数是虚拟dom
mySnabbdom.patch = function (Vnode1, Vnode2) {
    //第一个参数是真实dom需要包装成虚拟dom进行patch
    if (Vnode1.sel == '' || Vnode1.sel == undefined) {
        Vnode1 = mySnabbdom.vnode(Vnode1.tagName.toLowerCase(), {}, [], undefined, Vnode1);
    }
    //如果是同一虚拟节点
    if (Vnode1.key == Vnode2.key && Vnode1.sel == Vnode2.sel) {
        console.log('同一');
        mySnabbdom.patchVnode(Vnode1, Vnode2);
    } else {
        //不同直接暴力拆除旧节点,根据新虚拟节点递归创建真实的新DOM
        let DomVnode2 = mySnabbdom.createElement(Vnode2);
        let DomVnode1 = Vnode1.elm;
        let parentDom = DomVnode1.parentNode;
        if (parentDom) {
            //找到老虚拟节点的父元素
            parentDom.insertBefore(DomVnode2, DomVnode1);
            parentDom.removeChild(DomVnode1);
        }
    }
}

//把虚拟节点创建成真实DOM节点
mySnabbdom.createElement = function (Vnode) {
    if (Vnode.sel == '' || Vnode.sel == undefined)
        throw new TypeError('不是虚拟节点,无法创建为真实DOM');
    let dom = document.createElement(Vnode.sel);
    if (Vnode.children == undefined || Vnode.children.length == 0) {
        dom.innerText = Vnode.text;
    } else {
        //需要递归创建子节点
        for (let i = 0; i < Vnode.children.length; i++) {
            let vchild = Vnode.children[i];
            let domChild = mySnabbdom.createElement(vchild);
            dom.appendChild(domChild);
        }
    }
    Vnode.elm = dom;
    return dom;
}

//进行进一步的diff
mySnabbdom.patchVnode = function (oldVnode, newVnode) {
    //为同一个虚拟节点
    if (oldVnode == newVnode)
        return;
    //新虚拟节点是否有子虚拟数组
    if (newVnode.children == undefined || newVnode.children.length == 0) {
        //直接覆盖掉老虚拟节点的text
        oldVnode.elm.innerText = newVnode.text;
    } else {
        //老虚拟节点是否有子虚拟数组
        if (oldVnode.children != undefined && oldVnode.children.length != 0) {
            //有则进行diff核心
            console.log('diff核心');
            mySnabbdom.updateVnode(oldVnode.elm, oldVnode.children, newVnode.children);
        } else {
            console.log(newVnode);
            //先清空老的text,再把新节点虚拟子数组渲染到dom上
            oldVnode.elm.innerText = '';
            for (let i = 0; i < newVnode.children.length; i++) {
                let vchild = newVnode.children[i];
                let vchildDom = mySnabbdom.createElement(vchild);
                oldVnode.elm.appendChild(vchildDom);
            }
        }
    }
}
//是否为同一节点
mySnabbdom.isSameVnode = function (vnode1, vnode2) {
    return vnode1.key == vnode2.key && vnode1.sel == vnode2.sel;
}

//diff核心
mySnabbdom.updateVnode = function (parentDom, oldChildren, newChildren) {
    //目前是两个虚拟节点属于同一虚拟节点并且两个虚拟节点都有虚拟子数组的情况

    //初始化
    let oldStart = 0;
    let oldSVnode = oldChildren[oldStart];
    let oldEnd = oldChildren.length - 1;
    let oldEVnode = oldChildren[oldEnd];
    let newStart = 0;
    let newSVnode = newChildren[newStart];
    let newEnd = newChildren.length - 1;
    let newEVnode = newChildren[newEnd];
    //缓存老虚拟子数组的key,后面第五种情况下每次会遍历老数组匹配
    let keyMap = {};
    for (let i = 0; i < oldChildren.length; i++) {
        let key = oldChildren[i].key;
        if (key !== undefined)
            keyMap[key] = i;
    }
    // console.log(keyMap);
    // console.log(oldChildren, newChildren);
    //进行五种情况的处理,直到某一个虚拟子数组处理完结束循环
    while (oldStart <= oldEnd && newStart <= newEnd) {
        // console.log(oldStart,oldEnd,newStart,newEnd);
        //先进行预先处理第五种情况后发生的将找到的老数组设置为undefined略过
        if (oldSVnode == undefined || oldSVnode == null)
            oldSVnode = oldChildren[++oldStart];
        else if (oldEVnode == undefined || oldEVnode == null)
            oldEVnode = oldChildren[--oldEnd];
        else if (mySnabbdom.isSameVnode(oldSVnode, newSVnode)) {
            console.log(1);
            //第一种情况,旧前与新前
            //这里调用patchVnode进行子虚拟单独节点的递归打补丁,因为已经是同一虚拟节点所以直接递归patchVnode而不是patch
            mySnabbdom.patchVnode(oldSVnode, newSVnode);
            oldSVnode = oldChildren[++oldStart];
            newSVnode = newChildren[++newStart];
        }
        else if (mySnabbdom.isSameVnode(oldEVnode, newEVnode)) {
            console.log(2);
            //第二种,旧后与新后
            mySnabbdom.patchVnode(oldEVnode, newEVnode);
            oldEVnode = oldChildren[--oldEnd];
            newEVnode = newChildren[--newEnd];
        }
        else if (mySnabbdom.isSameVnode(oldSVnode, newEVnode)) {
            console.log(3);
            //第三种,旧前与新后
            mySnabbdom.patchVnode(oldSVnode, newEVnode);
            //这种情况下说明旧前虚拟节点需要移动到当前未处理的虚拟节点的最后
            //这里的insertBefore函数第一个参数为要插入的内容,如果为调用节点的子节点,则是移动  第二个参数如果为null,则自动插入到最后一个元素;
            //nextSibling则为旧后指针后一个,如果没有后一个则返回null;
            //完美实现了节点插入到未处理的最后
            parentDom.insertBefore(oldSVnode.elm, oldEVnode.elm.nextSibling);
            oldChildren[oldStart] = undefined;
            oldSVnode = oldChildren[++oldStart];
            newEVnode = newChildren[--newEnd];
        }
        else if (mySnabbdom.isSameVnode(oldEVnode, newSVnode)) {
            console.log(4);
            //第四种,旧后与新前
            mySnabbdom.patchVnode(oldEVnode, newSVnode);
            //这种情况说明要把旧后节点移动到当前未处理虚拟节点的最前面
            parentDom.insertBefore(oldEVnode.elm, oldSVnode.elm);
            oldChildren[oldEnd] = undefined;
            oldEVnode = oldChildren[--oldEnd];
            newSVnode = newChildren[++newStart];
        } else {
            console.log(5);
            //第五种情况,没有匹配到,则需要用新前节点去匹配旧虚拟数组
            if (keyMap[newSVnode.key] == undefined) {
                //说明老虚拟数组没有这个新前节点,需要创建
                let dom = mySnabbdom.createElement(newSVnode);
                //把新前节点插入到当前未处理的虚拟节点的最前面
                parentDom.insertBefore(dom, oldSVnode.elm);
                newSVnode = newChildren[++newStart];
            } else {
                //匹配到了老虚拟节点
                let index = keyMap[newSVnode.key];
                //需要将匹配到的这个老虚拟节点移动至当前未处理的虚拟节点的最前面
                let TargetVnode = oldChildren[index];
                parentDom.insertBefore(TargetVnode.elm, oldSVnode.elm);
                //匹配过的老虚拟节点设空,不影响指针的移动
                oldChildren[index] = undefined;
                newSVnode = newChildren[++newStart];
            }
        }
    }
    //跳出循环,说明至少一个数组遍历完了,现在进行未遍历完数组的处理
    if (oldStart <= oldEnd) {
        //老虚拟子数组未遍历完,说明新虚拟数组已经全部处理完,渲染完成,需要将这些不需要渲染的节点移除
        for (let i = oldStart; i <= oldEnd; i++) {
            if (oldChildren[i]) {
                let cancelDom = oldChildren[i].elm;
                parentDom.removeChild(cancelDom);
            }
        }
    }
    if (newStart <= newEnd) {
        //新虚拟子数组还未遍历完,说明剩下的这些节点是没有匹配上的,需要创建并插入dom树
        for (let i = newStart; i <= newEnd; i++) {
            let addDom = mySnabbdom.createElement(newChildren[i]);
            if (oldSVnode)
                parentDom.insertBefore(addDom, oldChildren[oldStart].elm);
            else
                parentDom.insertBefore(addDom, null);
        }
    }
}


let myVnode1 = mySnabbdom.h('ul', {}, [
    mySnabbdom.h('li', { key: 'A' }, 'A'),
    mySnabbdom.h('li', { key: 'B' }, 'B'),
    mySnabbdom.h('li', { key: 'C' }, 'C'),
    mySnabbdom.h('li', { key: 'D' }, 'D'),
]);
let myVnode2 = mySnabbdom.h('ul', {}, '单纯的ul文本1');

let myVnode3 = mySnabbdom.h('h1', {}, '单纯的h1文本');

let myVnode4 = mySnabbdom.h('ul', {}, [
    mySnabbdom.h('li', { key: 'B' }, 'B')
])

let myVnode5 = mySnabbdom.h('ul', {}, [
    mySnabbdom.h('li', { key: 'D' }, 'D'),
    mySnabbdom.h('li', { key: 'C' }, 'C'),
    mySnabbdom.h('li', { key: 'B' }, 'B'),
    mySnabbdom.h('li', { key: 'E' }, 'E'),
    mySnabbdom.h('li', { key: 'A' }, 'A'),
    mySnabbdom.h('li', { key: 'M' }, 'M'),
])
mySnabbdom.patch(document.getElementById('container'), myVnode1);

document.getElementById('button').onclick = function () {
    setTimeout(() => {
        mySnabbdom.patch(myVnode1, myVnode5);
    }, 0);
}

index.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
</head>

<body>
    <div id="container">
    </div>
    <button id="button">点击patch</button>
    <script src="main.js"></script>
    <script>
        

    </script>
</body>

</html> 

总结

这个版本的虚拟节点比较简陋,只能有文本或者只能有虚拟子节点,但是diff算法已经通过这个简略版完整的实现了。

在vue中,由于v-if,v-show,v-for引起的数据改变导致视图也要经常变化,在组件的生命周期中意味着beforeUpdate和updated会进行,在这两个钩子期间就是将视图进行更新(单向数据流),那么可以想到,这种指令将dom内容和数据进行绑定后,每关联的数据进行一次变化,页面也会进行相应的更新,如果采用拆除旧DOM,重建新DOM的方法开销会非常大,因为数据变更频繁导致视图变更。而且由于这种变化通常只是小部分,因此diff实现的最小量更新对效率的提升无疑是巨大的。