Vue 中的 VDOM(虚拟DOM) 和 diff 算法,你清楚吗 ?

前言 :


在学完基础的 html(html5),css(css3),js(es6) 等基础后,我们可以利用已有的知识去做原生开发。但是为了提高开发效率,可能会去学习一些方便操作 DOM,而封装的库或者框架,如 jQuery 等,还有一些 UI 组件库或框架,如 Bootstrap,Element-UI 等。如果你觉得操作 DOM 比较繁琐,那么就可以去了解比较流行的 react 和  vue 等前端框架。这些框架,它的核心是,数据响应式实现了操作数据化,废弃了之前操作DOM的繁琐的任务。还有就是我们今天要讨论的,它们的虚拟DOM,diff 算法等核心概念。


  • 什么是虚拟DOM(VDOM)?

虚拟 DOM(VDOM):所谓的 Virtual dom ,就是我们常说的虚拟节点,它是通过 JS 的 Object 对象模拟 DOM 中的节点,然后在通过特定的 render 函数去将其渲染成真实的 DOM 节点。

定义了一个vnode,它的数据结构是:
 {
        tag: 'div'
        data: {
            id: 'app',
            class: 'page-box'
        },
        children: [
            {
                tag: 'p',
                text: 'this is demo'
            }
        ]
    }
渲染出的实际的dom结构就是:
 <div id="app" class="page-box">
       <p>this is demo</p>
   </div>
  • 虚拟DOM,解决了哪些问题 ?

我们知道,在 jQuery 这样的库中,是没有虚拟 DOM 这个概念的。那么就得思考,为什么 react 和 vue 等框架中,会采用这种形式,这么做的好处和原因又是什么 ?当我们使用之前的一些库,如 jQuery 等的时候,我们不禁会大量的操作DOM,因此 DOM 元素引起的页面的回流和重绘就不可避免,频繁操作还是会出现页面卡顿,页面性能下降,影响用户体验。其实,框架并不一定需要使用虚拟DOM,关键看使用框架的过程中是否会频繁引起大面积的 DOM 操作,虚拟DOM的出现也是为了解决大面积重绘引发的性能问题。

  • 真实DOM和虚拟DOM之间有哪些区别 ?
  1. 虚拟 DOM 不会进行排版与重绘操作。
  2. 真实 DOM 频繁排版和重绘的效率是相当低的。
  3. 虚拟 DOM 进行频繁修改,然后一次性比较并修改真实 DOM 中需要改的部分,最后并在真实 DOM 中进行排版和重绘,减少过多的 DOM 节点的排版和重绘损耗。
  4. 虚拟 DOM 有效降低大面积(真实 DOM 节点)的重绘和排版,因为最终与真实 DOM 比较差异,可以只渲染局部。
  • 虚拟DOM原理 --- diff 算法 (patch)?

Virtual Dom 的原理是用 JavaScript 对象表示 DOM 信息结构,当状态改变的时候,重新构建一颗对象树,然后通过新渲染的对象树(newVnode)去和旧的对象树(oldVnode)进行对比,他使用了一个 diff 差异算法 计算差异,记录下来的不同就应用在真正的 dom 树上,从而减少页面的回流和重绘。

  • 当数据发生变化时,Vue 怎么去更新节点 ?

先根据真实 DOM 生成一颗 Virtual DOM,当 Virtual DOM 某个节点的数据发生改变后会生成一个新的 Vnode,然后Vnode 和 oldVnode对比,发现不一样的地方就直接修改在真实的 DOM上,然后使 oldVnode 的值为 Vnode。diff 的过程就是调用 patch 函数,比较新旧节点,一边比较一边给真实的DOM打补丁(patch)。

  • 详解 diff 算法 ?

两棵树如果采用 深度遍历 完全比较时间复杂度是O(n^3),因此为了为了提高速度(复杂度为O(n)),采取 diff 算法比较新旧节点的时候,比较只会在 同层级进行, 不会跨层级比较。算法首先会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个序号。在深度遍历的时候,每遍历到一个节点,我们就将这个节点和新的树中的节点进行比较,如果有差异,则将这个差异记录到一个对象中。

  • diff 算法,核心方法 ?

diff 算法是为了以最小代价去将 oldVnode 修改成 newVnode ,核心方法是:

  1. sameVnode通过判断传入的2个 vnode 的 key,tag, 是否同为注释节点 、inputType 等是否相同,来判断两节点是否值得比较,值得比较则执行 patchVnode。只有当基本属性相同的情况下才认为这个2个 vnode 只是局部发生了更新,然后才会对这2个 vnode 进行 diff,如果2个 vnode 的基本属性存在不一致的情况,那么就会直接跳过 diff 的过程,进而依据 vnode 新建一个真实的 dom,同时删除老的 dom节点。
  2. patchVnode :判断(3种情况):都为文本且不相等,则替换文本;一个有子节点一个没有(直接做对应的添加或者删除节点);都有子节点调用 updateChildren 进行比较子节点(核心讨论部分,也是 diff 的核心)
  3. updateChildren: 4个指针比较:对新老节点的子节点列表进行指针标记;oldStart+oldEnd,newStart+newEnd;即分别用两个指针标记头部和尾部,对比新老子节点进行匹配,并且做相应的指针移动。

注意 : 对列表元素进行对比的时候,由于 TagName 是重复的,所我们需要给每一个子节点加上一个 key,列表对比的时候使用key 来进行比较,这样我们才能够复用老的 DOM 树上的节点。如果我们提供 key 值,diff 算法会更高效,这样我们才能够复用老的 DOM 树上的节点。因为本身 diff 算法里面有做 key 的判断了。

