02月24, 2018

React Fiber 分析

2018年春节后的第一篇文章。主要来分享一下React Fiber架构的实现方式,本文仅代表我个人对React核心算法的理解,如有不对的地方,欢迎指正和讨论。


React Fiber is a complete, backward compatible rewrite of the React core — Adam Wolf

Fiber实际上是对react核心算法的重构,同时又向下兼容了旧版本的React,在原来的基础上做了一些新的突破性的改造。其中Fiber最显著的改进点就是加快渲染速度,区分元素优先级进行渲染的能力。由此有了更好的用户体验。

React Fiber 错误处理机制

React16新增了错误捕获机制,首次提出了Error Boundaries(错误边界)的概念,在React15旧版本中其实也小小的实现了一点错误捕获的功能:unstable_handleError,目前在16版本正式改成componentDidCatch方法。React16 将在开发环境中打印出所有在渲染期间产生的错误,即使是应用程序意外吞掉的错误也不例外。除了错误消息和JS堆栈之外,它还提供组件堆栈跟踪。

alt

具体的内容,你想了解更多可以阅读:Reactv16.0 新特性尝鲜,这里就不赘述了。

React Virtual DOM

这里我们先来简单了解一下浏览器的渲染过程。

alt

现代浏览器一般由两个线程(主线程和合成线程, Main Thread 和 Compositor Thread)共同合作完成渲染,浏览器内核渲染模式大概流程:

1.处理输入内容,解析HTML生成内容树:主进程将html标签或js生成的标签转换成DOM节点,生成内容树。

2.解析CSS生成渲染树:主进程解析CSS(包括外部样式和JS生成的样式)成样式结构体。根据CSS选择器计算对应DOM节点的样式,并应用到对应的DOM节点上,生成渲染树。

3.布局(layout):主进程从跟节点开始递归调用,计算每一个DOM节点的最终位置,计算出DOM元素相对于彼此的位置。

4.绘制(paint):主进程将已经经过布局的渲染树转换为一个展示列表(SKPicture),这个展示列表中包含逐层绘制的绘制命令。然后将展示列表(SKPicture)转化成位图。

5.合成多层位图:主进程将生成好的位图同步传递给合成线程。由合成线程更新最新的位图,再绘制到屏幕中。

感兴趣的同学可以戳这里了解更多浏览器渲染细节。

好了,现在我们来讨论一下DOM操作在实际应用中的成本。

DOM本身来讲是很庞大也很慢。当我们用JS创建一个div,然后将div的属性打印出来,可以看到仅仅一个div元素就包含大量的属性和值。

alt

图片转自:https://github.com/livoras/blog/issues/13

而当我们增加,删除,改动DOM节点时,通过CSS样式改变元素属性时,改变CSS样式时,都有可能触发浏览器的重新布局和重绘制,这在一定程度上,是巨大的性能耗费。

相比之下,通过js来处理页面上节点的变化会更加高效迅速一些。于是有了Virtual DOM的概念,用js对象来表示DOM的信息和结构,每一次展示都是基于状态的,当状态改变时,重新渲染js的对象结构,生成新的js对象,然后将js对象解析成代码片段,统一插入到HTML文档流根节点下方,再重新渲染整颗DOM树,但是这样做的缺点在于任何不分大小的状态的更新都会触发整个DOM树重新渲染,如果你写的是一个大型应用的话,那将对性能产生极大的负面影响。

因此,针对上述的问题,React从上述的过程中对Virtual DOM 生成DOM树的环节进行优化,提出了O(n)复杂度的Diff算法。

在React 中 Virtual DOM 运行步骤大概如下:

1.用js对象结构便是DOM树结构,然后解析js对象,生成真是的html片段,插入到文档流中。

2.状态变化时,生成一个新的js对象结构,然后对两棵树做diff比较,记录差异。

3.将差异应用到步骤一中所构建的真正的DOM树中。

下面我们来讨论一下React Diff 算法。

Virtual DOM在React中的应用

React中Virtual DOM的表现形式分为三种:React Components, Elements,Instances。

Elements

元素其实是一个不可变解释型对象,用来解释一个组件实例或者是一个DOM节点和其所需的属性。它通常只包含两个字段:type:(string | ReactClass)props: Object。组件type(比如: Button)用来表示组件的类型,组件属性props(比如: Button的color属性)用来支出组件所用的属性和子节点。元素不是真实的实例,它只是用来告诉React你想在页面上看到的什么,所以在元素上不能调用任何方法。

