Vue2、React -- Virtual DOM 及 Diff 算法

630 阅读28分钟

一、 React -- Virtual DOM

JSX到底是什么

弄清楚JSX是什么对我们学习Virtual DOM有着重要的意义,JSX看起来像HTML而已,但是不是HTML。JSX实际上是JavaScript代码,是React团队创造的一种JavaScript的语法扩展,用于来描述用户界面的。他的出现是为了React开发者能够在JavaScript当中更好更方便的去描述用户界面,但是浏览器是不认识JSX的,不能够执行它的。所以在JSX执行之前babel会去先编译JSX代码,将其编译成浏览器能够执行的JavaScript代码!

<div className="container">
    <h3>Hello React</h3>
    <p>React is great</p>
</div>

babel编译后

React.createElement (
    "div",
    {
        className: "container"
    },
    React.createElement("h3", null, "Hello React"),
    React.createElement("p", null, "React is great")
)

上面两种写法是等价的,JSX只是为 React.createElement(component, props, ...children) 方法提供的语法糖。也就是说所有的JSX 代码最后都会转换成React.createElement(...) Babel帮助我们完成了这个转换的过程。

React.createElement方法第一个参数是节点类型,值为节点名称字符串的形式,第二个参数是节点属性,第三个后之后的参数都是当前节点的子节点.

React.createElement这个方法就是用来创建Virtual DOM对象,其实也就是JavaScript对象,这就是使用JavaScript对象来描述DOM对象的一种实现方式。React.createElement的返回值就是Virtual DOM,然后React会将 VirtualDOM转化为真实DOM对象,再将真实DOM显示在页面当中。这就是JSX转换的过程

注意:babel在编译时会判断JSX中组件的首字母,当首字母为小写时,其被认定为原生DOM标签,createElement的第一个变量被编译为字符串;当首字母为大写时,其被认定为自定义组件,createElement的第一个变量被编译为对象;

如果React开发人员使用下面的写法描述用户界面就太繁琐了!!!

我们可以使用Babel REPL来体验一下JSX转化js代码是怎么样的

DOM操作问题

用脚本进行DOM操作的代价很昂贵。有个贴切的比喻,把DOM和JavaScript各自想象为一个岛屿,它们之间用收费桥梁连接,js每次访问DOM,都要途径这座桥,并交纳“过桥费”,访问DOM的次数越多,费用也就越高。 因此,推荐的做法是尽量减少过桥的次数,努力待在ECMAScript岛上。 现代浏览器使用JavaScript操作DOM是必不可少的,但是这个动作是非常消耗性能的,因为使用JavaScript操作DOM对象要比JavaScript操作普通对象要慢很多,页面如果频繁的DOM操作会造成页面卡顿,应用流畅度降低,造成非常不好的体验。

大多数的JavaScript框架对于DOM的更新都会远远超过必须进行的更新,从而使得这种缓慢操作变得更糟,例如假设你有包含是个项目的列表,你仅仅更改了列表中的一项大多数JavaScript框架会重建整个列表,这比必要的工作要多十倍,更新效率低下已经成为严重的问题

因为这个原因react的虚拟dom就显得难能可贵了,它创造了虚拟dom并且将它们储存起来,每当状态发生变化的时候就会创造新的虚拟节点和以前的进行对比,让变化的部分进行渲染.大大提高了JavaScript操作DOM的效率,那接下来我们了解下Virtual DOM是什么

Virtual DOM 是什么

Virtual DOM对象实际上就是JavaScript对象,使用JavaScript对象来描述DOM对象信息,比如DOM对象的类型是什么,他身上有哪些属性,它拥有哪些子元素。

也可以把Virtual DOM对象理解为DOM对象的副本,但是不能直接显示在屏幕上

<div className="container">
  <h3>Hello React</h3>
  <p>React is great </p>
</div>

下面就是virtual DOM对象,type属性表示节点的类型信息,props属性表示节点的属性信息,用children属性表示节点的子节点信息。节点的文本信息也断是props属性里面的textContent。可以对应上面JSX,看看对应virtral DOM对象的基本结构

{
  type: "div",
  props: { className: "container" },
  children: [
    {
      type: "h3",
      props: null,
      children: [
        {
          type: "text",
          props: {
            textContent: "Hello React"
          }
        }
      ]
    },
    {
      type: "p",
      props: null,
      children: [
        {
          type: "text",
          props: {
            textContent: "React is great"
          }
        }
      ]
    }
  ]
}

