写点什么

React 面试:谈谈虚拟 DOM,Diff 算法与 Key 机制

作者:beifeng1996
  • 2023-02-27
    浙江
  • 本文字数:5151 字

    阅读完需:约 17 分钟

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


用户头像

beifeng1996

关注

还未添加个人签名 2022-09-01 加入

还未添加个人简介

评论

发布
暂无评论
React面试:谈谈虚拟DOM,Diff算法与Key机制_前端_beifeng1996_InfoQ写作社区