//patch是整个diff的入口,会先从根节点对
function patch (oldVnode, vnode) {
    if (sameVnode(oldVnode, vnode)) {
// 值得比较会执行patchVnode(oldVnode, vnode)
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el// 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)// 父元素
        createEle(vnode)// 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))// 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)// 移除以前的旧元素节点
            oldVnode = null
        }
    }
    return vnode
}


//sameVnode函数就是看这两个节点是否值得比较,两个vnode的key和sel相同才去比较它们(判断新旧节点是否一致)
function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}


// 值得比较会执行patchVnode(oldVnode, vnode)
patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
//他们的引用一致,可以认为没有变化。
    if (oldVnode === vnode) return
//文本节点的比较,需要修改,则会调用Node.textContent = vnode.text。
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
 //两个节点都有子节点,而且它们不一样,调用updateChildren函数比较子节点
        if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
        }else if (ch){
//只有新的节点有子节点,调用createEle(vnode),vnode.el已经引用了老的dom节点,createEle函数会在老dom节点上添加子节点。
            createEle(vnode) //create el's children dom
        }else if (oldCh){
//新节点没有子节点,老节点有子节点,直接删除老节点。
            api.removeChildren(el)
        }
    }
}


//两个节点都有子节点,而且它们不一样,调用updateChildren函数比较子节点
updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//判断旧节点是否为空
            if (oldStartVnode == null) {   //对于vnode.key的比较,会把oldVnode = null
                oldStartVnode = oldCh[++oldStartIdx] 
            }else if (oldEndVnode == null) {
                oldEndVnode = oldCh[--oldEndIdx]
//判断新节点是否为空
            }else if (newStartVnode == null) {
                newStartVnode = newCh[++newStartIdx]
            }else if (newEndVnode == null) {
                newEndVnode = newCh[--newEndIdx]
//当新旧节点的头部值得对比,进入patchNode方法,同时各自的头部指针+1;
            }else if (sameVnode(oldStartVnode, newStartVnode)) {
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
//当新旧节点的尾部值得对比,进入patchNode方法,同时各自的尾部指针-1;
            }else if (sameVnode(oldEndVnode, newEndVnode)) {
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
//当oldStartVnode,newEndVnode值得对比,说明oldStartVnode已经跑到了后面,那么就将oldStartVnode.el移到oldEndVnode.el的后边。oldStartIdx+1,newEndIdx-1;
            }else if (sameVnode(oldStartVnode, newEndVnode)) {
                patchVnode(oldStartVnode, newEndVnode)
                api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
//当oldEndVnode,newStartVnode值得对比,说明oldEndVnode已经跑到了前面,那么就将oldEndVnode.el移到oldStartVnode.el的前边。oldEndIdx-1,newStartIdx+1;
            }else if (sameVnode(oldEndVnode, newStartVnode)) {
                patchVnode(oldEndVnode, newStartVnode)
                api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
//以上4种对比都不成立时,通过newStartVnode.key 看是否能在oldVnode中找到,如果没有则新建节点,如果有则对比新旧节点中相同key的Node,newStartIdx+1。
            }else {
               // 使用key时的比较
                if (oldKeyToIdx === undefined) {
                    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
                }
                idxInOld = oldKeyToIdx[newStartVnode.key]
                if (!idxInOld) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    newStartVnode = newCh[++newStartIdx]
                }
                else {
                    elmToMove = oldCh[idxInOld]
                    if (elmToMove.sel !== newStartVnode.sel) {
                        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    }else {
                        patchVnode(elmToMove, newStartVnode)
                        oldCh[idxInOld] = null
                        api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                    }
                    newStartVnode = newCh[++newStartIdx]
                }
            }
        }
//oldStartIdx > oldEndIdx可以认为oldVnode对比完毕,当然也有可能 newVnode也刚好对比完,一样归为此类。此时newStartIdx和newEndIdx之间的vnode是新增的,调用addVnodes,把他们全部插进before的后边。
        if (oldStartIdx > oldEndIdx) {
            before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
            addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
//newStartIdx > newEndIdx,可以认为newVnode先遍历完,oldVnode还有节点。此时oldStartIdx和oldEndIdx之间的vnode在新的子节点里已经不存在了,调用removeVnodes将它们从dom里删除。
        }else if (newStartIdx > newEndIdx) {
            removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
        }
}
  •  Vue 中的 key 有什么作用 ?
  1. key 是给每一个 vnode 的唯一 id,依靠 key,我们的 diff 操作可以更准确、更快速 (对于简单列表页渲染来说 diff 节点也更快,但会产生一些隐藏的副作用,比如可能不会产生过渡效果,或者在某些节点有绑定数据(表单)状态,会出现状态错位。
  2. diff 算法的过程中,先会进行新旧节点的首尾交叉对比,当无法匹配的时候会用新节点的 key 与旧节点进行比对,从而找到相应旧节点。
  3. 更准确 : 因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确,如果不加 key,会导致之前节点的状态被保留下来,会产生一系列的 bug。
  4. 更快速 : key 的唯一性可以被 Map 数据结构充分利用,相比于遍历查找的时间复杂度 O(n),Map 的时间复杂度仅仅为 O(1)。
  • 为什么不推荐数组下标作为 key 值 ?

Vue 不推荐使用数组下标作为 key 的原因。例如数组删除了一个元素,那么这个元素后方元素的下标全都前移了一位,之前 key 对应的数据和 dom 就会乱了,除非重新匹配 key,那就容易产生错误。如果重新匹配 key,等于全部重新渲染一遍,违背了使用 key 来优化更新 dom 的初衷。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值