了解完Virtual DOM之后,再来熟悉Virtual DOM是如何提升效率的

跨浏览器兼容

React基于VitrualDom自己实现了一套自己的事件机制,自己模拟了事件冒泡和捕获的过程,采用了事件代理,批量更新等方法,抹平了各个浏览器的事件兼容性问题。

跨平台兼容

VitrualDomReact带来了跨平台渲染的能力。以React Native为例子。React根据VitrualDom画出相应平台的ui层,只不过不同平台画的姿势不同而已。

Virtual DOM 如何提升效率

最核心的原则就是最小化DOM操作,精准找出发生变化的DOM对象,只更新发生变化的部分。

在React第一次创建DOM对象后,会为每个DOM对象创建其对应的Virtual DOM对象,在DOM对象发生更新之前,React会先更新所有的Virtual DOM对象,让后React会将更新后的Virtual DOM和更新前的Virtual DOM进行比较,从而找出发生变化的部分,React会将发生变化的部分更新到真实的DOM对象中,React仅更新必要更新的部分,从而提高了JavaScript操作DOM的性能。操作virtualDOM的时候就是JavaScript操作JavaScript对象是非常快的,几乎可以忽略不计,因为Virtual DOM 对象的更新和比较仅发生在内存中,不会在视图中渲染任何内容,所以这一部分的性能损耗成本是微不足道的。

image.png 左边是Virtual DOM,右边是Real DOM,React会拿更新前的virtral DOM和更新后的 Virtual DOM进行比对,发现在整个DOM树中删除一个节点,React在更新真实DOM对象的时候,就只会删除这一个节点,而不是更新整个DOM树,从而提高了DOM操作性能

更新前JSX

<div id="container">
	<p>Hello React</p>
</div>

更新后JSX

<div id="container">
	<p>Hello Angular</p>
</div>

可以看出是p标签里面的文本发生了变化,下面可以看一下虚拟DOM是如何进行对比的

更新前的虚拟DOM

const before = 
  type: "div",
  props: { id: "container" },
  children: [
    {
      type: "p",
      props: null,
      children: [
        { type: "text", props: { textContent: "Hello React" } }
      ]
    }
  ]
}

更新后的虚拟DOM

const after = {
  type: "div",
  props: { id: "container" },
  children: [
    {
      type: "p",
      props: null,
      children: [
        { type: "text", props: { textContent: "Hello Angular" } }
      ]
    }
  ]
}

这两个虚拟DOM会进行对比,很容易看出只有子节点里面的内容发生改变,React只会将这个改变的内容节点更新到真实DOM中,这样就起到了提高操作DOM的效率。

小结

现在我们就真正知道什么是Virtual DOM对象了,实际上就是JavaScript对象,使用JavaScript对象来描述真实dom对象的一种方式,我们还清楚了它是如何提高操作DOM效率了,就是通过对比新老Virtual DOM对象从中找出差异,最终只更新DOM对象差异的部分,从而提升JavaScript操作DOM的效率。

二、 React -- diff算法

React需要同时维护两棵虚拟DOM树:一棵表示当前的DOM结构,另一棵在React状态变更将要重新渲染时生成。React通过比较这两棵树的差异,决定是否需要修改DOM结构,以及如何修改。这种算法称作Diff算法。

这个算法问题有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作数。 然而,即使在最前沿的算法中,传统的diff算法是使用循环递归对节点进行依次对比,复杂度为O(n^3),效率低下。其中 n 是树中元素的数量。

如果在 React 中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。 所以为了降低算法复杂度,React的diff会预设3个限制:

  1. 同级元素进行Diff,如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  2. 不同类型的元素会产生出不同的树,如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 可以通过 key 来暗示哪些子元素在不同的渲染下能保持稳定。

React diff算法大致执行过程:

前置知识: 二叉树的深度优先遍历(DFS)与广度优先遍历(BFS)

深度优先遍历:从根节点出发,沿着左子树方向进行纵向遍历,直到找到叶子节点为止。然后回溯到前一个节点,进行右子树节点的遍历,直到遍历完所有可达节点为止。

广度优先遍历:从根节点出发,在横向遍历二叉树层段节点的基础上纵向遍历二叉树的层次

