Vue/React -- Virtual DOM 及 Diff 算法

1. 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的效率。

2. 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机制

3. React -- key机制

(1)key的作用:

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

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

首先,对新集合中的节点进行循环遍历 for (name in nextChildren),通过唯一的 key 判断新旧集合中是否存在相同的节点 if (prevChild === nextChild),如果存在相同节点,则进行移动操作,但在移动前需要将当前节点在旧集合中的位置与 lastIndex 进行比较 if (child._mountIndex < lastIndex),否则不执行该操作。

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

按新集合中顺序开始遍历

  1. B在新集合中 lastIndex(类似浮标) = 0, 在旧集合中 index = 1,index > lastIndex 就认为 B 对于集合中其他元素位置无影响,不进行移动,之后lastIndex = max(index, lastIndex) = 1
  2. A在旧集合中 index = 0, 此时 lastIndex = 1, 满足 index < lastIndex, 则对A进行移动操作,此时lastIndex = max(Index, lastIndex) = 1
  3. D和B操作相同,同(1),不进行移动,此时lastIndex=max(index, lastIndex) = 3
  4. C和A操作相同,同(2),进行移动,此时lastIndex = max(index, lastIndex) = 3

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

例子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值重复的同一类型的节点或组件很可能出现拷贝重复内部子元素的问题

参考链接

juejin.cn/post/697071…

juejin.cn/post/696762…

Guess you like

Origin juejin.im/post/7068281485094027301