Vue的虚拟DOM及diff算法

文章目录

      • 一、为什么会出现虚拟DOM
        • 1. 解决代码维护问题
        • 2. 虚拟DOM高效更新视图
      • 二、为什么引入虚拟DOM
        • 1. Vue状态更新视图的方案变更
        • 2. Vue中的虚拟DOM
        • 3. 虚拟DOM的优点
        • 4. 虚拟DOM的缺点
      • 三、vnode
      • 四、虚拟DOM比对(diff算法)
        • 1. 为什么要对比
        • 2. 基本思路
        • 3. 对比策略
        • 4. 复用节点
        • 4. 对比根节点
        • 5. 对比子节点
        • 6. 没有key的子节点对比
      • 五、key的作用
        • 1. 没有设置key
        • 2. 设置了key
        • 3. 为什么不能设置为index
        • 4. 正确用法
        • 5. 总结
      • 六、Vue3 diff算法优化
        • 1. 事件缓存
        • 2. 静态标记
        • 3. 静态提升
        • 4. 节点变更类型细分
        • 5. 子节点对比过程优化
      • 七、参考

通过篇文字,你能了解到

  • 为什么引入虚拟DOM?
  • 为什么操作DOM慢?
  • Vue的怎么对比节点?怎么复用节点?
  • v-for中key 的作用是什么?没有key为什么反而快了?
  • Vue3在diff算法方面做了哪些优化?

一、为什么会出现虚拟DOM

1. 解决代码维护问题
  • Web早期,页面交互比较简单,没有很复杂的状态需要管理,也不太需要频繁地操作DOM,用jQuery来开发就可以满足需求。
  • 随着页面复杂度的提高,程序需要维护的状态越来越多,DOM操作也越来约频繁。当状态变得越来越多,DOM操作越来越频繁时,使用jQuery来开发页面,代码会有相当多的代码实在操作DOM,程序状态也很难管理。
  • 这就是命令式操作DOM的问题,虽然简单易用,但是不好维护。
2. 虚拟DOM高效更新视图
  • 通常程序在运行时,状态会不断发生变化。每当状态发生变化时,都需要重新渲染。

  • 在这种情况下,最简单粗暴的方式是,把所有DOM全删了,然后使用状态重新生成一份DOM。但是,访问DOM是非常耗费性能的,这会造成相当多性能的浪费。

  • 通常状态变化的只有几个节点,只需要更新这几个节点就可以了,问题是这么找到它们。这个问题有很多种解决方案。

  • 虚拟DOM是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在重新渲染之前,会使用新生成的虚拟节点树和旧的虚拟节点树进行对比,只渲染不同的部分。

二、为什么引入虚拟DOM

1. Vue状态更新视图的方案变更
  • 虚拟DOM能够通过比对后进行针对性更新,但不是唯一的方案。Vue.js可以观察到状态的变化,并且绑定到视图,根本不需要比对。

  • 事实上,在Vue2.0之前是这样实现的。但是这样做有一定代价,因为粒度太细,每绑定一个都会有一个对应的watcher来观察状态的变化,这样就会有一些内存开销以及一些依赖追踪的开销。对于大型项目来说,这个开销是非常大的。

  • 所以,从Vue2.0开始,Vue引入了虚拟DOM。从一个节点生成一个Watcher实例变为一个组件生成一个Watcher实例。也就是说,即便一个组件内有10个节点使用了某个状态,但其实也只有一个Watcher在观察这个状态的变化。状态变化时,只能通知到组件,然后在组件内部通过虚拟DOM去比对与渲染。

2. Vue中的虚拟DOM

虚拟DOM主要做了两件事情

  • 提供与真实DOM节点对应的虚拟节点vnode,就是用对象去描述DOM。
  • 每次生成虚拟节点vnode都会缓存下来,将本次生成的虚拟节点vnode和旧虚拟节点oldVnode进行比对,判断出哪些节点发生了变化,从而只对发生了变化的节点进行更新操作。

Vue的虚拟DOM及diff算法_第1张图片

3. 虚拟DOM的优点
  • 简单方便

    如果使用手动操作真实DOM来完成页面,繁琐又容易出错,在大规模应用下维护起来也很困难。

  • 性能好

    使用虚拟DOM,能够有效避免真实DOM数频繁更新,减少重绘与回流,提高性能。

  • 跨平台(最重要)

    Vue和React借助虚拟DOM, 带来了跨平台的能力,一套代码多端运行。