Diff算法会对新旧两棵树做深度优先遍历,避免对两棵树做完全比较,因此算法复杂度可以达到O(n)。然后给每个节点生成一个唯一的标志:

在这里插入图片描述 在遍历的过程中,每遍历到一个节点,就将新旧两棵树作比较,并且只对同一级别的元素进行比较:

在这里插入图片描述 也就是只比较图中用虚线连接起来的部分,把前后差异记录下来。

React diff算法具体策略:

  • 针对树结构(tree diff):对UI层的DOM节点跨层级的操作进行忽略。(数量少)

  • 针对组件结构(component diff):拥有相同的两个组件生成相似的树形结构,拥有不同的两个组件会生成不同的属性结构。

  • 针对元素结构(element-diff): 对于同一层级的一组节点,使用具有唯一性的id区分 (key属性)

(1)tree diff:

tree diff只会对相同层级的节点进行比较。由于跨层级的DOM移动操作较少,所以React diff算法的tree diff没有针对此种操作进行深入比较,只是简单进行了删除和创建操作

在这里插入图片描述 如图所示,A 节点(包括其子节点)整个被移动到 D 节点下,由于 React 只会简单地考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。

  • 当根节点发现子节点中 A 消失了,就会直接销毁 A;该节点与其所有子节点会被完全删除,不在进行进一步比较。当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点)作为其子节点。此时,diff 的执行情况:create A → create B → create C → delete A
  • 只需要遍历一次,便完成对整个DOM树的比较。

由此可以发现,当出现节点跨层级移动时,并不会出现想象中的移动操作,而是以 A 为根节点的整个树被重新创建。这是一种影响 React 性能的操作,因此官方建议不要进行 DOM 节点跨层级的操作。

基于上述原因,在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏或显示节点,而不是真正地移除或添加 DOM 节点

(2)component diff:

component diff是专门针对更新前后的同一层级间的React组件比较的diff 算法:

  • 如果是同一类型的组件,按照原策略继续比较 Virtual DOM 树(例如继续比较组件props和组件里的子节点及其属性)即可。
  • 如果不是,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点,即销毁原组件,创建新组件。
  • 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切知道这点,那么就可以节省大量的 diff 运算时间。因此,React 允许用户通过 shouldComponentUpdate()来判断该组件是否需要进行 diff 算法分析

在这里插入图片描述

如图 所示,当组件 D 变为组件 G 时,即使这两个组件结构相似,一旦 React 判断 D 和G 是不同类型的组件,就不会比较二者的结构,而是直接删除组件 D,重新创建组件 G 及其子节点。

虽然当两个组件是不同类型但结构相似时,diff 会影响性能,但正如 React 官方博客所言:不同类型的组件很少存在相似 DOM树的情况,因此这种极端因素很难在实际开发过程中造成重大的影响

(3)element diff:

element diff是专门针对同一层级的所有节点(包括元素节点和组件节点)的diff算法。当节点处于同一层级时,diff 提供了 3 种节点操作,分别为 INSERT_MARKUP(插入)MOVE_EXISTING(移动)REMOVE_NODE(删除)

我们将虚拟dom树中欲比较的某同一层级的所有节点的集合分别称为新集合和旧集合,则有以下策略:

  • INSERT_MARKUP:新集合的某个类型组件或元素节点不存在旧集合里,即全新的节点,需要对新节点执行插入操作。
  • MOVE_EXISTING:新集合的某个类型组件或元素节点存在旧集合里,且 element 是可更新的类型,generateComponent-Children 已调用receiveComponent,这种情况下 prevChild=nextChild,就需要做移动操作,可以复用以前的 DOM 节点。
  • REMOVE_NODE:旧集合的某个组件或节点类型,在新集合里也有,但对应的 element 不同则不能直接复用和更新,需要执行删除操作,或者旧组件或节点不在新集合里的,也需要执行删除操作。

在这里插入图片描述 如图 所示,旧集合中包含节点A、B、C 和 D,更新后的新集合中包含节点 B、A、D 和C(只是发生了位置变化,各自节点以及内部数据没有变化),此时新旧集合按顺序进行逐一的diff 差异化对比,发现 B != A,则创建并插入 B 至新集合,删除旧集合 A;以此类推,创建并插入 A、D 和 C,删除 B、C 和 D。

React 发现这类操作烦琐冗余,因为这些都是相同的节点,但由于位置顺序发生变化,导致需要进行繁杂低效的删除、创建操作,其实只要对这些节点进行位置移动即可。