Elements (元素)又可以分为三大类:DOM Elements(DOM 元素) , Component Element(组件元素), Components Encapsulate Element Trees(组件封装的元素树)

一、DOM Elements:当元素的type值为字符串类型时,当前元素表示具有该tag名称的DOM节点,props的内容和该元素的属性相对应。这样React就知道要将元素渲染成什么了。

举个例子: alt

上面的js对象就是一个Virtual Dom中的DOM Elements,type值是一个字符串,props中包含了它的属性和子节点,它的子节点也包含两个属性type 和 props。子元素和父元素都只是描述而不是真是的实例

转换成HTML如下:

alt

二、Component Elements: 当元素的type值为一个方法或是一个class类时,就可以称之为是组件元素。

alt

不难发现,描述组件的元素其实也是一个元素,和上面描述DOM节点是一样的。它们可以互相混合嵌套。这就是React的核心思想。

三、Components Encapsulate Element Trees:组件封装的元素树,当React 发现 当前元素的type值是一个函数或class类时,会继续向这个type值对应的组件进一步追溯,直到遇到type值是string类型的DOM 元素

举个例子:

alt

当React遇到上面这个元素时候,React会问Button元素它想要渲染什么? 那么 Button 将返回这个元素:

alt

这是React发现这次返回的是一个DOM Elements,然后依次将元素递归解析成HTML。

Components

组件声明可以通过定义一个function或是一个继承于React.Component的class,或是通过React.creatClass方法 进行创建。

// 1) 创建函数方式
const Button = ({ children, color }) => ({
  type: 'button',
  props: {
    className: 'button button-' + color,
    children: {
      type: 'b',
      props: {
        children: children
      }
    }
  }
});

// 2) React.createClass() 方法
const Button = React.createClass({
  render() {
    const { children, color } = this.props;
    return {
      type: 'button',
      props: {
        className: 'button button-' + color,
        children: {
          type: 'b',
          props: {
            children: children
          }
        }
      }
    };
  }
});

// 3) 创建一个类继承于React.Component
class Button extends React.Component {
  render() {
    const { children, color } = this.props;
    return {
      type: 'button',
      props: {
        className: 'button button-' + color,
        children: {
          type: 'b',
          props: {
            children: children
          }
        }
      }
    };
  }

其中,通过函数式声明的组件更加简洁一些,类似于类组件只拥有render()方法。相反,类声明的组件相对功能比较强大,类声明的组件可以在形影的DOM节点被创建或销毁的时候存储一些本地状态和一些定制的逻辑。

但是无论使用哪种方式声明,他们的含义都是React的组件,都是将props作为输入,将元素elements作为输出返回回来。

Instances

上面我们提到了React中的Components,Elements的概念,接下来我们讨论一下Instances。

We let React create, update, and destroy instances. We describe them with elements we return from the components, and React takes care of managing the instances.

这段引用来源于React官方文档,翻译一下,我们让React创建,更新,销毁实例。我们通过组件components返回的元素elements描述这些实例instances的形态。对于React来讲,React只关注实例instances的管理。

这句话澄清了React中ComponentsELementsInstances三者之间的关系。

那么究竟什么是实例Instances呢?

可以这么说,函数式声明的组件是没有实例的。类组件拥有实例。也就说实例就是你创建的那个类组件,可以存储本地状态和处理react生命周期事件。需要注意的是,你不必手动的创建实例(例如:使用 new的方式),React内部已经将实例创建好了。

React diff 算法

React 的最大的特点之一就是它对dom树的diff算法的优化。

像之前提到的,当你使用React类组件时,render()方法会在初始化的时候创建React 元素(elements),当 stateprops更新时,render()方法会返回一个不同的React元素(elements)。那么这时候React就拿到更新前的元素和更新后的元素,经过diff算法找到改变的地方,对UI进行修改???。

我们详细介绍一下这个Diff算法,React基于两种假设实现了O(n)复杂度的算法:

  1. 两种不同 type 的 元素element 会产生两颗不同的树。
  2. 开发人员可以通过关键 prop 的属性值来确定 子元素 在渲染器中是否保持原来的状态。

diff比较大概分为以下几种类型:

一、元素更新前后有不同的type 值

无论任何时候,当根元素具有不同的type值时,React就会将旧的树摧毁,然后用从头开始构建新树。比如:<a>变更为<img>< Article >变更为< Comment >或者是<Button>变更为<div >都会导致重构元素。

比如当react 对下面的元素进行diff的时候:

//before
<div>
  <Counter />
</div>

//after
<span>
  <Counter />
</span>

尽管<Counter />子节点是相同的,但是由于父节点的type值不同,依然会被摧毁,当老元素树被摧毁时,老的DOM 节点也会被摧毁同时与老元素树相关联的状态也会销毁,这时组件实例instance 会接受到componentWillUnmount ()。销毁老树后,再重新创建一个新的元素树,生成新的 DOM 节点插入到之前生成的DOM 树中,组件实例会接受到componentWillMount()componentDidMount()

二、更新前后具有相同的 type 的 DOM 元素

当比较具有相同 type 值的两颗React DOM元素时,React 会对比属性,保持相同的底层DOM节点,只更新相应改动的属性。

举个例子:

//before
<div className="before" title="stuff" />

//after
<div className="after" title="stuff" />

通过比较,React发现只有底层DOM节点上的className这个属性发生了修改,于是更新该属性。

//before
<div style={{color: 'red', fontWeight: 'bold'}} />

//after
<div style={{color: 'green', fontWeight: 'bold'}} />

通过比较,React发现只有底层DOM节点上的color样式发生了修改,更新color属性。

三、更新前后具有相同的 type 的 组件元素

当组件元素更新时,组件对应的实例会保持不变,这么做的目的是为了保持 state 状态在渲染中是稳定不变的 。 React 会更新底层组件实例的props值来匹配新的元素,同时会调用底层组件的componentWillReceiveProps()componentWillUpdate()

然后调用 render() 方法,对新旧结果进行diff算法比较。

四、子节点递归

默认情况下,当递归DOM节点的子节点时,React同时遍历两个子节点列表数组,并在发现变化的时候,生成一个变更记录。

举个例子,当添加一个元素在孩子节点列表的末尾时:

//before
<ul>
  <li>first</li>
  <li>second</li>
</ul>


//after
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React 会比较并匹配两颗树的 <li>first</li>,再对两颗树的<li>second</li>进行比较和匹配,最后插入<li>third</li>,这属于比较理想的情况。

我们看下面这个例子:

//before
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

//after
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

这一次我们是在子节点列表第一个位置插入元素。React并不能感知<li>Duke</li><li>Villanova</li>是相同的,相反会进行同级比对,逐一替换。这样的效率就非常低了,对性能也有很大的负面影响。

所以为了解决上述的低性能的问题,React提出了 Key 这个属性 。 当子节点有key属性时,React使用key的值来对修改前后的树进行比较匹配。这里的diff算法涉及到经典问题 动态规划,具体的实现方式在下一篇文章中详细介绍。

举个例子:

//before
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

//after
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

上面的代码中每一个li 元素都增加了 key 属性,现在,React就可以清楚的知道 key 值是 2014的元素是新增的, key 值是 2015 和 2016 的元素只是移动了位置。同时这个 key 的值只需要在兄弟元素中保持唯一就ok了,你不需要让它全局唯一,因为React diff 比较是基于同级元素节点进行比较的。

这里需要注意一点是,当你使用map 所产生的 index 值作为key值的话,可能会有一些问题。我们通过官方提供的这两个例子来分析一下:key使用index vs key使用数据中的id

首先我创建一个input 输入 a , 其次我在第一个input 之前插入一个 input 输入 b, 最后我在第一个 input 之后插入一个 input 输入 c。

key=index 的结果:

alt

key=id 的结果:

alt

很显然,key=index的结果并不是我们预期的。这是因为当我往 state list列表中塞入 第一个 input 值时list数组应该是:

state.list = [
    {
        value: a,
        id: 1,
        index: 0
    },
]

当塞入第二个 input 和第三个 input 的时候,数组应该是:

state.list = [
 {
    value: b,
    id: 2,
    index: 0
},
{
    value: a,
    id: 1,
    index: 1
},
{
    value: c,
    id: 3,
    index: 3
},
]

当key 取 index 值时,第一个 值为 a 的input 的 key 值为 0, 当再次出插入值第二个 input 时,第二个 input 的 key 值变为了 0,React 进行diff比较时并没有发现差异,于是第一个 input 顺利的保留下来。

总结来说:差异类型分为一下几种:

  1. 元素type不同,替换整个节点。
  2. 修改节点属性,不替换节点,更新原有属性。
  3. 移动、删除、新增子节点,通过对比 Key 来进行diff比较。

参考链接:

本文链接:https://www.imwineki.cn/post/ReactFiber.html

-- EOF --

Comments

评论加载中...

注:如果长时间无法加载,请针对 disq.us | disquscdn.com | disqus.com 启用代理。