4. 虚拟DOM的缺点
  • 首屏加载时间更长

    因为需要先生成虚拟DOM再渲染出真实的节点,多了生成虚拟DOM这一个步骤。在页面节点多的情况下会增加耗时。

  • 极端场景下不是最优解

    比如当前页面的节点全部替换,那么生成虚拟DOM再去对比替换,都是无效操作。

三、vnode

  • 在Vue.js中存在一个VNode类,使用它可以实例化不同类型的vnode实例,而不同类型的vnode实例各自表示不同类型的DOM元素。
    例如:DOM元素有元素节点、文本节点和注释节点,vnode实例也会对应着有元素节点、文本节点和注释节点等。

  • vnode是Javascript中一个普通的对象,这个对象的属性上保存了生成DOM节点所需要的一些属性。

哈哈

{ 'div', props:{ id:'app', class:'container' }, children: [ { tag: 'h1', children:'哈哈' } ] }

四、虚拟DOM比对(diff算法)

1. 为什么要对比
  • 状态侦测策略

    前面已经说明,Vue.js目前对状态侦测策略采用了中等粒度。当状态发生变化时,只通知到组件级别。

    如果没有虚拟DOM,只要组件使用的众多状态中有一个状态发生了变化,那么整个组件就要重新渲染。这明显浪费性能。

  • 为什么操作DOM慢

    1. 线程之间通信

      因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信

    2. 回流重绘

      操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。

  • 总结

    生成vnode和进行比对的过程也需要消耗时间,但是DOM操作的速度远不如JS的运算速度。因此把大量的DOM操作搬运到JS中,使用patch算法来计算出真正需要更新的节点,最大限度地减少DOM操作。

    本质就是用JS的运算成本来替换DOM操作的执行成本。

2. 基本思路
  • 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点
3. 对比策略
  • 同层对比

    Diff算法比较只会在同层级进行, 不会跨层级比较

  • 深度优先

    比对到相同的节点,会对两个节点的所有子节点都比较完成,才会回到同层的节点继续比对

  • 判断相同(复用)

    两个虚拟节点的标签类型和key值均相同,但input元素还要看type属性。如果相同会进行复用,只修改内部的一些属性。

4. 复用节点

新旧两个虚拟节点经过对比,如果相同,就会直接复用

  • 连接真实DOM

    先将旧节点对应的真实dom赋值到新节点(真实dom连线到新子节点)

  • 对比属性并更新

    然后循环对比新旧节点的属性,看看有没有不一样的地方,将有变化的更新到真实dom中

  • 对比子节点

    比较新旧两个节点的所有子节点

(下面了解一下重新渲染时的对比过程。)

4. 对比根节点
  • 相同(复用节点)
    1. 对比新节点和旧节点的属性,有变化的更新到真实dom中
    2. 当前新旧两个节点处理完成,开始 「对比子节点」
  • 不同(新建)
    1. 新节点递归「新建元素」
    2. 旧节点 「销毁元素」
5. 对比子节点
  • 双指针

    vue使用两个指针分别指向新旧子节点树的头和尾

    流程如图所示
    在这里插入图片描述

  • 比较流程

    注意:

    每次对比完一个节点,头指针或尾指针会向中间移动到下一个节点,继续进行比对。

    当(新/旧)头指针超过尾指针的时候,循环结束,旧虚拟节点上剩余的所有节点对应的真实DOM会被移除。新虚拟节点上剩余的所有节点会创建真实DOM。

    具体流程

    • 首先比较新旧的头,直到第一个不相同的,往下

    • 然后比较新旧的尾,直到第一个不相同的,往下

    • 然后会做头尾,尾头的比较,只要比较结果相同,就移动指针,就重复前面第1、2步的比对

    • 如果前面的比较发现都不相同,无法复用,那就需要:

      1. 把所有oldVnode的 key 做一个映射到oldVnode的索引key -> index 表(遍历oldVnode)

      2. 然后用新 vnodekey 去找出在旧节点中可以复用的位置(遍历vnode),如果存在,就复用,不存在就重建。

      该对比需要遍历两次,时间复杂度为 O(n^2),在Vue3中做了优化

    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    	// 在旧列表中找到 和新列表头节点key 相同的节点
        let newtKey = newStartNode.key,
        oldIndex = prevChildren.findIndex(child => child.key === newKey);
    
        if (oldIndex > -1) {
            let oldNode = prevChildren[oldIndex];
            patch(oldNode, newStartNode, parent)
            parent.insertBefore(oldNode.el, oldStartNode.el)
            // 复用后,设置为 undefined 
            prevChildren[oldIndex] = undefined
        }
    	newStartNode = nextChildren[++newStartIndex]
    }
    