针对这一现象,React 提出优化策略:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分 。见下面key机制

三、 React -- key机制

(1)key的作用:

当同一层级的某个节点添加了对于其他同级节点唯一的key属性,当它在当前层级的位置发生了变化后。react diff算法通过新旧节点比较后,如果发现了key值相同的新旧节点,就会执行移动操作(然后依然按原策略深入节点内部的差异对比更新),而不会执行原策略的删除旧节点,创建新节点的操作。这无疑大大提高了React性能和渲染效率

(2)key的具体执行过程:

首先对新集合的节点进行循环遍历,for (name in nextChildren),通过唯一 key 可以判断新老集合中是否存在相同的节点,if (prevChild === nextChild),如果存在相同节点,则进行移动操作,但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较,if (child._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点在老集合中的位置比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。

例子1:同一层级的所有节点只发生了位置变化: 在这里插入图片描述

以上图为例,可以更为清晰直观的描述 diff 的差异对比过程:

  • 从新集合中取得 B,判断老集合中存在相同节点 B,通过对比节点位置判断是否进行移动操作,B 在老集合中的位置 B._mountIndex = 1,此时 lastIndex = 0,不满足 child._mountIndex < lastIndex 的条件,因此不对 B 进行移动操作;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),其中 prevChild._mountIndex 表示 B 在老集合中的位置,则 lastIndex = 1,并将 B 的位置更新为新集合中的位置prevChild._mountIndex = nextIndex,此时新集合中 B._mountIndex = 0,nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 A,判断老集合中存在相同节点 A,通过对比节点位置判断是否进行移动操作,A 在老集合中的位置 A._mountIndex = 0,此时 lastIndex = 1,满足 child._mountIndex < lastIndex的条件,因此对 A 进行移动操作enqueueMove(this, child._mountIndex, toIndex),其中 toIndex 其实就是 nextIndex,表示 A 需要移动到的位置;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 1,并将 A 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中A._mountIndex = 1nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 D,判断老集合中存在相同节点 D,通过对比节点位置判断是否进行移动操作,D 在老集合中的位置 D._mountIndex = 3,此时 lastIndex = 1,不满足 child._mountIndex < lastIndex的条件,因此不对 D 进行移动操作;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 3,并将 D 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中D._mountIndex = 2nextIndex++ 进入下一个节点的判断。
  • 从新集合中取得 C,判断老集合中存在相同节点 C,通过对比节点位置判断是否进行移动操作,C 在老集合中的位置 C._mountIndex = 2,此时 lastIndex = 3,满足 child._mountIndex < lastIndex 的条件,因此对 C 进行移动操作 enqueueMove(this, child._mountIndex, toIndex);更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 3,并将 C 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中 C._mountIndex = 3nextIndex++ 进入下一个节点的判断,由于 C 已经是最后一个节点,因此 diff 到此完成。

当ABCD节点比较完成后,diff过程还没完,还会整体遍历老集合中节点,看有没有没用到的节点,有的话,就删除

上述结论中的移动操作即对节点进行更新渲染,而不进行移动则表示无需更新渲染

例子2:同一层级的所有节点发生了节点增删和节点位置变化:

在这里插入图片描述

  1. 同上面那种情形,B不进行移动,lastIndex=1
  2. 新集合中取得E,发现旧中不存在E,在 lastIndex处创建E,lastIndex++
  3. 在旧集合中取到C,C不移动,lastIndex=2
  4. 在旧集合中取到A,A移动到新集合中的位置,lastIndex=2
  5. 完成新集合中所有节点diff后,对旧集合进行循环遍历,寻找新集合中不存在但就集合中的节点(此例中为D),删除D节点。

(3)index作为key:

react中常常会用到通过遍历(如Array.map)来在当前层级动态生成多个子节点的操作。这是常见的列表数据渲染场景。

React官方建议不要用遍历的index作为这种场景下的节点的key属性值。比如当前遍历的所有节点类型都相同,其内部文本不同,在用index作key的情况下,当我们对原始的数据list进行了某些元素的顺序改变操作,导致了新旧集合中在进行diff比较时,相同index所对应的新旧的节点其文本不一致了,就会出现一些节点需要更新渲染文本,而如果用了其他稳定的唯一标识符作为key,则只会发生位置顺序变化,无需更新渲染文本,提升了性能。

