Virtual DOM 深入理解

2020年春节,一场突如其来的新型冠状病毒席卷全球,中国首当其冲,确诊病人人数急剧上升,一场没有硝烟的战争就此打响。全国进入一级响应,全国人民众志成城共同抗击疫情,医务人员"国有难,召必回,战必胜",奔赴一线;而我们自行在家隔离定不给祖国添麻烦。直至今日,疫情仍没停止,开学遥遥无期,也祈祷疫情早日结束,让我们回归正常的生活。以上的感慨发自肺腑,就当作2020年的一个纪念吧。
笔至于此,名归正传,近日在家对前端框架的虚拟dom、diff算法,做了深入的理解,有了全面的认识和启发,故作此文来梳理记录下。下面逐层讲解。

一、节点树以及虚拟 DOM

vue官网中这样描述,当浏览器读到HTML代码时,它会建立一个“DOM 节点”树来保持追踪所有内容,如同你会画一张家谱树来追踪家庭成员的发展一样。
在这里插入图片描述
每个元素都是一个节点。当我们更新节点时不得不操作dom,频繁的手动去操作dom而带来性能问题, 而用JS来模拟DOM结构,把DOM的变化操作放在JS层来做,这样大大降低了内存消耗,所以就引入了Virtual DOM(js对象树)。在vue中,渲染函数如下,完成视图更新,其前端框架虚拟dom借鉴于snabbdom.js,如要深挖,可前往了解。

render: function (createElement) {
  return createElement('h1',{style: background: 'black'},children)  //返回一个vnode
}
二、渲染过程
  • Vue.js通过首先编译(通过vue-loader)将template 模板(react则是通过babel编译jsx语法)转换成渲染函数(render ) ,执行渲染函数中的createElement()就可以得到一个虚拟节点树
  • 在模板编译过程中,模板当中变量的依赖和watcher相绑定(这个过程可参考上一篇文章),一旦model中的响应式的数据发生了变化,setter便会监听到,数据所维护的dep数组便会调用dep.notify()方法通知对应的watcher,从而调用render函数vm._render(),会返回一个新的VNode,进而调用patch(oldVnode, newVnode)方法,将变更的地方以打补丁的方式到oldVnode上,并完成真实dom的更新工作。

在这里插入图片描述
整个流程最核心的时patch()函数,通过Diff算法比较新老vnode差异,以至于达到最小的程度的更新节点。下文代码会粗略的展现实现流程。

三、Vue和react 虚拟dom更新策略不同之处
  • vue ,采用了Object.defineProperty这个属性,将数据维护成了可观察的数据,数据的每一项都通过getter来收集依赖,然后将依赖转化成watcher保存在闭包中,数据修改后,触发数据的setter方法,然后通知所对应的watcher,调用render函数,会返回一个newVnode,进而调用patch(oldVnode,newVnode)函数来更新dom
  • react将数据和jsx模版结合通过createElement方法生成js对象树,也就是虚拟dom。react采用setState来控制视图的更新,setState会自动调用render函数,触发patch,完成视图更新。但react给开发者暴露一个生命周期函数:shouldcomponentupdate,这个函数可以根据开发者的需求决定是否重新渲染。
  • vue为每个数据设置setter、getter、watcher,当数据足够多时,运行效率反倒不如react。而如果项目中型,并且想快速开发,vue更高效。
四、流程及附上核心代码
index.js
// 创建虚拟dom,vnode为一个对象树(虚拟dom)
// let oldVnode = createElement('div', {
//     id: 1, a: 1, key: 'xxx'
// }, createElement('span', {
//     style: {
//         color: 'red'
//     }
// }, 'text2'), 'text1')

  patch(oldVnode, newVnode)
export default function createElement(type, props = {}, ...children) {
    //获取属性的key, 然后删除
    let key
    if (props.key) {
        key = props.key
        delete props.key
    }
    //子节点是标签还是文本
    children = children.map(child => {
        if (typeof child === 'string') {
            return vnode(undefined, undefined, undefined, undefined, child)
        } else {
            return child
        }
    })
    return vnode(type, props, key, children)
}
  • 调用patch(oldVnode, newVnode),若oldVnode不存在,那么就用newVnode创建一个真实dom节点,若 存在,进行diff,完成更新操作,渲染视图
  • 对于vnode采用深搜的方式层层递进调用patch();对于同层的vnode,调用updateChildren()进行比较更新。
