VDOM(虚拟dom、diff算法)和key

本文深入探讨了Vue.js中虚拟DOM的工作原理及其如何通过diff算法优化DOM操作,减少回流和重绘,提升应用程序性能。文章详细介绍了snabbdom库的使用,以及key在列表渲染中的作用。

参考文献

vue核心之虚拟DOM(vdom)

一、背景定义

JS操作真实DOM的代价!

用我们传统的开发模式,原生JS或JQ操作DOM时,浏览器会从构建DOM树开始从头到尾执行一遍流程。在一次操作中,我需要更新10个DOM节点,浏览器收到第一个DOM请求后并不知道还有9次更新操作,因此会马上执行流程,最终执行10次。例如,第一次计算完,紧接着下一个DOM更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算DOM节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作DOM的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验。

二、原理解析

《Virtual Dom库snabbdom代码解析》

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

DOM 属于渲染引擎,而 JS 又是属于 JS 引擎,JS 要操作 DOM ,就要涉及 JS 引擎线程和 GUI渲染线程的通信,而线程间通信代价是非常昂贵的,这也是造成 JS 操作 DOM 效率不高的原因。减少这种性能消耗做法:减少回流重绘次数 《从各大跨平台技术说起,我们真的需要虚拟 DOM 吗》

优点:

《从各大跨平台技术说开去,我们真的需要虚拟 DOM 吗?》《神三元》

  • 批量操作更新视图:虚拟 DOM 进行频繁修改,不会立马进行回流重绘,而是一次性比较并修改真实 DOM 中需要改的部分,最后在真实 DOM 中进行排版与重绘,减少过多回流重绘的损耗
  • 虚拟 DOM 有效降低大面积真实 DOM 的回流重绘,因为最终与真实 DOM 比较差异,可以只渲染局部
  • 它打开了函数式的UI编程的大门,即UI = f(data)这种构建UI的方式
  • 夸端:(这个优点其实是主要的)虚拟 DOM 本质上是 JavaScript 对象,而 DOM 与平台强相关,相比之下虚拟 DOM 可以进行更方便地跨平台操作,可以通过不同的渲染引擎生成不同平台下的 UI。例如RN(react-native)weex( weeks 的谐音)flute 移动开发等等
不足之处:
  • 有人说虚拟DOM并不比真实的DOM快,其实也是有道理的。当视图中每一条数据都改变时,显然真实的DOM操作更快,因为虚拟DOM还存在js中diff算法的比对过程。所以,VDOM性能优势针对于于大量数据的渲染并且改变的数据只是一小部分的情况
Vue选择的virtual dom库是snabbdom

几个重要的api:

  • h(tag,attr,children):生成vdom
  • patch(container, vnode):将h()函数生成的虚拟dom替换掉原生真实的dom
  • patch(vnode,newVnode):diff算法进行前后对比虚拟dom

注:tag:标签;attr:节点的属性比如id,class等等;children:节点的子节点

vnode对象属性:

  • tag 属性即这个vnode的标签名
  • data 属性包含了最后渲染成真实dom节点后,节点上的class,attribute,style以及绑定的事件
  • children 属性是vnode的子节点
  • text 属性是文本属性
  • elm 属性为这个vnode对应的真实dom节点
  • key 属性是vnode的标记,在diff过程中可以提高diff的效率(主要是针对列表渲染)
定义了一个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>
diff算法实现(使用patch函数)
var snabbdom = require('snabbdom');
var patch = snabbdom.init([ // Init patch function with chosen modules
  require('snabbdom/modules/class').default, // makes it easy to toggle classes
  require('snabbdom/modules/props').default, // for setting properties on DOM elements
  require('snabbdom/modules/style').default, // handles styling on elements with support for animations
  require('snabbdom/modules/eventlisteners').default, // attaches event listeners
]);
var h = require('snabbdom/h').default; // helper function for creating vnodes

var container = document.getElementById('container');

