浅谈React 虚拟DOM,Diff算法与Key机制

9,554 阅读12分钟

参考文章:

juejin.cn/post/684490…

《深入React技术栈》

1.虚拟dom

在这里插入图片描述 原生的JS DOM操作非常消耗性能,而React把真实原生JS DOM转换成了JavaScript对象。这就是虚拟Dom(Virtual Dom)

每次数据更新后,重新计算虚拟Dom,并和上一次生成的虚拟dom进行对比,对发生变化的部分作批量更新。在此其中,React提供了componentShouldUpdate生命周期来让开发者手动控制减少数据变化后不必要的虚拟dom对比,提升性能和渲染效率。

原生html元素代码:

<div class="title">
      <span>Hello ConardLi</span>
      <ul>
        <li>苹果</li>
        <li>橘子</li>
      </ul>
</div>

在React可能存储为这样的JS代码:

const VitrualDom = {
  type: 'div',
  props: { class: 'title' },
  children: [
    {
      type: 'span',
      children: 'Hello ConardLi'
    },
    {
      type: 'ul',
      children: [
        { type: 'li', children: '苹果' },
        { type: 'li', children: '橘子' }
      ]
    }
  ]
}


当我们需要创建或更新元素时,React首先会让这个VitrualDom对象进行创建和更改,然后再将VitrualDom对象渲染成真实DOM;

当我们需要对DOM进行事件监听时,首先对VitrualDom进行事件监听,VitrualDom会代理原生的DOM事件从而做出响应。

虚拟DOM的组成:

通过JSX或React.createElement,React.createClass等方式创建虚拟元素和组件。即ReactElementelement对象,我们的组件最终会被渲染成下面的结构:

  • type:元素的类型,可以是原生html类型(字符串),或者自定义组件(函数或class)
  • key:组件的唯一标识,用于Diff算法,下面会详细介绍
  • ref:用于访问原生dom节点
  • props:传入组件的props,chidren是props中的一个属性,它存储了当前组件的孩子节点,可以是数组(多个孩子节点)或对象(只有一个孩子节点)
  • owner:当前正在构建的Component所属的Component
  • self:(非生产环境)指定当前位于哪个组件实例
  • _source:(非生产环境)指定调试代码来自的文件(fileName)和代码行数(lineNumber)
<div className="title">
      <span>Hello ConardLi</span>
      <ul>
        <li>苹果</li>
        <li>橘子</li>
      </ul>
</div>

将此JSX元素打印出来,证实虚拟DOM本质就是js对象: 在这里插入图片描述

其中,在jsx中使用的原生元素标签,其type为标签名。而如果是函数组件或class组件,其type就是对应的class或function对象

在这里插入图片描述 在这里插入图片描述

2.diff算法

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

这个算法问题有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作数。 然而,即使在最前沿的算法中,该算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。

如果在 React 中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。于是 React 在以下两个假设的基础之上提出了一套 O(n) 的启发式算法:

1:两个不同类型的元素会产生出不同的树;

2:开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定;

React diff算法大致执行过程:

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

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

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

React diff算法具体策略:

(1)tree diff

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

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

当根节点发现子节点中 A 消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点)作为其子节点。此时,diff 的执行情况:create A → create B → create C → delete A

由此可以发现,当出现节点跨层级移动时,并不会出现想象中的移动操作,而是以 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. 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值重复的同一类型的节点或组件很可能出现拷贝重复内部子元素的问题