6. 没有key的子节点对比

源码

  • 在比较子节点的过程中,存在 patchKeyedChildren和`patchUnkeyedChildren``
  • ``patchKeyedChildren是存在key的时候执行的,是正式的开启diff的流程,patchUnkeyedChildren`针对没有key的情况。
  • 也就是说,子节点有没有key值,进行的对比算法是不一样的,前面讲的是有key的情况。
if (patchFlag > 0) {
  if (patchFlag & PatchFlags.KEYED_FRAGMENT) { 
     /* 对于存在key的情况用于diff算法 */
     patchKeyedChildren()
  } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
     /* 对于不存在key的情况,直接patch  */
     patchUnkeyedChildren()
  }
}

没有key的流程如下

  • 依次遍历新老vnode进行对比
  • 如果老节点数量大于新的节点数量 ,移除多出来的节点。
  • 如果新的节点数量大于老节点的数量,新增节点。

特点

  • 准确性低

    如果两个相同类型的子节点只需要调换位置就能直接复用,不需要修改的条件下:

    • 没有key:可以复用,但是需要对属性进行修改。
    • 拥有key:能快速找到vnode对应的oldVnode,然后直接复用,只需要移动位置。
  • 为什么可能更快

    因为准确度降低了,在遍历模板简单的情况下,会导致虚拟新旧节点对比更快,节点也会复用。

    比如:同时增加两个和删除两个,没有key的情况下,会进行增删操作;而有key的情况下,会直接复用,修改属性就可以了。

    VUE文档也说明了 这个默认的模式是高效的,但是只适用于不依赖子组件状态或临时 DOM 状态 (例如:表单输入值) 的列表渲染输出

五、key的作用

以 a、b、c、d 四个div为例进行说明

[
  '
1
', // A '
2
', // B '
3
', // C '
4
', // D '
5
' // E ] vm.dataList = [4, 1, 3, 5, 2] // 数据位置替换
1. 没有设置key

没有key的情况, 节点位置不变,但是节点innerText内容更新了

[
  '<div>4div>', // A
  '<div>1div>', // B
  '<div>3div>', // C
  '<div>5div>', // D
  '<div>2div>'  // E
]
2. 设置了key

有key的情况,dom节点位置进行了交换,但是内容没有更新

 // <div v-for="i in dataList" :key='i'>{{ i }}div>
  [
    '<div>4div>', // D
    '<div>1div>', // A
    '<div>3div>', // C
    '<div>5div>', // E
    '<div>2div>'  // B
  ]
3. 为什么不能设置为index
  • 用 index 作为 key 时,在对数据进行破坏顺序的操作的修改时,会产生没必要的真实 DOM更新,从而导致效率低

    // 在前面增加一个div,内容为6
    // 修改了所有div的内容,同时新增了F
    // 如果在末尾新增就不会产生这个问题
    [
      '<div>6div>', // A
      '<div>1div>', // B
      '<div>2div>', // C
      '<div>3div>', // D
      '<div>4div>', // E
      '<div>5div>'  // F
    ]
    
  • 如果结构中包含输入类的 DOM,会产生错误的 DOM 更新(不添加key也同样有这个问题)

    Vue的虚拟DOM及diff算法_第2张图片

4. 正确用法
  • 用唯一值id做key(我们可以用前后端交互的数据源的id为key)。
5. 总结
  • Vue是通过标签名和key来判断两个新旧节点是否相同。缺少key会缺少准确判断并复用节点的依据。在Vue内部会执行与设置key不同的操作。
  • key值的正确做法是设置为唯一的id。

六、Vue3 diff算法优化

1. 事件缓存
  • 在vue2 中,其实每次更新,render函数跑完之后vnode绑定的事件都是一个全新生成的function,就算它们内部的代码是一样的。
  • Vue3缓存我们的事件,事件的变化不会引起重新渲染

举例


export function render(_ctx, _cache, $props, $setup, $data, $options) {

  return (_openBlock(), _createElementBlock("button", {

    onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))

  }, "按钮"))

}
2. 静态标记

Vue3在 patch 过程中就会判断静态标记来跳过一些静态节点对比。

举例

<div id="app">

    <div>哈哈div>

    <p>{{ age }}p>

div>

Vue2编译的结果是:

with(this){

    return _c(

      'div',

      {attrs:{"id":"app"}},

      [ 

        _c('div',[_v("哈哈")]),

        _c('p',[_v(_s(age))])

      ]

    )

}

在 Vue3 中编译的结果是这样的:

const _hoisted_1 = { id: "app" }

const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "哈哈", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {

  return (_openBlock(), _createElementBlock("div", _hoisted_1, [

    _hoisted_2,

    _createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)

  ]))

}

看到上面编译结果中的 -11 了吗,这就是静态标记。

3. 静态提升
  • 在 Vue2 里每当触发更新的时候,不管元素是否参与更新,每次都会全部重新创建vnode
  • Vue3 中会把这个不参与更新的元素保存起来,只创建一次,之后在每次渲染的时候不停地复用。
4. 节点变更类型细分
  • vue2 中 patchVnode 阶段如果是普通节点,会通过内置的update钩子全量进行新旧对比,然后更新
  • Vue3 增加了动态属性标记和变更属性标记,所以只需要对比动态属性和变更的属性
    • 标记出动态属性的的名称,dynamicProps
    • 对变更的vnode进行标记,表示修改了哪些属性patchFlag

例子

hello {{msg}}
// 动态绑定id,对文字进行修改

Vue3生成的vnode会有dynamicProps patchFlag属性
dynamicProps标记出来了动态的属性名称,只有 id 是需要动态对比的
patchFlag99代表的就是文字和属性都有修改

5. 子节点对比过程优化

主要是针对乱序情况

  • 最长递增子序列(减少移动)

    概念:在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。例如:{ 3,5,7,1,2,8 } 的 LIS 是 { 3,5,7,8 },长度为 4

    对于移动、删除、添加、更新这些操作,其中最复杂的就是移动操作,Vue中采用最长递增子序列来求解不需要移动的元素有哪些,所以这个算法的目的就是最大限度的减少移动

    var prev = [1, 2, 3, 4, 5, 6]// 移动前
    
    var next = [1, 3, 2, 6, 4, 5]// 修改移动后
    // 这个时候,可以找到[1,2,4,5],那么需要移动3,6
    // 如果是[1,3,6],那么就需要移动2,4,5
    
  • 流程大致说明

    • 头和头,尾和尾比较,两种比较都不同后,如果数据还没有比较完成。
    • 建一个还未比较的新vnode的key和新索引的映射keyToNewIndexMap
    • 然后用key去获取oldVnode的旧索引,按新索引的顺序放到Map数据中,得到新旧索引的对应关系,从而知道怎么移动节点位置。
  • 详细过程

    // old arr
    ["a", "b", "c", "d", "e", "f", "g", "h"]
    // new arr
    ["a", "b", "d", "f", "c", "e", "x", "y", "g", "h"]
    

    第1步:从头到尾开始比较,[a,b]是sameVnode,进入patch,到 [c] 停止;

    第2步:从尾到头开始比较,[h,g]是sameVnode,进入patch,到 [f] 停止;

    第3步:判断新旧数据是否已经比较完毕,如果旧数据比较完毕,新数据多余的说明是新增的。如果新数据比较完毕,旧数据多余的说明是删除的。

    第4步:如果新旧vnode都没有比较完毕,

    • 建一个还未比较的新vnode的key和index的映射keyToNewIndexMap

      新节点:   a b e c d i g h
      得到了一个值为 {e:2,c:3,d:4,i:5} key与index的映射
      
    • 循环一遍oldVnode剩余数据,通过keyToNewIndexMap中的key获取到在oldVnode中的旧索引,将获取到的旧索引保存到Map数据中。

      注意:该旧索引Map和keyToNewIndexMap长度一样,不足的用-1填充。而且旧索引Map中的排序是按照keyToNewIndexMap中key值的顺序排列。也就是旧索引按照新索引的顺序排列。通过key,得到新旧索引的对应关系,也就知道怎么去移动。

    • 从尾到头循环一下旧索引Map,不是-1 的,说明vnode在oldVnode中存在,是-1的说明是新增节点,接下来进行进行移动/新增/删除就可以了。

    • 使用最长递增子序列可以最大程度的减少 DOM 的移动,达到最少的 DOM 操作。

七、参考

《深入浅出Vue.js》——刘博文

深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别 - 掘金 (juejin.cn)

细致分析,尤雨溪直播中提到 vue3.0 diff 算法优化细节 - 掘金 (juejin.cn)

图解Diff算法——Vue篇-技术圈 (proginn.com)

你可能感兴趣的:(Vue,vue,前端)