解析React 虚拟DOM和Diff算法

一、 JSX

众所周知,React 使用 JSX 来替代常规的 JavaScript。JSX 是一个看起来很像 XML 的 JavaScript 语法扩展,其本质是 createElement()方法的语法糖 (语法糖:更加直观、简洁、友好)。

JSX 代码会经过babel-loader 会解析为 React.createElement()嵌套对象。React.createElement() 创建的就是一个虚拟DOM结构。

二、 虚拟DOM

通过React.createElement()创建的虚拟DOM描述了DOM树的结构,其本质是一个轻量级的javaScript对象。该JS对象包含如下属性:

- type:元素的类型,可以是原生html类型(字符串),或者自定义组件(函数或class)

- key:组件的唯一标识,用于Diff算法,之后会详细介绍

- ref:用于访问原生dom节点

-  props:传入组件的props,children是props中的一个属性,它存储了当前组件的子节点,可以是数组(多个子节点)或对象(只有一个子节点)

- owner:当前正在构建的Component所属的Component

- self:(非生产环境)指定当前位于哪个组件实例

-  _source:(非生产环境)指定调试代码来自的文件(fileName)和代码行数(lineNumber)

为更好理解,下面我们来看一组转换流程。

存在JSX代码如下:

const element = (

 

    Hello JSX

   

         

  • test1
  •      

  • test2
  •    

 

);

通过babel-loader 解析后:

const element = React.createElement(

  "div",

  { className: "title" },

  React.createElement("span", null, "Hello JSX"),

  React.createElement(

    "ul",

    null,

    React.createElement("li", null, "test1"),

    React.createElement("li", null, "test2")

  )

);

转换成虚拟Dom后,会变成如下JS代码(为方便查看,删除部分不必要属性)

const element = {

  type: "div",

  props: { class: "title" },

  children: [

    { type: "span", children: "Hello JSX" },

    {

      type: "ul",

      children: [

        { type: "li", children: "test1" },

        { type: "li", children: "test2" },

      ],

    },

  ],

};

完整的虚拟Dom代码如下:

由此可见,虚拟DOM就是JS对象。最后,ReactDom.render 将生成好的虚拟DOM渲染到指定容器上,其中采用了批处理,事务等机制,并且对特定浏览器进行了性能优化,最终转换为真实DOM。

1. 为什么需要虚拟DOM呢?

- 提高性能

我们都知道,每次DOM操作会引起重绘或者回流,频繁的真实DOM的修改会触发多次的排版和重绘,相当耗性能。

虚拟DOM可以提高性能,不是说不操作DOM,而是减少操作真实DOM的次数。即当状态/数据改变时,React会自动更新虚拟DOM,产生一个新的虚拟DOM树。通过diff算法对新旧虚拟DOM进行比较,找出最小的有变化的部分,将这个变化的部分Patch(即需要修改的部分)加入队列,最终,批量的更新这些Patch到真实DOM上,以减少重绘和回流,从而达到性能优化的目的。

此外,React还提供了componentShouldUpdate生命周期来让开发者手动控制减少数据变化后不必要的虚拟dom对比,提升性能和渲染效率。

- 跨浏览器兼容

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

- 跨平台兼容

虚拟DOM为React带来了跨平台渲染的能力,以React-native为例子,React根据虚拟DOM画出相应平台的UI.

- 提高开发效率

三、 Diff算法

传统的diff算法是使用递归循环对节点进行依次对比,即使在最前沿的算法中 将前后两棵树完全比对的算法的复杂程度为 O(n^3),其中 n 是树中元素的数量。 如果在React中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。 这个开销实在是太过高昂。

为了提高性能,React同时维护着两棵虚拟DOM树:一棵为当前的DOM结构(旧虚拟DOM),另一棵为React状态变更后生成的DOM结构(新虚拟DOM)。React通过比较这两棵树的差异,决定是否需要修改DOM结构,以及如何修改。这种算法称作**React 的 Diff算法**

React的 Diff算法会帮助我们计算出虚拟DOM 中真正发生变化的部分,并且只针对该部分进行实际的DOM操作,而不是对整个页面进行重新渲染。为了降低算法复杂度,React的 Diff算法提出三种策略:

- 针对同一层级的节点进行比较。即如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用它(因为跨层级的DOM移动操作特别少,可以忽略不计)。

- 不同类型的元素会产生出不同的树。即相同类的两个组件将会生成相似的树形结构,不同类的两个组件将会生成不同的树形结构。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。

- 同一层级的一组子节点,可以通过唯一的id区分(key)

基于以上三个策略,React 的 Diff算法分别对Tree Diff、Component Diff以及Element diff进行了算法优化。

 1. Tree Diff

基于第一个策略,React只会对同一层次的节点进行比较,即如果父节点不同,React将不会再去对比子节点。因为不同的组件DOM结构会不相同,所以就没有必要再去对比子节点了。这样就只需要遍历一次,就能完成对整个DOM树的比较,进而提高了对比的效率,把事件复杂度降低为O(n).


React对于不同层级的节点,只有创建和删除操作。如图所示,如果A节点整个被移动到D节点下,当根节点发现子节点中A不见了,就会直接销毁A;而当D发现自己多了一个子节点A,则会创建一个新的A作为子节点。


因此对于这种结构的转变的实际操作是:

A.destroy();

A = new A();

A.append(new B());

A.append(new C());

D.append(A);

由于React 的Diff 算法没有针对跨层级的DOM移动操作进行深入比较,对于节点跨层级移动时,只是进行简单的创建和删除。这会影响 React 性能的操作,因此,官方建议不要进行 DOM 节点跨层级的操作。在组件开发时,推荐通过 CSS 隐藏或显示节点,不做真正地移除或添加 DOM 节点的操作,进而保证稳定的 DOM 结构,提升性能。

