Vue 虚拟DOM、diff算法、key属性

663 阅读12分钟

虚拟DOM与真实DOM的区别

真实DOM:

<ul>
  <li>a</li>
  <li>b</li>
  <li>c</li>
</ul>

虚拟DOM

let vnode = h('ul.list',[
   h('li','a')
   h('li','b')
   h('li','c')
])

虚拟DOM不会进行排版与重绘操作,虚拟DOM就是把真实DOM转化为JS代码

引入原因

Vue是数据驱动视图(数据的变化将引起视图的变化),但你发现某个数据改变时,视图是局部刷新而不是整个重新渲染,如何精准的找到数据对应的视图并进行更新呢?那就需要拿到数据改变前后的dom结构,找到差异点并进行更新。

虚拟dom实质上是针对真实dom提炼出的简单对象。就像一个简单的div包含200多个属性,但真正需要的可能只有tagName,所以对真实dom直接操作将大大影响性能。

实现 Virtual DOM

Virtual DOM 主要包括以下三个方面:

  • 使用 js 数据对象 表示 DOM 结构 -> VNode
  • 比较新旧两棵 虚拟 DOM 树的差异 -> diff
  • 将差异应用到真实的 DOM 树上 -> patch

虚拟DOM原理

虚拟节点(vnode)大致包含以下属性:

{
  tag: 'div',       // 标签名
  data: {},         // 属性数据,包括class、style、event、props、attrs等
  children: [],     // 子节点数组,也是vnode结构
  text: undefined,  // 文本
  elm: undefined,   // 真实dom
  key: undefined    // 节点标识
}

虚拟dom的比较,就是找出新节点(vnode)和旧节点(oldVnode)之间的差异,然后对差异进行打补丁(patch)。大致流程如下: 解析: 新旧节点如果不相似,直接根据新节点创建dom;如果相似,先是对data比较,包括class、style、event、props、attrs等,有不同就调用对应的update函数,然后是对子节点的比较,子节点的比较用到了diff算法。

  • patch()函数