export function patch(oldVnode, newVnode) {
    // 判断类型不同
    if (oldVnode.type !== newVnode.type) {
        return oldVnode.domElement.parentNode.replaceChild(createDomElementVnode(newVnode), oldVnode.domElement)
    }
    // 类型相同,换文本
    if (oldVnode.text) {
        if (oldVnode.text == newVnode.text) return
        return oldVnode.domElement.textContent = newVnode.text
    }
    //顶级更新
    let domElement = newVnode.domElement = oldVnode.domElement
    // console.log(domElement);
    // 更新顶级属性
    updateProps(newVnode, oldVnode.props)
    //对比儿子-三种情况
    // 1.老的有儿子,新的有儿子
    // 2.老的有儿子,新的无儿子
    // 3.新增儿子
    let oldChildren = oldVnode.children
    let newChildren = newVnode.children
    if (oldChildren.length > 0 && newChildren.length > 0) {
        //新老都有儿子执行updateChildren
        updateChildren(domElement, oldChildren, newChildren)
    } else if (oldChildren.length > 0) {//新的无儿子
        domElement.innerHTML = ''
    } else if (newChildren.length > 0) {//新增儿子,转成dom添加到domElement
        for (let i = 0; i < newChildren.length; i++) {
            domElement.appendChild(createDomElementVnode(newChildren[i]))
        }
    }
}
//根据我们虚拟节点的属性 去更新真实的dom属性
function updateProps(newVnode, oldProps = {}) {
    let domElement = newVnode.domElement //节点
    let newProps = newVnode.props
    // 和老的做对比
    //1.老的里面有,新的有,则直接干掉这个属性
    for (const oldPropname in oldProps) {
        if (!newProps[oldPropname]) {
            delete domElement[oldPropname]
        }
    }
    // 2.老的里面没有,新的里面有
    for (const newPropsName in newProps) {
        domElement[newPropsName] = newProps[newPropsName]
    }
    // 3.style
    let newStyleObj = newProps.style || {}
    let oldStyleObj = oldProps.style || {}
    for (const propName in oldStyleObj) {
        if (!newStyleObj[propName]) {
            domElement.style[propName] = ''
        }
    }
    // 循环将style给dom
    for (const newPropsName in newProps) {
        // 有style
        if (newPropsName == 'style') {
            for (const s in newProps.style) {
                domElement.style[s] = newProps.style[s]
            }
        } else {
            domElement[newPropsName] = newProps[newPropsName]
        }

    }
}

// 返回真实dom
function createDomElementVnode(vnode) {
    let { type, props, key, children, text } = vnode

    if (type) {//标签节点
        vnode.domElement = document.createElement(type)
        //根据我们虚拟节点的属性 去更新真实的dom属性
        updateProps(vnode)
        //递归调用渲染函数
        children.forEach(childNode => render(childNode, vnode.domElement));
    } else { //文本节点
        vnode.domElement = document.createTextNode(text)
    }
    return vnode.domElement
}
//渲染view
export function render(vnode, container) {
    let ele = createDomElementVnode(vnode)
    container.appendChild(ele)
}
  • updateChildren()方法时diff中最重要的环节,对于同层节点进行比较,找出最优的更新方案,以至于最小程度去修改dom
  • 节点属性中key来作为唯一标识,可以提高diff效率。下文会举例说明
