vue 虚拟 DOM

目录

一、vue/react 等框架的诞生是为了解决什么问题?

二、虚拟 DOM

1、什么是虚拟DOM?

2、DOM 树的变更 以及 key 属性的作用

(1)、场景一(节点类型不同):移动

(2)、场景二(节点类型不同):删除-->新建

(3)、场景三(节点类型不同):删除-->新建

(4)、场景四(节点类型相同,无 key):更新-->删除-->新建

(5)、场景五(节点类型相同,有 key):更新-->移动

(6)、场景六(节点类型相同,有key):插入

三、Vue2 虚拟 DOM 和 Vue3 虚拟 DOM(❤❤❤)

1、VNode

2、vue2 的 Diff 算法

(1)、Diff 算法的执行

(2)、vue 中 Diff 算法的特点

(3)、vue2 的新旧虚拟 DOM 对比(patch 过程)

3、Vue3 重写了 Diff 算法

(1)、事件缓存

(2)、静态标记和静态提升

(3)、对比子节点的方式不同


一、vue/react 等框架的诞生是为了解决什么问题?

jQuery:在JavaScript基础上简化了操作 DOM 的API。具体来讲就是,通过 jQuery的API绑定事件,然后再通过事件操作DOM。

随着系统的复杂化,事件越来越多,不同的事件操作相同或不同的事件,变得相当繁杂。

为了解决这个痛点,vue之类的框架诞生了。

Vue:引入一个数据的中间层,避免我们直接操作DOM。我们需要关注的仅仅是数据state,所有的事件,我们操作的对象都是数据,由Vue底层将数据映射到DOM 上,数据的变化会导致DOM的更新。

当数据变化后如何尽可能的减少DOM的更新?这是新的难题。于是,虚拟DOM 就被提了出来。

二、虚拟 DOM

1、什么是虚拟DOM?

在引入了虚拟DOM后,数据不是直接反映到真实的节点上,而是先通过数据(state)和模板(template)生成的一个类似DOM的一个树结构,也是一个json对象,这就是虚拟DOM。

虚拟DOM主要用于:让开发者不直接操作DOM元素,只操作数据便可以重新渲染页面。而隐藏在背后的原理便是其高效的Diff算法。

Diff算法:比对新的虚拟DOM树和原来的虚拟DOM树的变化,计算出最终要改变哪些DOM节点,然后再去更新真实DOM的节点。

Virtual DOM Diff:

通常情况下不会出现跨层级的diff,所以,开发人员在 Virtual DOM Diff 的算法中,制定了一个标准:只对同层级的节点进行比较。如上图,同一颜色的进行比较。

2、DOM 树的变更 以及 key 属性的作用

DOM 树的变更规则:

  • 当页面的数据发生变化时,Diff 算法只会比较同一层级的节点:
    • 如果节点类型不同:
      • diff 算法会直接删除原来的节点,再创建并插入新的节点;
    • 如果节点类型相同:
      • 没有key的时候:diff 算法会直接依次重置节点的属性,实现更新。
      • 有key的时候:diff 算法会复用没有变化的节点,然后实现指定节点的移动或插入。

下面分几个场景介绍一下:

(1)、场景一(节点类型不同):移动

上图中的DOM树,对应的的代码结构如下图:

(2)、场景二(节点类型不同):删除-->新建

C节点并没有携带着其子节点直接移动到B节点下,而是将原来的C节点及其子节点一起删除掉,然后在B节点下新建C、E、F节点。这是为什么呢?

由“同层级的节点进行比较”可知:在比较第二层节点时,发现C节点不见了,那就直接删除C节点,然后比较第三层节点,发现新增了C节点,找不到E、F节点,于是新建C节点,同时删除E、F节点,然后比较第四层,发现新增了E、F节点,于是新建E、F节点。

这里就跟jQuery不同了,通过jQuery操作DOM,可以直接将C节点移动到B节点下,但是虚拟DOM为了简化算法的时间复杂度,只允许进行同级比较,但这样的弊端就是不能直接跨层级移动DOM了。

(3)、场景三(节点类型不同):删除-->新建

 由“同层级的节点进行比较”可知,先删除C节点及其子节点,新建G节点,然后新建E、F节点。

(4)、场景四(节点类型相同,无 key):更新-->删除-->新建

对应的代码结构如下:

由“同层级的节点进行比较”可知,算法在比较第二层时,B1节点和B2节点发生了更新,但是此时,它们仍无法移动E、F节点,而被删除,也就是说E、F节点仍然没有得到合理的复用。等到比对第三层节点的时候,算法会在B2节点下新建E、F节点。

如何让E、F节点复用呢?

关键在于,让算法知道,我们不是要更新节点,而是要移动节点——这就是 key 要做的事情。

(5)、场景五(节点类型相同,有 key):更新-->移动

节点加上key之后,每一个节点就有了唯一的一个标识符。