此外使用index作为key很可能会存在一些出人意料的显示错误的问题:

{this.state.data.map((v,index) => <Item key={index} v={v} />)}
// 开始时:['a','b','c']=>
<ul>
    <li key="0">a <input type="text"/></li>
    <li key="1">b <input type="text"/></li>
    <li key="2">c <input type="text"/></li>
</ul>

// 数组重排 -> ['c','b','a'] =>
<ul>
    <li key="0">c <input type="text"/></li>
    <li key="1">b <input type="text"/></li>
    <li key="2">a <input type="text"/></li>
</ul>

上面实例中在数组重新排序后,key对应的实例都没有销毁,而是重新更新。具体更新过程我们拿key=0的元素来说明, 数组重新排序后:

  • 组件重新render得到新的虚拟dom;
  • 新老两个虚拟dom进行diff,新老版的都有key=0的组件,react认为同一个组件,则只可能更新组件;
  • 然后比较其children,发现内容的文本内容不同(由a--->c),而input组件并没有变化,这时触发组件的componentWillReceiveProps方法,从而更新其子组件文本内容;
  • 因为组件的children中input组件没有变化,其又与父组件传入的任props没有关联,所以input组件不会更新(即其componentWillReceiveProps方法不会被执行),导致用户输入的值不会变化。

在这里插入图片描述

(4)key机制的缺点:

在这里插入图片描述 如图 所示,若新集合的节点更新为 D、A、 B、C,与旧集合相比只有 D 节点移动,而 A、B、C 仍然保持原有的顺序,理论上 diff 应该只需对 D 执行移动操作,然而由于 D 在旧集合中的位置是最大的,导致其他节点的 _mountIndex <lastIndex,造成 D 没有执行移动操作,而是 A、B、C 全部移动到 D 节点后面的现象.

在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作。当节点数量过大或更新操作过于频繁时,这在一定程度上会影响 React 的渲染性能。。

(5)key使用注意事项:

  1. 如果遍历的列表子节是作为纯展示,而不涉及到列表元素顺序的动态变更,那使用index作为key还是没有问题的。
  2. key只是针对同一层级的节点进行了diff比较优化,而跨层级的节点互相之间的key值没有影响
  3. 大部分情况下,通过遍历的同一层级的使用了key属性的元素节点其节点类型是相同的(比如都是span元素或者同一个组件)。如果存在新旧集合中,相同的key值所对应的节点类型不同(比如从span变成div),这相当于完全替换了旧节点,删除了旧节点,创建了新节点。
  4. 如果新集合中,出现了旧集合没有存在过的key值。例如某个节点的key之前为1,现在为100,但旧集合中其他节点也没有使用100这个key值。说明没发生过移动操作,此时diff算法会对对应的节点进行销毁并重新创建。这在一些场景中会比较有用(比如重置某个组件的状态)
  5. key值在比较之前都会被执行toString()操作,所以尽量不要使用object类型的值作为key,会导致同一层级出现key值相同的节点。key值重复的同一类型的节点或组件很可能出现拷贝重复内部子元素的问题

四、 Vue -- Virtual DOM

模板转换成视图的过程

  1. Vue.js通过编译将template 模板转换成渲染函数(h) ,执行渲染函数就可以得到一个虚拟节点树。
  2. 在对 Model 进行操作的时候,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。这个过程主要是将新旧虚拟节点进行差异对比,然后根据对比结果进行DOM操作来更新视图。 Vue的渲染流程
  3. 把模板编译为render函数
  4. 实例进行挂载, 根据根节点render函数的调用,递归的生成虚拟dom
  5. 对比虚拟dom,渲染到真实dom
  6. 组件内部data发生变化,组件和子组件引用data作为props重新调用render函数,生成虚拟dom, 返回到步骤3

Virtual DOM

1.定义

Virtual dom, 即虚拟DOM节点。它通过JS的Object对象模拟DOM中的节点,然后再通过特定的render方法将其渲染成真实的DOM节点。

所以就变成 代码 => Virtual DOM( 一个特殊的js对象) => DOM

Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。

所以说,虚拟DOM就是一个特殊的js对象,包含一些属性来定义一个虚拟DOM,比如最少包含tag props children等。

下面是Vue中对Vnode的定义:

export class VNode {
  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.functionalContext = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }
}

