乐趣区

关于vue.js:彻底搞懂Vue虚拟Dom和diff算法

前言

应用过 Vue 和 React 的小伙伴必定对虚构 Dom 和 diff 算法很相熟,它扮演着很重要的角色。因为小编接触 Vue 比拟多,React 只是浅学,所以本篇次要针对 Vue 来开展介绍,带你一步一步搞懂它。

虚构 DOM

什么是虚构 DOM?

虚构 DOM(Virtual Dom),也就是咱们常说的虚构节点,是用 JS 对象来模仿实在 DOM 中的节点,该对象蕴含了实在 DOM 的构造及其属性,用于比照虚构 DOM 和实在 DOM 的差别,从而进行部分渲染来达到优化性能的目标。

实在的元素节点:

<div id="wrap">
    <p class="title">Hello world!</p>
</div>

VNode:

{
    tag:'div',
    attrs:{id:'wrap'},
    children:[
        {
            tag:'p',
            text:'Hello world!',
            attrs:{class:'title',}
        }
    ]
}

为什么应用虚构 DOM?

简略理解虚构 DOM 后,是不是有小伙伴会问:Vue 和 React 框架中为什么会用到它呢?好问题!那来解决下小伙伴的疑难。

起初咱们在应用 JS/JQuery 时,不可避免的会大量操作 DOM,而 DOM 的变动又会引发回流或重绘,从而升高页面渲染性能。那么怎么来缩小对 DOM 的操作呢?此时虚构 DOM 利用而生,所以虚构 DOM 呈现的次要目标就是为了 缩小频繁操作 DOM 而引起回流重绘所引发的性能问题的

虚构 DOM 的作用是什么?

  1. 兼容性好。因为 Vnode 实质是 JS 对象,所以不论 Node 还是浏览器环境,都能够操作;
  2. 缩小了对 Dom 的操作。页面中的数据和状态变动,都通过 Vnode 比照,只须要在比对完之后更新 DOM,不须要频繁操作,进步了页面性能;

虚构 DOM 和实在 DOM 的区别?

说到这里,那么虚构 DOM 和实在 DOM 的区别是什么呢?总结大略如下:

  • 虚构 DOM 不会进行回流和重绘;
  • 实在 DOM 在频繁操作时引发的回流重绘导致性能很低;
  • 虚构 DOM 频繁批改,而后一次性比照差别并批改实在 DOM,最初进行顺次回流重绘,缩小了实在 DOM 中屡次回流重绘引起的性能损耗;
  • 虚构 DOM 无效升高大面积的重绘与排版,因为是和实在 DOM 比照,更新差别局部,所以只渲染部分;
总损耗 = 实在 DOM 增删改 + (多节点)回流 / 重绘;    // 计算应用实在 DOM 的损耗
总损耗 = 虚构 DOM 增删改 + (diff 比照)实在 DOM 差异化增删改 + (较少节点)回流 / 重绘;   // 计算应用虚构 DOM 的损耗

能够发现,都是围绕频繁操作实在 DOM 引起回流重绘,导致页面性能损耗来说的。不过框架也不肯定非要应用虚构 DOM,关键在于看是否频繁操作会引起大面积的 DOM 操作。

那么虚构 DOM 到底通过什么形式来缩小了页面中频繁操作 DOM 呢?这就不得不去理解 DOM Diff 算法了。

DIFF 算法

当数据变动时,vue 如何来更新视图的?其实很简略,一开始会依据实在 DOM 生成虚构 DOM,当虚构 DOM 某个节点的数据扭转后会生成一个新的 Vnode,而后 VNode 和 oldVnode 比照,把不同的中央批改在实在 DOM 上,最初再使得 oldVnode 的值为 Vnode。

diff 过程就是调用 patch 函数,比拟新老节点,一边比拟一边给实在 DOM 打补丁(patch);

对照 vue 源码来解析一下,贴出外围代码,旨在简单明了讲述分明,不然小编本人看着都头大了 O(∩_∩)O

patch

那么 patch 是怎么打补丁的?

//patch 函数  oldVnode: 老节点   vnode: 新节点
function patch (oldVnode, vnode) {
    ...
    if (sameVnode(oldVnode, vnode)) {patchVnode(oldVnode, vnode)    // 如果新老节点是同一节点, 那么进一步通过 patchVnode 来比拟子节点
    } 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
}

// 判断两节点是否为同一节点
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 必须雷同
    )
}

从下面能够看出,patch 函数是通过判断新老节点是否为同一节点:

  • 如果是同一节点,执行 patchVnode 进行子节点比拟;
  • 如果不是同一节点,新节点间接替换老节点;

那如果不是同一节点,然而它们子节点一样怎么办嘞?OMG,要牢记:diff 是同层比拟,不存在跨级比拟的!简略提一嘴,React 中也是如此,它们只是针对同一层的节点进行比拟。

patchVnode

既然到了 patchVnode 办法,阐明新老节点为同一节点,那么这个办法做了什么解决?

function patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el           // 找到对应的实在 DOM
    let i, oldCh = oldVnode.children, ch = vnode.children     
    if (oldVnode === vnode) return         // 如果新老节点雷同, 间接返回
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        // 如果新老节点都有文本节点且不相等, 那么新节点的文本节点替换老节点的文本节点
        api.setTextContent(el, vnode.text)  
    }else {updateEle(el, vnode, oldVnode)
        if (oldCh && ch && oldCh !== ch) {// 如果新老节点都有子节点, 执行 updateChildren 比拟子节点[很重要也很简单, 上面开展介绍]
            updateChildren(el, oldCh, ch)
        }else if (ch){
            // 如果新节点有子节点而老节点没有子节点, 那么将新节点的子节点增加到老节点上
            createEle(vnode)
        }else if (oldCh){
            // 如果新节点没有子节点而老节点有子节点,那么删除老节点的子节点
            api.removeChildren(el)
        }
    }
}

如果两个节点不一样,间接用新节点替换老节点;

如果两个节点一样,

  • ​ 新老节点一样,间接返回;
  • ​ 老节点有子节点,新节点没有:删除老节点的子节点;
  • ​ 老节点没有子节点,新节点有子节点:新节点的子节点间接 append 到老节点;
  • ​ 都只有文本节点:间接用新节点的文本节点替换老的文本节点;
  • ​ 都有子节点:updateChildren

最简单的状况也就是新老节点都有子节点,那么 updateChildren 是如何来解决这一问题的,该办法也是 diff 算法的外围,上面咱们来理解一下!

updateChildren

因为代码太多了,这里先做个概述。updateChildren 办法的外围:

  1. 提取出新老节点的子节点:新节点子节点 ch 和老节点子节点 oldCh;
  2. ch 和 oldCh 别离设置 StartIdx(指向头)和 EndIdx(指向尾)变量,它们两两比拟(依照 sameNode 办法),有四种形式来比拟。如果 4 种形式都没有匹配胜利,如果设置了 key 就通过 key 进行比拟,在比拟过程种 startIdx++,endIdx–,一旦 StartIdx > EndIdx 表明 ch 或者 oldCh 至多有一个曾经遍历实现,此时就会完结比拟。

上面联合图来了解:

第一步:

oldStartIdx = A , oldEndIdx = C;
newStartIdx = A , newEndIdx = D;

此时 oldStartIdx 和 newStarIdx 匹配,所以将 dom 中的 A 节点放到第一个地位,此时 A 曾经在第一个地位,所以不做解决,此时实在 DOM 程序:A B C;

参考 vue 实战视频解说:进入学习

第二步:

oldStartIdx = B , oldEndIdx = C;
newStartIdx = C , oldEndIdx = D;

此时 oldEndIdx 和 newStartIdx 匹配,将本来的 C 节点挪动到 A 前面,此时实在 DOM 程序:A C B;

第三步:

oldStartIdx = C , oldEndIdx = C;
newStartIdx = B , newEndIdx = D;
oldStartIdx++,oldEndIdx--;
oldStartIdx > oldEndIdx

此时遍历完结,oldCh 曾经遍历完,那么将残余的 ch 节点依据本人的 index 插入到实在 DOM 中即可,此时实在 DOM 程序:A C B D;

所以匹配过程中判断完结有两个条件:

  • oldStartIdx > oldEndIdx 示意 oldCh 先遍历实现,如果 ch 有残余节点就依据对应 index 增加到实在 DOM 中;
  • newStartIdx > newEndIdx 示意 ch 先遍历实现,那么就要在实在 DOM 中将多余节点删除掉;

看下图这个实例,就是新节点先遍历实现删除多余节点:

最初,在这些子节点 sameVnode 后如果满足条件继续执行 patchVnode,层层递归,直到 oldVnode 和 Vnode 中所有子节点都比对实现,也就把所有的补丁都打好了,此时更新到视图。

总结

dom 的 diff 算法工夫复杂度为 o(n^3), 如果应用在框架中性能会很差。Vue 应用的 diff 算法,工夫复杂度为 o(n),简化了很多操作。

最初,用一张图来记忆整个 Diff 过程,心愿你能有所播种!

彩蛋

因为 React 只是简略学了根底,这里作为比照来概述一下:

1.React 渲染机制:React 采纳虚构 DOM,在每次属性和状态发生变化时,render 函数会返回不同的元素树,而后比照返回的元素树和上次渲染树的差别并对差别局部进行更新,最初渲染为实在 DOM。

2.diff 永远都是同层比拟,如果节点类型不同,间接用新的替换旧的。如果节点类型雷同,就比拟他们的子节点,顺次类推。通常元素上绑定的 key 值就是用来比拟节点的,所以肯定要保障其唯一性,个别不采纳数组下标来作为 key 值,因为当数组元素发生变化时 index 会有所改变。

3. 渲染机制的整个过程蕴含了更新操作,将虚构 DOM 转换为实在 DOM,所以整个渲染过程就是 Reconciliation。而这个过程的外围又次要是 diff 算法,利用的是生命周期 shouldComponentUpdate 函数。

退出移动版