有key的时候,算法认为是“移动”——复用原来的节点。

这就进化成了场景一。

(6)、场景六(节点类型相同,有key):插入

没有key的时候,算法认为是“更新”——删除-->新建——

对比第二层节点时,首先,算法会删除B2节点,新建B4节点,然后,再删除B3节点,新建B2节点,最后,新建B3节点。

有key的时候,算法认为是“移动”——复用原来的节点,插入新的节点——

对比第二层节点时,直接复用B1、B2、B3节点,插入B4节点。

【总结】

根据上述的六个场景,建议:尽可能在使用 v-for 时提供 key 属性,除非遍历输出的 DOM 内容非常简单。

三、Vue2 虚拟 DOM 和 Vue3 虚拟 DOM(❤❤❤)

参考:深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别

1、VNode

虚拟 DOM(又叫 VNode) 简单说就是 用JS对象来模拟 DOM 结构

例如:

<template>
    <div id="app" class="container">
        <h1>title</h1>
    </div>
</template>

用 JS 对象模拟 DOM 结构:

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

 它的表达方式就是把每一个标签都转为一个对象,这个对象可以有三个属性:

  • tag:必选。就是标签。也可以是组件,或者函数
  • props:非必选。就是这个标签上的属性和方法
  • children:非必选。就是这个标签的内容或者子节点,如果是文本节点就是字符串,如果有子节点就是数组。换句话说 如果判断 children 是字符串的话,就表示一定是文本节点,这个节点肯定没有子元素。

2、vue2 的 Diff 算法

Diff 算法(在 Vue 里面就是叫做 patch)通过新旧虚拟 DOM 对比(即 patch 过程),找出最小变化的地方转为进行 DOM 操作

(1)、Diff 算法的执行

  • 在页面首次渲染的时:会调用一次 patch 并创建新的 vnode,不会进行更深层次的比较。
  • 在组件中数据发生变化时:会触发 setter 然后通过 Notify 通知 Watcher,对应的 Watcher 会通知更新并执行更新函数,它会执行 render 函数获取新的虚拟 DOM,然后执行 patch 对比上次渲染结果的老的虚拟 DOM,并计算出最小的变化,然后再去根据这个最小的变化去更新真实的 DOM,也就是视图。

Vue 或者 React 里使用 Diff 算法的时候都遵循深度优先,同层比较的策略做了一些优化,来计算出最小变化。

(2)、vue 中 Diff 算法的特点

  • 同层级比较,不跨级。
  • 比较标签名。
  • 比较 key。

①、同层级比较,不跨级

②、比较标签名

③、比较 key

  • key 的作用主要是为了更高效的更新虚拟 DOM,因为它可以非常精确的找到相同节点,因此 patch 过程会非常高效。
  • Vue 在 patch 过程中会判断两个节点是不是相同节点时,key 是一个必要条件。比如渲染列表时,如果不写 key,Vue 在比较的时候,就可能会导致频繁更新元素,使整个 patch 过程比较低效,影响性能。
  • 应该避免使用数组下标作为 key,因为 key 值不是唯一的话可能会导致上面图中表示的 bug,使 Vue 无法区分它他,还有比如在使用相同标签元素过渡切换的时候,就会导致只替换其内部属性而不会触发过渡效果。

不使用 key 或者列表的 index 作为 key 的时候,每个元素对应的位置关系都是 index。插入一个元素时,其后面的元素都会重新渲染。

在使用唯一 key 的情况下,每个元素对应的位置关系就是 key。插入一个元素时,其后面的元素不会重新渲染。 

(3)、vue2 的新旧虚拟 DOM 对比(patch 过程)

vue2 的新旧虚拟 DOM 对比过程,就是 vue 里的 patch 过程。patch 就是一个函数,它可以接收 4 个参数(主要还是前两个):

  • oldVnode:老的虚拟 DOM 节点。
  • vnode:新的虚拟 DOM 节点。
  • hydrating:是不是要和真实 DOM 混合,服务端渲染的话会用到,这里不过多说明。
  • removeOnly:transition-group 会用到,这里不过多说明。

vue2 的新旧虚拟 DOM 对比的主要流程是这样的:

  • vnode 不存在,oldVnode 存在,就删掉 oldVnode
  • vnode 存在,oldVnode 不存在,就创建 vnode
  • 两个都存在的话,通过 sameVnode() 函数对比是不是同一节点
    • 如果是同一节点的话,通过 patchVnode 进行后续对比节点文本变化或子节点变化
    • 如果不是同一节点,就把 vnode 挂载到 oldVnode 的父元素下
      • 如果组件的根节点被替换,就遍历更新父节点,然后删掉旧的节点
      • 如果是服务端渲染就用 hydrating 把 oldVnode 和真实 DOM 混合
  • 当新的 vnode 和 oldVnode 都有子节点,且子节点不一样的时,需要在 updateChildren() 函数里对比子节点变化,顺序依次是:
    • 新的头和老的头对比。
    • 新的尾和老的尾对比。
    • 新的头和老的尾对比。
    • 新的尾和老的头对比。 

 【拓展】为什么会有头对尾,尾对头的操作?