简单来说,可以把Virtual DOM 理解为一个简单的JS对象,其中几个比较重要的属性:

  • tag 属性即这个vnode的标签属性
  • data 属性包含了最后渲染成真实dom节点后,节点上的class,attribute,style以及绑定的事件
  • children 属性是vnode的子节点
  • text 属性是文本属性
  • elm 属性为这个vnode对应的真实dom节点
  • key 属性是vnode的标记,在diff过程中可以提高diff的效率,后文有讲解

真实的元素节点

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

VNode

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

对于虚拟DOM,咱们来看一个简单的实例,就是下图所示的这个,详细的阐述了模板 → 渲染函数 → 虚拟DOM树 → 真实DOM的一个过程

2540892611-5da18b4c51e59_fix732.png

2.作用

虚拟DOM的最终目标是将虚拟节点渲染到视图上。但是如果直接使用虚拟节点覆盖旧节点的话,会有很多不必要的DOM操作。例如,一个ul标签下很多个li标签,其中只有一个li有变化,这种情况下如果使用新的ul去替代旧的ul,因为这些不必要的DOM操作而造成了性能上的浪费。

为了避免不必要的DOM操作,虚拟DOM在虚拟节点映射到视图的过程中,将虚拟节点与上一次渲染视图所使用的旧虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,从而避免操作其他无需改动的DOM。

其实虚拟DOM在Vue.js主要做了两件事:

  • 提供与真实DOM节点所对应的虚拟节点vnode
  • 将虚拟节点vnode和旧虚拟节点oldVnode进行对比,然后更新视图

3.优势:

  • 具备跨平台的优势--兼容性好:  由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。

  • 操作 DOM 慢,js运行效率高我们可以将DOM对比操作放在JS层,提高效率:  因为DOM操作的执行速度远不如Javascript的运算速度快,因此,把大量的DOM操作搬运到Javascript中,运用patching算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。

  • 提升渲染性能:  Virtual DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。

4. 虚拟DOM和真实DOM的区别?

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

  • 虚拟DOM不会进行回流和重绘;
  • 真实DOM在频繁操作时引发的回流重绘导致性能很低;
  • 虚拟DOM频繁修改,然后一次性对比差异并修改真实DOM,最后进行依次回流重绘,减少了真实DOM中多次回流重绘引起的性能损耗;
  • 虚拟DOM有效降低大面积的重绘与排版,因为是和真实DOM对比,更新差异部分,所以只渲染局部;
总损耗 = 真实DOM增删改 + (多节点)回流/重绘;    //计算使用真实DOM的损耗
总损耗 = 虚拟DOM增删改 + (diff对比)真实DOM差异化增删改 + (较少节点)回流/重绘;   //计算使用虚拟DOM的损耗

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

那么虚拟DOM究竟通过什么方式来减少了页面中频繁操作DOM呢?这就不得不去了解DOM Diff算法了。

五、 Vue -- diff算法

vdom因为是纯粹的JS对象,所以操作它会很高效,但是vdom的变更最终会转换成DOM操作,为了实现高效的DOM操作,一套高效的虚拟DOM diff算法显得很有必要

diff算法包括一下几个步骤:

  • 用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文

档当中

  • 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较(diff),记录两棵树差异
  • 把2所记录的差异应用到步骤1所构建的真正的DOM树上(patch),视图就更新了

总结一下就是当数据变化时,一开始会根据真实DOM生成虚拟DOM,当虚拟DOM某个节点的数据改变后会生成一个新的Vnode,然后VNode和oldVnode对比,把不同的地方修改在真实DOM上(通过patch实现虚拟dom=>真实dom),最后再使得oldVnode的值为Vnode。

diff算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n),是一种相当高效的算法,也是深度优先遍历

118944587-5da18b4c5198b_fix732.png

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

对照vue源码来解析一下,贴出核心代码,旨在简单明了讲述清楚:

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进行子节点比较;
  • 如果不是同一节点,新节点直接替换老节点;