function patch (oldVnode, vnode) {
  var elm, parent;
  if (sameVnode(oldVnode, vnode)) {
    // 相似就去打补丁(增删改)
    patchVnode(oldVnode, vnode);
  } else {
    // 不相似就整个覆盖
    elm = oldVnode.elm;
    parent = api.parentNode(elm);
    createElm(vnode);
    if (parent !== null) {
      api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }
  return vnode.elm;
}

patch()函数接收新旧vnode两个参数,传入的这两个参数有个很大的区别:oldVnode的elm指向真实dom,而vnode的elm为undefined...但经过patch()方法后,vnode的elm也将指向这个(更新过的)真实dom。

  • 打补丁

    • 对于新旧vnode不一致的处理方法很简单,就是根据vnode创建真实dom,代替oldVnode中的elm插入DOM文档。

    • 对于新旧vnode一致的处理,就是我们前面经常说到的打补丁了。patchVnode()方法

  function patchVnode (oldVnode, vnode) {
      // 新节点引用旧节点的dom
      let elm = vnode.elm = oldVnode.elm;
      const oldCh = oldVnode.children;
      const ch = vnode.children;

      // 调用update钩子
      if (vnode.data) {
        updateAttrs(oldVnode, vnode);
        updateClass(oldVnode, vnode);
        updateEventListeners(oldVnode, vnode);
        updateProps(oldVnode, vnode);
        updateStyle(oldVnode, vnode);
      }
      // 判断是否为文本节点
      if (vnode.text == undefined) {
        if (isDef(oldCh) && isDef(ch)) {
          if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
        } else if (isDef(ch)) {
          if (isDef(oldVnode.text)) api.setTextContent(elm, '')
          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        } else if (isDef(oldCh)) {
          removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        } else if (isDef(oldVnode.text)) {
          api.setTextContent(elm, '')
        }
      } else if (oldVnode.text !== vnode.text) {
        api.setTextContent(elm, vnode.text)
      }
  }

所有数据比较完后,就到子节点的比较了。先判断当前vnode是否为文本节点,如果是文本节点就不用考虑子节点的比较;若是元素节点,就需要分三种情况考虑:

  • 新旧节点都有children,那就进入子节点的比较(diff算法);
  • 新节点有children,旧节点没有,那就循环创建dom节点;
  • 新节点没有children,旧节点有,那就循环删除dom节点。

vue2中diff算法

diff算法干嘛的

DIFF算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM。

diff算法做的事情是比较VNode和oldVNode,再以VNode为标准的情况下在oldVNode上做小的改动,完成VNode对应的Dom渲染。

diff算法原理

  • 先去同级比较,然后再去比较子节点

  • 先去判断一方有子节点一方没有子节点的情况

  • 比较都有子节点的情况

  • 递归比较子节点

子节点比较图: 图中的oldCh和newCh分别表示新旧子节点数组,它们都有自己的头尾指针oldStartIdx,oldEndIdx,newStartIdx,newEndIdx,数组里面存储的是vnode,为了容易理解就用a,b,c,d等代替,它们表示不同类型标签(div,span,p)的vnode对象。

子节点的比较实质上就是循环进行头尾节点比较。循环结束的标志就是:旧子节点数组或新子节点数组遍历完,(即 oldStartIdx > oldEndIdx || newStartIdx > newEndIdx)。大概看一下循环流程:

第一步 头头比较。若相似,旧头新头指针后移(即 oldStartIdx++ && newStartIdx++),真实dom不变,进入下一次循环;不相似,进入第二步。

第二步 尾尾比较。若相似,旧尾新尾指针前移(即 oldEndIdx-- && newEndIdx--),真实dom不变,进入下一次循环;不相似,进入第三步。

第三步 头尾比较。若相似,旧头指针后移,新尾指针前移(即 oldStartIdx++ && newEndIdx--),未确认dom序列中的头移到尾,进入下一次循环;不相似,进入第四步。

第四步 尾头比较。若相似,旧尾指针前移,新头指针后移(即 oldEndIdx-- && newStartIdx++),未确认dom序列中的尾移到头,进入下一次循环;不相似,进入第五步。

第五步 若节点有key且在旧子节点数组中找到sameVnode(tag和key都一致),则将其dom移动到当前真实dom序列的头部,新头指针后移(即 newStartIdx++);否则,vnode对应的dom(vnode[newStartIdx].elm)插入当前真实dom序列的头部,新头指针后移(即 newStartIdx++)。

没有key的子节点的比较:

解析:

  • 第一次是头头相似(都是a),dom不改变,新旧头指针均后移。a节点确认后,真实dom序列为:a,b,c,d,e,f,未确认dom序列为:b,c,d,e,f;
  • 第二次是尾尾相似(都是f),dom不改变,新旧尾指针均前移。f节点确认后,真实dom序列为:a,b,c,d,e,f,未确认dom序列为:b,c,d,e;
  • 第三次是头尾相似(都是b),当前剩余真实dom序列中的头移到尾,旧头指针后移,新尾指针前移。b节点确认后,真实dom序列为:a,c,d,e,b,f,未确认dom序列为:c,d,e;
  • 第四次是尾头相似(都是e),当前剩余真实dom序列中的尾移到头,旧尾指针前移,新头指针后移。e节点确认后,真实dom序列为:a,e,c,d,b,f,未确认dom序列为:c,d;
  • 第五次是均不相似,直接插入到未确认dom序列头部。g节点插入后,真实dom序列为:a,e,g,c,d,b,f,未确认dom序列为:c,d;
  • 第六次是均不相似,直接插入到未确认dom序列头部。h节点插入后,真实dom序列为:a,e,g,h,c,d,b,f,未确认dom序列为:c,d; 但结束循环后,有两种情况需要考虑:

新的字节点数组(newCh)被遍历完(newStartIdx > newEndIdx)。那就需要把多余的旧dom(oldStartIdx -> oldEndIdx)都删除,上述例子中就是c,d; 新的字节点数组(oldCh)被遍历完(oldStartIdx > oldEndIdx)。那就需要把多余的新dom(newStartIdx -> newEndIdx)都添加

vue3中diff算法

与vue2的区别

  • 事件缓存:静态事件
  • 添加静态标记:vu2中是全量Diff,vue3是静态标记+非全量Diff
  • 静态提升:创建静态节点时保存,后续直接复用

key

key面试的讲解

  • key的作用主要是为了更高效的更新虚拟DOM,因为使用key可以精确的找到相同节点,patch过程会非常高效
  • vue在patch过程中会判断两个节点是不是相同节点时,key是一个必要条件。比如渲染列表时,如果不写key,vue在比较时,可能会频繁更新元素,市整个patch过程比较低效,影响性能。
  • vue 判断两个节点是否相同同时主要判断两者的元素类型和key等,不设置key,就可能会认为是两个相同节点,只能去做更新操作,造成大量不必要的DOM更新操作。

key存在的价值

key 的特殊属性主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。而使用 key 时,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。

key值使用场景

  • 在列表渲染时使用key属性

    官方文档:当 Vue.js 用v-for正在更新已渲染过的元素列表时,它默认用“就地复用”策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序, 而是简单复用此处每个元素,并且确保它在特定索引下显示已被渲染过的每个元素。

    <div v-for="num in numbers">{{num}}</div>
    

    循环numbers值的变化, 例如: 旧numbers:[1,2,3,5,7.9],新numbers:[0,1,2,3,5,7,9]

    如果没有key属性,原先内容为1的<div>元素内容变成0,原先内容为2的<div>元素内容变成1,……以此类推,最后新增一个<div>元素,内容为9。

    如果没有key属性Vue无法跟踪每个节点,只能通过改变原来元素的内容和增加/减少元素来完成这个改变。

    如果有了key属性之后,Vue会记住元素们的顺序,并根据这个顺序在适当的位置插入/删除元素来完成更新,这种方法比没有key属性时的就地复用策略效率更高。

  • 使用key属性强制替换元素

    key属性还有另外一种使用方法,即强制替换元素,从而可以触发组件的生命周期钩子或者触发过渡。因为当key改变时,Vue认为一个新的元素产生了,从而会新插入一个元素来替换掉原有的元素。

    <transition>
    <span :key="text">{{text}}</span>
    </transition>
    

    如果text发生改变,整个<span>元素会发生更新,因为当text改变时,这个元素的key属性就发生了改变,在渲染更新时,Vue会认为这里新产生了一个元素,而老的元素由于key不存在了,所以会被删除,从而触发了过渡。

假设没有key属性:

 <transition>
 <span>{{text}}</span>
 </transition>

那么当text改变时,Vue会复用元素,只改变<span>元素的内容,而不会有新的元素被添加进来,也不会有旧的元素被删除。

同理,key属性被用在组件上时,当key改变时会引起新组件的创建和原有组件的删除,此时组件的生命周期钩子就会被触发。

key值的工作原理

 /* 
 因为key值主要使用在虚拟DOM算法,即diff算法中。
 所以我们在src\core\vdom\patch.js文件中,从源码级别进行探讨

 先说这里的核心方法patch。这个方法在vue进行update,
 即将render函数(虚拟DOM生成的函数)转化为真实DOM的时候执行,
 里面主要首次渲染创建真实DOM树,进行虚拟DOM节点直接的对比,
 以及真实DOM的更新的一系列操作,并且会进行一系列判断和兼容处理,其中就有对key值的具体使用

 这个方法主要在patch方法中调用
 方法名很语义化 sameVnode === 相同虚拟DOM节点
 */
function sameVnode (a, b) {
    return (
        // 判断a, b两个Vnode上的key值是否相等
        a.key === b.key && (
            (
                a.tag === b.tag &&
                a.isComment === b.isComment &&
                isDef(a.data) === isDef(b.data) &&
                sameInputType(a, b)
            ) || (
                isTrue(a.isAsyncPlaceholder) &&
                a.asyncFactory === b.asyncFactory &&
                isUndef(b.asyncFactory.error)
            )
        )
    )
}


/* 
在简单说下patchVnode方法的作用,这个方法会在patch方法里面调用,
是直接对比新旧虚拟Vnode节点,也是diff算法真正执行的地方

以下代码在patchVnode方法中
在开始进行判断,符合条件的话就跳出方法,不再进行下面的diff对比
vnode.key === oldVnode.key判断双方是不是同一个组件
*/
if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
        vnode.componentInstance = oldVnode.componentInstance
        return
}

在例子中可以看出,对Vnode进行patch的时候会调用sameVnode方法,里面会使用key值是否相等来判断Vnode是否为同一个。并且在对比过程中作为组件复用的一个判断条件。

总结

key值是在DOM树进行diff算法时候发挥作用:

  • 一个是用来判断新旧Vnode是否为同一个,从而进行下一步的比较以及渲染。
  • 另外一个作用就是判断组件是否可以复用,是否需要重新渲染。

额外面试题

  • 虚拟DOM与真实DOM的区别

    • 虚拟DOM不会进行排版与重绘操作

    • 虚拟DOM进行频繁修改,然后一次性比较并修改真实DOM中需要改的部分(注意!),最后并在真实DOM中进行排版与重绘,减少过多DOM节点排版与重绘损耗

    • 真实DOM频繁排版与重绘的效率是相当低的

    • 虚拟DOM有效降低大面积(真实DOM节点)的重绘与排版,因为最终与真实DOM比较差异,可以只渲染局部(同2)

  • v-for循环时,可以使用index代替key么?

    不可以,因为不管你数组的顺序怎么颠倒,index 都是 0, 1, 2 这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作