因为可以快速检测出 reverse(交换、翻转) 操作,加快 Diff 效率。

3、Vue3 重写了 Diff 算法

相较于 vue2 的 Diff 算法,vue3 的 Diff 算法做了如下优化:

  • 事件缓存:Vue3 将事件缓存,可以理解为变成静态的了。而在 Vue2 中的事件都是动态的。
  • 静态标记和静态提升
    • Vue2 是全量 Diff,Vue3 是静态标记 + 非全量 Diff。
    • 创建静态节点时保存,后续直接复用。
  • 对比子节点的方式不同
    • Vue3 用 patchKeyedChildren() 函数代替了 Vue2 的 updateChildren() 函数来对比子节点的变更。
    • Vue3 的 Diff 算法里使用了 “最长递增子序列” 优化了对比流程。

(1)、事件缓存

在 vue3 中,事件可以自动会被缓存成静态的,事件触发时会先读取缓存,如果缓存没有的话,就把传入的事件存到缓存里。而在 Vue2 中事件就没有缓存,就是动态的。

例如,这样一个有点击事件的按钮:

<button @click="handleClick">按钮</button>

 来看下在 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)))
  }, "按钮"))
}

 注意看,onClick 会先读取缓存,如果缓存没有的话,就把传入的事件存到缓存里,都可以理解为变成静态节点了,优秀吧,而在 Vue2 中就没有缓存,就是动态的。

(2)、静态标记和静态提升

vue3 新增了静态标记,并基于此进做了静态提升。这些在 vue 2 中是没有的。

什么是静态标记?下面举例说明。

<div id="app">
    <div>title</div>
    <p>{{ age }}</p>
</div>

在 Vue2 中编译的结果是,有兴趣的可以自行安装 vue-template-compiler 自行测试:

with(this){
    return _c(
      'div',
      {attrs:{"id":"app"}},
      [ 
        _c('div',[_v("title")]),
        _c('p',[_v(_s(age))])
      ]
    )
}

 在 Vue3 中编译的结果是这样的,有兴趣的可以点击这里自行测试:

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "title", -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 */)
  ]))
}

看到上面编译结果中的 -1 和 1 了吗,这就是静态标记,这是在 Vue2 中没有的,patch 过程中就会判断这个标记来 Diff 优化流程,跳过一些静态节点对比。

认清了静态标记后,下面就来说说静态提升。

在 Vue2 里每当触发更新的时候,不管元素是否参与更新,每次都会全部重新创建,就是下面这一堆:

with(this){
    return _c(
      'div',
      {attrs:{"id":"app"}},
      [ 
        _c('div',[_v("title")]),
        _c('p',[_v(_s(age))])
      ]
    )
}

在 Vue3 中会把这个不参与更新的元素保存起来,只创建一次,之后在每次渲染的时候不停地复用,比如上面例子中的这个,静态的创建一次保存起来。

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "title", -1 /* HOISTED */)

然后每次更新 age 的时候,就只创建这个动态的内容,复用上面保存的静态内容。

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 */)
  ]))
}

(3)、对比子节点的方式不同

在 Vue2 里 updateChildren 会进行:

  • 头和头比
  • 尾和尾比
  • 头和尾比
  • 尾和头比
  • 都没有命中的对比

在 Vue3 里 patchKeyedChildren 为:

  • 头和头比
  • 尾和尾比
  • 基于最长递增子序列进行移动、添加、删除

vue3 的虚拟 DOM 对比典例】现有:

老的 children:[ a, b, c, d, e, f, g ]
新的 children:[ a, b, f, c, d, e, h, g ]

他们在 vue3 的虚拟 DOM 中的对比过程如下:

  • 先进行头和头比,发现不同就结束循环,得到 [ a, b ]。
  • 再进行尾和尾比,发现不同就结束循环,得到 [ g ]。
  • 再保存没有比较过的节点 [ f, c, d, e, h ],并通过 newIndexToOldIndexMap 拿到在数组里对应的下标,生成数组 [ 5, 2, 3, 4, -1 ],-1 是老数组里没有的就说明是新增。
  • 然后再拿取出数组里的最长递增子序列,也就是 [ 2, 3, 4 ] 对应的节点 [ c, d, e ]。
  • 最后只需把其他剩余的节点基于最长递增子序列位置进行移动/新增/删除就可以了,也就是基于 [ c, d, e ] 的位置把剩余的节点进行移动/新增/删除就可以了。

【拓展】最长递增子序列:

使用最长递增子序列可以最大程度的减少 DOM 的移动,达到最少的 DOM 操作。有兴趣的话去 leet-code 第300题(最长递增子序列)体验下。

【参考文章】

深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值