那如果不是同一节点,但是它们子节点一样怎么办嘞?凉拌!要牢记: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

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0 // 老 vnode 遍历的下标
    let newStartIdx = 0 // 新 vnode 遍历的下标
    let oldEndIdx = oldCh.length - 1 // 老 vnode 列表长度
    let oldStartVnode = oldCh[0] // 老 vnode 列表第一个子元素
    let oldEndVnode = oldCh[oldEndIdx] // 老 vnode 列表最后一个子元素
    let newEndIdx = newCh.length - 1 // 新 vnode 列表长度
    let newStartVnode = newCh[0] // 新 vnode 列表第一个子元素
    let newEndVnode = newCh[newEndIdx] // 新 vnode 列表最后一个子元素
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !removeOnly
    
    // 循环,规则是开始指针向右移动,结束指针向左移动移动
    // 当开始和结束的指针重合的时候就结束循环
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
        
        // 老开始和新开始对比
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 是同一节点 递归调用 继续对比这两个节点的内容和子节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // 然后把指针后移一位,从前往后依次对比
        // 比如第一次对比两个列表的[0],然后比[1]...,后面同理
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 老结束和新结束对比
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // 然后把指针前移一位,从后往前比
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老开始和新结束对比
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        // 老的列表从前往后取值,新的列表从后往前取值,然后对比
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老结束和新开始对比
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        // 老的列表从后往前取值,新的列表从前往后取值,然后对比
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 以上四种情况都没有命中的情况
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 拿到新开始的 key,在老的 children 里去找有没有某个节点有这个 key
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
          
        // 新的 children 里有,可是没有在老的 children 里找到对应的元素
        if (isUndef(idxInOld)) {
          /// 就创建新的元素
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 在老的 children 里找到了对应的元素
          vnodeToMove = oldCh[idxInOld]
          // 判断标签如果是一样的
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 就把两个相同的节点做一个更新
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 如果标签是不一样的,就创建新的元素
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // oldStartIdx > oldEndIdx 说明老的 vnode 先遍历完
    if (oldStartIdx > oldEndIdx) {
      // 就添加从 newStartIdx 到 newEndIdx 之间的节点
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    
    // 否则就说明新的 vnode 先遍历完
    } else if (newStartIdx > newEndIdx) {
      // 就删除掉老的 vnode 里没有遍历的节点
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

由于代码太多了,这里先做个概述。updateChildren方法的核心:

  1. 提取出新老节点的子节点:新节点子节点ch和老节点子节点oldCh;
  2. ch和oldCh分别设置StartIdx(指向头)和EndIdx(指向尾)变量,它们两两比较(按照sameNode方法),有四种方式来比较。如果4种方式都没有匹配成功,如果设置了key就通过key进行比较,在比较过程种startIdx++,endIdx--,一旦StartIdx > EndIdx表明ch或者oldCh至少有一个已经遍历完成,此时就会结束比较。

比如现在有两个子节点列表对比,对比主要流程如下:

循环遍历两个列表,循环停止条件是:其中一个列表的开始指针 startIdx 和 结束指针 endIdx 重合

循环内容是:

  • 新的头和老的头对比
  • 新的尾和老的尾对比
  • 新的头和老的尾对比
  • 新的尾和老的头对比 diff2.gif

以上四种只要有一种判断相等,就调用 patchVnode 对比节点文本变化或子节点变化,然后移动对比的下标,继续下一轮循环对比

如果以上四种情况都没有命中,就不断拿新的开始节点的 key 去老的 children 里找

  • 如果没找到,就创建一个新的节点

  • 如果找到了,再对比标签是不是同一个节点

    • 如果是同一个节点,就调用 patchVnode 进行后续对比,然后把这个节点插入到老的开始前面,并且移动新的开始下标,继续下一轮循环对比
    • 如果不是相同节点,就创建一个新的节点
  • 如果老的 vnode 先遍历完,就添加新的 vnode 没有遍历的节点

  • 如果新的 vnode 先遍历完,就删除老的 vnode 没有遍历的节点

为什么会有头对尾,尾对头的操作?

因为可以快速检测出 reverse 操作,加快 Diff 效率

下面结合图来理解:

1111111111111.png

第一步:

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

此时oldStartIdx和newStarIdx匹配,所以将dom中的A节点放到第一个位置,此时A已经在第一个位置,所以不做处理,此时真实DOM顺序:A B C;

第二步:

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

a2e419345efb43c5961046ab48414911_tplv-k3u1fbpfcp-watermark.png

此时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中将多余节点删除掉;

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

d73b6792c1c449939bc5aaa8170a54dc_tplv-k3u1fbpfcp-watermark.png

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

总结

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

最后,用一张图来记忆整个Diff过程,希望你能有所收获!

参考链接

juejin.cn/post/697071…

juejin.cn/post/696762…

juejin.cn/post/686410…

segmentfault.com/a/119000003…