var vnode = h('div#container.two.classes', {on: {click: someFn}}, [
  h('span', {style: {fontWeight: 'bold'}}, 'This is bold'),
  ' and this is just normal text',
  h('a', {props: {href: '/foo'}}, 'I\'ll take you places!')
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

var newVnode = h('div#container.two.classes', {on: {click: anotherEventHandler}}, [
  h('span', {style: {fontWeight: 'normal', fontStyle: 'italic'}}, 'This is now italic type'),
  ' and this is still just normal text',
  h('a', {props: {href: '/bar'}}, 'I\'ll take you places!')
]);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

可以看到patch函数是snabbdom.init出来的,而且传入的参数既可以是用h函数返回的一个vnode又可以是实际的dom元素,现在我们看看init方法的代码,一些实现钩子之类的代码我们就不看了

  // snabbdom的init方法
  ...
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  ...
  return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    const insertedVnodeQueue: VNodeQueue = [];
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode);
    }

    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      elm = oldVnode.elm as Node;
      parent = api.parentNode(elm);

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };
}

三、diff算法

《详解vue的diff算法》《vue 虚拟dom实现原理》

两棵树如果采用深度遍历完全比较时间复杂度是O(n^3),因此为了为了提高速度(复杂度为O(n)),采取diff算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。

算法首先会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个序号。在深度遍历的时候,每遍历到一个节点,我们就将这个节点和新的树中的节点进行比较,如果有差异,则将这个差异记录到一个对象中

  • 永远只比较同层节点。
  • 不同的两个节点产生两个不同的树。
  • 通过key值指定哪些更新是相同的。

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种情况)
    1. 都为文本且不相等,则替换文本
    2. 一个有子节点一个没有(直接做对应的添加或者删除节点)
    3. 都有子节点调用updateChildren进行比较子节点(核心讨论部分,也是diff的核心)
  3. updateChildren 4个指针比较:对新老节点的子节点列表进行指针标记;oldStart+oldEnd,newStart+newEnd;即分别用两个指针标记头部和尾部
  4. 对比新老子节点进行匹配,并且做相应的指针移动

列表元素进行对比的时候,由于 TagName 是重复的,所我们需要给每一个子节点加上一个 key,列表对比的时候使用key 来进行比较,这样我们才能够复用老的 DOM 树上的节点

如果我们提供key值,diff算法会更高效,这样我们才能够复用老的 DOM树上的节点。因为本身diff算法里面有做key的判断了。下面是有key时候的diff流程
在这里插入图片描述

function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(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
        }
    }
    // some code 
    return vnode
}

四、key的使用

知乎的一篇文章
vue 中 key 值的作用可以分为两种情况来考虑。

由于 Vue 会尽可能高效地渲染元素,通常会复用已有元素而不是从头开始渲染。因此当我们使用v-if 来实现元素切换的时候,如果切换前后含有相同类型的元素,那么这个元素就会被复用。如果是相同的 input 元素,那么切换前后用户的输入不会被清除掉,这样是不符合需求的。因此我们可以通过使用 key 来唯一的标识一个元素,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素。这个时候 key 的作用是用来标识一个独立的元素。

  • 第二种情况是 v-for 中使用 key。

用 v-for 更新已渲染过的元素列表时,它默认使用“就地复用”的策略。如果数据项的顺序发生了改变,Vue 不会移动 DOM 元素来匹配数据项的顺序,而是就地更新每个元素,并且确保它们在每个索引位置正确渲染。因此通过为每个列表项提供一个 key 值,来以便 Vue 跟踪元素的身份,从而高效的实现复用。这个时候 key 的作用是为了高效的更新渲染虚拟 DOM。

不使用数组下标作为key

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

Vue 中的 key 到底有什么用?

  • key 是给每一个 vnode 的唯一 id,依靠 key,我们的 diff 操作可以更准确、更快速 (对于简单列表页渲染来说 diff 节点也更快,但会产生一些隐藏的副作用,比如可能不会产生过渡效果,或者在某些节点有绑定数据(表单)状态,会出现状态错位。)

  • diff 算法的过程中,先会进行新旧节点的首尾交叉对比,当无法匹配的时候会用新节点的 key 与旧节点进行比对,从而找到相应旧节点.

  • 更准确 : 因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确,如果不加 key,会导致之前节点的状态被保留下来,会产生一系列的 bug。

  • 更快速 : key 的唯一性可以被 Map 数据结构充分利用,相比于遍历查找的时间复杂度 O(n),Map 的时间复杂度仅仅为 O(1)

评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值