2. Component Diff

Component Diff是专门针对更新前后的同一层级间的React组件比较的Diff 算法。React对于组件间的比较采取的策略如下:

- 如果是同一类型的组件,按照原策略继续进行虚拟DOM 比较。

- 如果不是,则将该组件判断为dirty component,从而替换整个组件下的所有子节点, 即销毁原组件,创建新组件。

- 对于同一类型的组件,有可能其虚拟DOM没有任何变化,如果能够确切的知道这点那可以节省大量的Diff运算的时间,因此,React允许用户通过shouldComponentUpdate()判断该组件是否需要进行diff 算法分析。

举个例子来说,当下图中componentD改变为componentG时,即使这两个compoent结构很相似,但是react会判断D和G并不是同类型组件,也就不会比较二者的结构了,而是直接删除了D,重新创建G及其子节点。

因此对于这种结构的转变的实际操作是:

D.destroy();

G = new G();

G.append(new E());

G.append(new F());

V.append(G);

3. Element Diff

Element Diff是专门针对同一层级的所有节点(包括元素节点和组件节点)的Diff算法。当节点处于同一层级时,React的Diff提供了三种节点操作:插入、移动和删除。

- 插入:新的component类型不在老集合里,即全新的节点,需要对新节点执行插入操作

- 移动:在老集合里有新component类型,且element是可更新的类型,generateComponentChildren已调用receiveComponent,这种情况下prevChild=nextChild,就可以复用以前的DOM节点,执行移动操作。

- 删除:当老的component类型,在新集合中也有,但对应的element不同则不能直接复用和更新,需要执行删除操作;当老component不在新集合里,也需要执行删除操作。

如下图,老集合为节点A、B、C、D,想生成新集合B、A、D、C,通过新老集合差异化对比,最简单粗暴的方法为: 发现B != A,则新集合创建B,老集合删除A;以此类推,在老集合删除B、C、D,在新集合添加A、D、C。

可以发现这类操作烦琐冗余,因为这些都是相同的节点,只是由于位置顺序发生变化,就需要进行繁杂低效的删除、创建操作,其实只要对这些节点执行移动操作即可。为此,react提出了优化机制 ---  Key机制

四、 Key机制

React允许开发者对同一层级的同组子节点,添加唯一key进行区分。React会根据key来决定是删除重新创建组件还是更新(移动)组件,原则是:

- key相同,组件有所变化,React会只更新组件对应变化的属性。

- key不同,组件会销毁之前的组件,将整个组件重新渲染。

1. 移动规则

添加了key 之后,按如下步骤确认是否移动: 首先,对新集合中的节点进行循环遍历 for (name in nextChildren),通过唯一的 key 判断新旧集合中是否存在相同的节点,if (prevChild === nextChild)。如果存在相同节点,且child.mountIndex(当前节点在老集合中的位置)与 lastIndex(参考位置,类似浮标)进行比较满足 child._mountIndex < lastIndex,则进行移动操作,否则不执行移动操作。

这是一种顺序优化手段,lastIndex = Math.max(prevChild.mountIndex, lastIndex) 将一直更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。

基于移动规则,我们看几个实例:

实例(1):同一层级的所有节点只发生了位置变化

按新集合中顺序开始遍历:

1. B在新集合中 lastIndex = 0, 在旧集合中 mountIndex = 1,mountIndex > lastIndex 就认为 B 对于集合中其他元素位置无影响,不进行移动。此时,lastIndex = max(prevChild.mountIndex, lastIndex) = 1,其中,prevChild.mountIndex表示B在老集合中的位置。

2. A在旧集合中 mountIndex = 0, 此时, 满足 mountIndex < lastIndex, 则对A进行移动操作。此时,lastIndex = max(prevChild.mountIndex, lastIndex) = 1。

3. D和B操作相同,同(1),不进行移动,此时lastIndex = 3。

4. C和A操作相同,同(2),进行移动,此时lastIndex = 3。

上述结论中的移动操作即对节点进行更新渲染,而不进行移动则表示无需更新渲染。可见有key值后,相比于之前的繁琐冗余做法,极大的提升React 的性能。

实例(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节点。

2. key值的缺陷

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

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

3. key值设置

如果没有添加唯一的key值时,会遇到这个错:

这是React在遇到列表时却又找不到key时提示的警告。虽然无视这条警告大部分界面也会正确工作,但这通常意味着潜在的性能问题,因为React觉得自己可能无法高效的去更新这个列表。

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

此外,使用index作为key很可能会存在一些出人意料的显示错误的问题。例如;存在三个input输入框,以index作为其key进行渲染时。

若想实现点击第二个删除按钮,删除第二列。则会发现,第二列未成功删除,第三列被删除掉了。

为什么呢?

这是因为你认为你删除了2,但React会认为你做了两件事:「把2变成了3」以及「把3删除了」。

看看这两个数组:[123]和[13],人类会说,这不就是少了个2吗?但是计算机会遍历数组:首先对比1和1,发现1没变;然后对比2和3,发现2变成了3; 最后对比undefined和3,发现「3被删除了」。所以计算机的结论是:「2变成了3」以及「3被删除了」。

因此,React渲染逻辑为: 1没变,复用之前的1和三角形;「2变成了3」,正方形左边的2改为3。里面的正方形就地复用(正方形没有被删除);「3被删除了」,之前的「圆形」当然应该被删掉,里面的子元素也要删除。

因此,为了避免此类型错误,也为了提升性能,不要使用index作为key值。

# 参考内容:

[React 源码剖析系列]

[虚拟DOM与DOM Diff 的原理]

你可能感兴趣的:(解析React 虚拟DOM和Diff算法)