// 同层的儿子节点
function updateChildren(parent, oldChildren, newChildren) {
    let oldStartIndex = 0
    let oldStartVnode = oldChildren[0]
    let oldEndIndex = oldChildren.length - 1
    let oldEndVnode = oldChildren[oldChildren.length - 1]
    let newStartIndex = 0
    let newStartVnode = newChildren[0]
    let newEndIndex = newChildren.length - 1
    let newEndVnode = newChildren[newChildren.length - 1]
    //判断老的儿子和新的儿子,在循环体中 谁先结束就停止
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
        if (!oldStartVnode) {
            oldStartVnode = oldChildren[++oldEndIndex]

        } else if (!oldEndVnode) {
            oldEndVnode = oldChildren[--oldEndIndex]
        } else
            //如果虚拟节点type和key相同
            if (isSameVnode(oldStartVnode, newStartVnode)) {//首部
                //深搜--去更新打补丁
                patch(oldStartVnode, newStartVnode)
                //同层节点下一个比较
                oldStartVnode = oldChildren[++oldStartIndex]
                newStartVnode = newChildren[++newStartIndex]
            } else if (isSameVnode(oldEndVnode, newEndVnode)) {//尾部
                patch(oldEndVnode, newEndVnode)
                oldEndVnode = oldChildren[--oldEndIndex]
                newEndVnode = newChildren[--newEndIndex]
            } else if (isSameVnode(oldStartVnode, newEndVnode)) {//首尾
                // oldS和newE相同,进行patch,oldS插入到oldE之后  oldS++,newE--
                patch(oldStartVnode, newEndVnode)
                parent.insertBefore(oldStartVnode.domElement, oldEndVnode.domElement.nextSiblings)
                oldStartVnode = oldChildren[++oldStartIndex]
                newEndVnode = newChildren[--newEndIndex]
            } else if (isSameVnode(oldEndVnode, newStartVnode)) {//尾首
                // oldE和newS相同,进行patch,oldE插入到oldS之前,oldE--,newS++
                patch(oldEndVnode, newStartVnode)
                parent.insertBefore(oldEndVnode.domElement, oldStartVnode.domElement)
                oldEndVnode = oldChildren[--oldEndIndex]
                newStartVnode = newChildren[++newStartIndex]
            } else {
                // 如果以上条件都不满足,那么这个时候开始比较key值,首先建立key和index索引的对应关系
                // 暴力对比
                let index = keyMapByINdex(oldChildren)[newStartVnode.key]
                // let index = map[newStartIndex.key]
                if (index = null) {
                    parent.insertBefore(createDomElementVnode(newStartVnode), oldStartVnode.domElement)
                } else {
                    let toMoveNode = oldChildren[index]
                    patch(toMoveNode, newStartVnode)
                    parent.insertBefore(toMoveNode.domElement, oldStartVnode.domElement)
                    oldChildren[index] = undefined
                }
                // 移动位置
                newStartVnode = newChildren[++newStartIndex]

            }
    }
    //若新节点有多余,塞进去
    if (newStartIndex <= newEndIndex) {
        for (let i = newStartIndex; i <= newEndIndex; i++) {
            //获取要塞入位置的后一节点,
            let beforeElement = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domElement
            // console.log(beforeElement);
            parent.insertBefore(createDomElementVnode(newChildren[i]), beforeElement)
        }
    }
    // 判断中间的undefined
    if (oldStartIndex <= oldEndIndex) {
        for (let i = oldStartIndex; i < oldEndIndex; i++) {
            if (oldChildren[i]) {
                parent.removeChild(oldChildren[i].domElement)

            }
        }

    }
}
function isSameVnode(oldVnode, newVnode) {
    return oldVnode.key === newVnode.key && oldVnode.type === newVnode.type
}

五、属性key

下方图newVnode相对比oldVnode新增了F节点,加入都为

  • 节点,各个字母为属性k
    在这里插入图片描述
    • 无属性k: 在updateChildren()函数中,每一项节点type都相同,isSameVnode(oldStartVnode, newStartVnode)一直为true,故从左一直比到右,做了三次节点更新和一次节点创建插入。
    • 有k : 只做了一次创建插入操作。因此key的存在可以高效的更新虚拟Dom,在diff算法中更准确的判断是否为同一节点,减少不必要的更新。
    • k为index下标时,在列表更新时会引发bug。如在v-for中,增删数据时,单选框的选择项会出现错乱(如在首部新增一个单选框,由于首部新老节点key和type 、属性都相同,只是e.target.checked不同而已但patch函数没做比较,所以不会更新,最后仅仅在oldNode尾部追加了一个节点,所以导致之前的选中项还是在索引下标的位置即错乱);在给key赋值时,不推荐index(会失去key值的意义),应使用唯一的id值。还有input文本框同理,只是value变化。

    例子1:
    在这里插入图片描述
    例子2:
    index做key
    原始数据:A-0 B-1 C-2
    删除A : B-0 C-1

    将会失去diff更新效率

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值