由浅入深快速掌握React Fiber

2020-05-15  本文已影响0人  这个前端不太冷
React-fiber.png

前言


React Fiber 不是一个新的东西,但在前端领域是第一次广为认知的应用。几年前全新的Fiber架构让刚刚上手React的我又迷茫了。我不是升到React v16了吗? 没什么出奇的啊? 真正要体会到 React Fiber 重构效果,可能下个月、可能要等到 v17。v16 只是一个过渡版本,也就是说,现在的React 还是同步渲染的,一直在跳票、不是说今年第二季度就出来了吗。是我开始查阅资料,无奈尼玛理解起来很吃力啊。一直想总结一篇文章来着,这都 2020年了,我才开始写 Fiber 的文章,表示惭愧呀。不过现在好的是关于 Fiber 的资料已经很丰富了,我发现网上很多文章写的非常不容易理解。而且比较片面。于是决定先讲一下React Fiber学习前的准备知识。然后
用比较容易理解话语让读者尽快明白React Fiber是个什么东东。见解有限,如有描述不当之处,请帮忙及时指出,如有错误,会及时修正。

框架总览



从浏览器多进程到JS单线程

掌握浏览器多进程到JS单线程对了解React Fiber的架构初衷特别有帮助,网上查阅了很多资料。已经将其整理成文章
从浏览器多进程到JS单线程

React 的核心知识点

React 的核心思想

内存中维护一颗虚拟DOM树,数据变化时(setState),自动更新虚拟 DOM,得到一颗新树,然后 Diff 新老虚拟 DOM 树,找到有变化的部分,得到一个 Change(Patch),将这个 Patch 加入队列,最终批量更新这些 Patch 到 DOM 中。

JSX(JavaScript XML)、element(元素)与 React.createElement

jsx.png

(1)什么是JSX
我们来看一下这样一段代码:

const title = <h1 className="title">Hello, world!</h1>;

定义:JSX是JavaScript XML,是React提供的语法糖。 通过它我们就可以很方便的在js代码中书写html片段。
作用:React中用JSX来创建虚拟DOM(元素)。
注意:JSX不是字符串,也不是HTML/XML标签,本质上还是javascript;上面这段代码会被babel转换成如下javascript:

const title = React.createElement(
    'h1',
    { className: 'title' },
    'Hello, world!'
);

(2)JSX基本语法规则
如以下代码:

class Home extends React.Component {
  render() {
    return (
      <div>
        <Welcome name='飞哥' />
        <p>Anything you like</p>
        {this.state.product.length>0?<p className="title">简书</p>:''"}
      </div>
    )
  }
}

在 HTML 中,我们会使用 进行注释,不过 JSX 中并不支持:


render() {
  return (
    <div>
      <!-- This doesn't work! -->
    </div>
  )
}

我们需要以 JavaScript 中块注释的方式进行注释:


{/* A JSX comment */}

{/* 
  Multi
  line
  comment
*/}  

2.数组

JSX 允许使用任意的变量,因此如果我们需要使用数组进行循环元素渲染时,直接使用 map、reduce、filter 等方法即可:

function NumberList(props) {
  const numbers = props.numbers;
  return (
    <ul>
      {numbers.map((number) =>
        <ListItem key={number.toString()}
                  value={number} />
      )}
    </ul>
  );
}

3.条件渲染

在JSX中我们不能再使用传统的if/else条件判断语法,但是可以使用更为简洁明了的Conditional Operator运算符,譬如我们要进行if操作:

{condition && <span>为真时进行渲染</span> }

如果要进行非操作:

{condition || <span>为假时进行渲染</span> }

我们也可以使用常见的三元操作符进行判断:

{condition
  ? <span>为真时进行渲染</span>
  : <span>为假时进行渲染</span>
}

如果对于较大的代码块,建议是进行换行以提升代码可读性:

{condition ? (
  <span>
   为假时进行渲染
  </span>
) : (
  <span>
   为假时进行渲染
  </span>
)}

JSX 中的 style 并没有跟 HTML 一样接收某个 CSS 字符串,而是接收某个使用 camelCase 风格属性的 JavaScript 对象,这一点倒是和DOM 对象的 style 属性一致。譬如:

const divStyle = {
  color: 'blue',
  backgroundImage: 'url(' + imgUrl + ')',
};

function HelloWorldComponent() {
  return <div style={divStyle}>Hello World!</div>;
}

注意,内联样式并不能自动添加前缀,这也是笔者不太喜欢使用 CSS-in-JS 这种形式设置样式的的原因。为了支持旧版本浏览器,需要提供相关的前缀:

const divStyle = {
  WebkitTransition: 'all', // note the capital 'W' here
  msTransition: 'all' // 'ms' is the only lowercase vendor prefix
};

function ComponentWithTransition() {
  return <div style={divStyle}>This should work cross-browser</div>;
}

2.className

React 中是使用 className 来声明 CSS 类名,这一点对于所有的 DOM 与 SVG 元素都起作用。不过如果你是将 React 与 Web Components 结合使用,也是可以使用 class 属性的。

3.htmlFor

因为 for 是JavaScript中的保留关键字,因此 React 元素是使用 htmlFor 作为替代。

4.Boolean 系列属性

HTML 表单元素中我们经常会使用 disabled、required、checked 与 readOnly 等 Boolean 值性质的书,缺省的属性值会导致 JSX 认为 bool 值设为 true。当我们需要传入 false 时,必须要使用属性表达式。譬如 <input type='checkbox' checked={true}> 可以简写为<input type='checkbox' checked>,而 <input type='checkbox' checked={falsed}> 即不可以省略 checked 属性。

如果在 JSX 中向 DOM 元素中传入自定义属性,React 是会自动忽略的:

<div customProperty='a' />

不过如果要使用HTML标准的自定义属性,即以 data-* 或者 aria-* 形式的属性是支持的。

<div data-attr='attr' />
  • JSX只允许被一个标签包裹,因此最外层要包裹一个闭合的标签。
  • 如果使用数据形式,必须对数组中的每一个元素都要给一个唯一的key值
  • 元素如果是小写首字母则是元素,如果是大写首字母则是组件元素
  • 遇到以 { 开头的代码,以JS的语法解析: 标签中的js代码必须用{}包含。
  • class属性改为className
  • for属性改成htmlFor
  • 传入数据的展开性{…props}
  • JSX中,我们必须使用驼峰的形式来书写事件的属性名
  • 如果你在JSX中使用了不存在于HTML规范的属性,这个属性是会被忽略的,你需要使用data-方式来自定义属性

React.createElement和 element

依然拿上面的代码举个例子:

上面的JSX经过bable转义之后返回javascript 函数。函数React.createElement知道如何渲染type = 'div' 和 type = 'p' 的节点,但不知道如何渲染type='Welcome'的节点当React 发现Welcome 是一个React 组件时,会调用该组件的render方法,产生该组件的Element,如果该组件的element中有首字母大写开头的Element的type,继续找下去,直到没有首字母大写的type。
因此,所有的React组件必须首字母大写,原因是生成React Element的时候,type属性会直接使用该组件的实例化时使用的名字(<Welcome/>)如果没大写React将不能判断是否需要继续调用该组件的render方法创建Element

class Home extends React.Component {
  render() {
    return (
      <div>
        <Welcome name='飞哥' />
        <p>Anything you like</p>
        {this.state.product.length>0?<p className="title">简书</p>:''"}
      </div>
    )
  }
}
// 被babel.js转义之后jsx变成了一段javascript :
React.createElement(
    'div',
     null,
    React.createElement(
      'Welcome',
      {"name":'飞哥'},
       null
    ),
    React.createElement(
      'p',
       null
      'Anything you like'
    ),
    this.state.product.length>0? React.createElement(
      'p',
      {'className':'title'},
      '简书'
    )
);
// 执行上面的javascript 函数之后生成的对象就是element,element就是VDom,多个element组成的一整个对象就是VDom树。
{
  type: 'div',
  props:{
    children: [
      {
        type: 'Welcome',
        props: {
          name: '老干部'
        },
      },
      {
        type: 'p',
        props: {
          children: 'Anything you like'
        },
      },
     {
        type: 'p',
        props: {
          className:‘title’,
          children: '简书'
        },
      }
    ]
  }
}

(1)element
在React中Element通常指代的是render函数或者stateless函数返回的Object对象,拥有props,type,key等等属性,用来描述真实的DOM 长什么样子,这个对象通常作为虚拟DOM的一个节点,React通过调用ReactDOM.render将这些虚拟DOM在浏览器上渲染成真实DOM。在React中Element根据其type属性的不同,分成两类: 以原生的DOM元素作为return值的组件,以及以React组件作为return值的组件。

可以通过React 库提供的React.createElement 函数来创建。

(2)React.createElement(a, b, c)

let h1Elem = React.createElement('h1', {id: 'recipe', 'data-type': 'title'}, 'Hello World');

作用:根据指定的第一个参数创建一个React元素(element)


Virtual DOM和Diff算法

什么是Virtual DOM?

概念: Virtual DOM(虚拟DOM)是对DOM的抽象,本质上是JavaScript对象,这个对象就是更加轻量级的对DOM的描述。简写为vdom。
我的理解: 虚拟DOM不是真实的DOM,而是一个JS对象。React内存中始终维护着一个对象,用来描述当前页面中真实的DOM. 这个对象就是Virtual DOM,它的作用是判断DOM是否改变、哪些部分需要被重新渲染。这样,不需要操纵真实的DOM,极大的提高了React的性能。

<div class="title">
    <p>hello</p>
</div>

例如可以将上面的DOM使用JS对象来表示,大致可以是如下结构:

let vNode = {
    type: 'div',
    props: {
        className: 'title'
    },
    children: [
        {type: 'p',text: 'hello'}
    ]
}

type: 指定元素的标签类型,案例为:'ul' (react中用type)
props: 表示指定元素身上的属性,如id,class, style, 自定义属性等
children: 表示指定元素是否有子节点,参数以数组的形式传入,如果是文本就是数组中为text的字符串


为什么要使用Virtual DOM
image (2).jpg

React中的核心--Diff算法**

JSX转成dom流程

用JSX语法时,渲染dom的流程:JSX——Virtual DOM——真实dom

具体步骤:


1749373634-5bbec7416c02f_articlex.png

(1)获取state数据
(2)解析JSX模板
(3)生成虚拟dom(虚拟dom就是一个JS对象,里面包含了对真实dom的描述

['div',{id:'a'},['span',{},'hello']]

(4)用虚拟dom解构,生成真实dom并显示

<div id='a'><span>hello</span></div>

(5)state数据发生变化(比如hello变成了hi)
(6)生成新的虚拟dom

['div',{id:'a'},['span',{},'hi']]

(7)比较原始虚拟dom和新的虚拟dom的区别,找出区别是span里的内容
(8)直接操作dom,只改变span里的内容


虚拟dom中的diff算法

18616547-937784fb8fbbde22.png

在上面我们介绍了react中state变化时,dom是如何发生变化的,在第七步中比较原始虚拟dom和新的虚拟dom的区别采用的方法,就是diff算法(diffrence)。传统的 Diff 算法也是一直都有的, 但是它的时间复杂度为O(n^3) 意思是: 在React中更新10个元素则需要进行1000次的比较。(1000个===10亿),React 通过制定大胆的策略,将 O(n^3) 复杂度的问题转换成 O(n^1=n) 复杂度的问题:

  • 两个不同类型的元素会产生不同的树
  • 对于同一层级的一组子节点,它们可以通过唯一 key 进行区分

基于以上两个前提策略,React 分别对 tree diff、component diff 以及 element diff 三种 diff 方法是 进行算法优化,

Tree Diff

Web UI中DOM节点跨层级的移动操作特别少,可以忽略不计。


Tree Diff 01.png

策略:

1 只会对同一父节点下的所有子节点(相同颜色方框内)的DOM节点进行比较
2 当发现节点已经不存在,则该节点及其子节点直接移除,不再进行深度比较
image.png

执行过程:


image.png

如上图所示,以A为根节点的整棵树会被重新创建,而不是移动,因此 官方建议不要进行DOM节点跨层级操作,可以通过CSS隐藏、显示节点,而不是真正地移除、添加DOM节点。

Component Diff

策略:

1. 如果组件类型相同,暂时不更新,

2. 如果组件类型不相同,就需要更新; ( 删除旧的组件,再创建一个新的组件,插入到删除组件的那个位置)
Component Diff 01.png

React 是基于组件构建应用的,对于组件间的比较所采取的策略也是简洁高效。
如果是同一类型的组件,按照原策略继续比较 virtual DOM tree。
如果不是,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点。
对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间,因此 React 允许用户通过 **shouldComponentUpdate() **来判断该组件是否需要进行 diff。
如下图,当 component D 改变为 component G 时,即使这两个 component 结构相似,一旦 React 判断 D 和 G 是不同类型的组件,就不会比较二者的结构,而是直接删除 component D,重新创建 component G 以及其子节点。虽然当两个 component 是不同类型但结构相似时,React diff 会影响性能,但正如 React 官方博客所言:不同类型的 component 是很少存在相似 DOM tree 的机会,因此这种极端因素很难在实现开发过程中造成重大影响的。

Element Diff

当节点处于同一层级时,React diff 提供了三种节点操作,分别为: INSERT_MARKUP(插入)MOVE_EXISTING(移动)REMOVE_NODE(删除)

  1. INSERT_MARKUP(插入),新的组件集合(A,B,C)中 C 不在老组件集合(A,B)中, 即C是全新的节点,需要对新节点执行插入操作。

  2. MOVE_EXISTING(移动),组件D已经在老组件集合(A,B,C,D)里了,且组件集合更新时,D没有发生更新,只是位置改变,如新集合(A,D,B,C),D在第二个,无须像传统diff,让旧集合的第二个B和新集合的第二个D 比较,并且删除第二个位置的B,再在第二个位置插入D,而是 (对同一层级的同组子节点) 添加唯一key进行区分,做移动操作,可以复用以前的 DOM 节点。

  3. REMOVE_NODE(删除)
    (1)新老组件集合中同一个位置的组件,对应的type不同则不能直接复用和更新,需要执行删除操作删除旧的组件再创建新的组件。
    (2)老 element 不在新集合里,比如组件 D 之前在 集合(A,B,D)中,但集合变成新的集合(A,B)了,也需要执行删除操作。


重点说下element diff的逻辑

element diff针对这三个方法进行运算,但是单纯的去比较新旧集合的差异化,会导致只是进行繁杂的添加、删除操作,于是React 提出优化策略:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分,这样的策略便使性能有了质的飞跃:

情形一:新旧集合中存在相同节点但位置不同时,如何移动节点

image

(1)看着上图的 B,React先从新中取得B,然后判断旧中是否存在相同节点B,当发现存在节点B后,就去判断是否移动B。
B在旧 中的index=1,它的lastIndex=0,不满足 index < lastIndex 的条件,因此 B 不做移动操作。此时,一个操作是,lastIndex=(index,lastIndex)中的较大数=1.

注意:lastIndex有点像浮标,或者说是一个map的索引,一开始默认值是0,它会与map中的元素进行比较,比较完后,会改变自己的值的(取index和lastIndex的较大数)。

(2)看着 A,A在旧的index=0,此时的lastIndex=1(因为先前与新的B比较过了),满足index<lastIndex,因此,对A进行移动操作,此时lastIndex=max(index,lastIndex)=1

(3)看着D,同(1),不移动,由于D在旧的index=3,比较时,lastIndex=1,所以改变lastIndex=max(index,lastIndex)=3

(4)看着C,同(2),移动,C在旧的index=2,满足index<lastIndex(lastIndex=3),所以移动。

由于C已经是最后一个节点,所以diff操作结束。

情形二:新集合中有新加入的节点,旧集合中有删除的节点

image

(1)B不移动,不赘述,更新l astIndex=1

(2)新集合取得 E,发现旧不存在,故在lastIndex=1的位置 创建E,更新lastIndex=1

(3)新集合取得C,C不移动,更新lastIndex=2

(4)新集合取得A,A移动,同上,更新lastIndex=2

(5)新集合对比后,再对旧集合遍历。判断 新集合 没有,但 旧集合 有的元素(如D,新集合没有,旧集合有),发现 D,删除D,diff操作结束。


diff的不足与待优化的地方

image

看图的 D,此时D不移动,但它的index是最大的,导致更新lastIndex=3,从而使得其他元素A,B,C的index<lastIndex,导致A,B,C都要去移动。

理想情况是只移动D,不移动A,B,C。因此,在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,会影响React的渲染性能。

到此,我们介绍完了神秘而又强大的React diff算法介绍完毕了

React 渲染原理(1)--首次渲染

我们知道, 对于一般的React 应用, 浏览器会首先执行代码ReactDOM.render来渲染顶层组件, 在这个过程中递归渲染嵌套的子组件, 最终所有组件被插入到DOM中. 我们来看看:调用ReactDOM.render发生了什么

一、JSX生成element

这里是一段写在render里的jsx代码。

return (
  <div className="cn">
       <Header> Hello, This is React </Header>
       <div>Start to learn right now!</div>
       Right Reserve.
  </div>
)

首先,它会经过babel编译成React.createElement的表达式。

return (
  React.createElement(
      'div',
      { className: 'cn' },
      React.createElement(
          Header,
          null,
          'Hello, This is React'
      ),
      React.createElement(
          'div',
          null,
          'Start to learn right now!'
      ),
      'Right Reserve'
  )
)

这个React.createElement的表达式会在render函数被调用的时候执行,换句话说,当render函数被调用的时候,会返回如下element

{
  type: 'div',
    props: {
      className: 'cn',
        children: [
          {
            type: function Header,
            props: {
                children: 'Hello, This is React'
            }
          },
          {
            type: 'div',
            props: {
                children: 'start to learn right now!'
            }
          },
          'Right Reserve'
      ]
  }
}

我们来观察一下这个对象的children,现在有三种类型:

1、string

2、原生DOM节点

3、React Component - 自定义组件

除了这三种,还有两种类型:

4、fale ,null, undefined,number

5、数组 - 使用map方法的时候

这里需要记住一个点:element不一定是Object类型。

二、element如何生成真实节点

顺利得到element之后,我们再来看看React是如何把element转化成真实DOM节点的:

创建出来的 element 被当作参数和指定的 DOM container 一起传进ReactDOM.render. 接下来会调用一些内部方法, 接着调用了 instantiateReactComponent, 这个函数根据element的类型实例化对应的component. 规则如下:

先判断element是否为Object类型,是的话,看它的type是否是原生DOM标签,是的话创建ReactDOMComponent的实例对象并返回,其他同理。

image

简单的instantiateReactComponent函数实现、如下:

function instantiateReactComponent(node){
    // 文本节点的情况
    if(typeof node === 'string' || typeof node === 'number'){
        return new ReactDOMTextComponent(node);
    }
    // 浏览器默认节点的情况
    if(typeof node === 'object' && typeof node.type === 'string'){
        //注意这里,使用了一种新的component
        return new ReactDOMComponent(node);

    }
    // 自定义的元素节点,类型为构造函数
    if(typeof node === 'object' && typeof node.type === 'function'){
        // 注意这里,使用新的component,专门针对自定义元素
        return new ReactCompositeComponent(node);

    }
}

这时候有的人可能会有所疑问:这些个ReactDOMComponent, ReactCompositeComponentWrapper怎么开发的时候都没有见过?

其实这些都是React的私有类,React自己使用,不会暴露给用户的。它们的常用方法有:mountComponent,updateComponent等。其中mountComponent 用于创建组件,而updateComponent用于用户更新组件。而我们自定义组件的生命周期函数以及render函数都是在这些私有类的方法里被调用的。

既然这些私有类的方法那么重要我们就先来简单了解一下吧~

ReactDOMComponent

首先是ReactMComponent的mountComponent方法,这个方法的作用是:将element转成真实DOM节点,并且插入到相应的container里,然后返回realDOM。

由此可知ReactDOMComponentmountComponent是element生成真实节点的关键。

下面看个栗子它是怎么做到的吧。

假设有这样一个type类型是原生DOM的element:

{
  type: 'div',
    props: {
    className: 'cn',
      children: 'Hello world',
    }
}

简单mountComponent的实现:


mountComponent(container) {
  const domElement = document.createElement(this._currentElement.type);
  const textNode = document.createTextNode(this._currentElement.props.children);

  domElement.appendChild(textNode);
  container.appendChild(domElement);
  return domElement;
}

其实实现的过程很简单,就是根据type生成domElement,再将子节点append进来返回。当然,真实的mountComponent没有那么简单,感兴趣的可以自己去看源码啦。

讲完ReactDOMComponent,再来看看ReactCompositeComponentWrapper。

ReactCompositeComponentWrapper

这个类的mountComponent方法作用是:实例化自定义组件,最后是通过递归调用到ReactDOMComponent的mountComponent方法来得到真实DOM。

注意:也就是说他自己是不直接生成DOM节点的。

那这个递归是一个怎样的过程呢?我们通过首次渲染来看下。

首次渲染

假设我们有一个Example的组件,它返回<div>hello world</div> 这样一个标签。

首次渲染的过程如下:

image

首先从React.render开始,由于我们刚刚说,render函数被调用的时候会返回一个element,所以此时返回给我们的element是:

{
  type: function Example,
  props: {
    children: null
  }
}

由于这个type是一个自定义组件类,此时要初始化的类是ReactCompositeComponentWrapper,接着调用它的mountComponent方法。这里面会做四件事情,详情可以看上图。其中,第二步的render的得到的element为:

{
  type: 'div',
    props: {
    children: 'Hello World'
  }
}

由于这个type是一个原生DOM标签,此时要初始化的类是ReactDOMComponent。接下来它的mountComponent方法就可以帮我们生成对应的DOM节点放在浏览器里啦。

这时候有人可能会有疑问,如果第二步render出来的element 类型也是自定义组件呢?

这时候它就会去调用ReactCompositeComponentWrapper的mountComponent方法,从而形成了一个递归。不管你的自定义组件嵌套多少层,最后总会生成原生dom类型的element,所以最后一定能调用到ReactDOMComponent的mountComponent方法。

感兴趣的可以自己在打断点看下这个递归的过程。

由我打的断点图可以看出在ReactCompositeComponentmountComponent被调用多次之后,最后调用到了ReactDOMComponent的mountComponent方法。

image

到这里,首次渲染的过程就基本讲完了:-D。

但是还有一个问题:前面我们说自定义组件的生命周期跟render函数都是在私有类的方法里被调用的,现在只看到render函数被调用了,那么首次渲染时候生命周期函数 componentWillMountcomponentDidMount在哪被调用呢?

image

由图可知,在第一步得到instance对象之后,就会去看instance.componentWillMount是否有被定义,有的话调用,而在整个渲染过程结束之后调用componentDidMount。

以上,就是渲染原理的部分,让我们来总结以下:

JSX代码经过babel编译之后变成React.createElement的表达式,这个表达式在render函数被调用的时候执行生成一个element。

在首次渲染的时候,先去按照规则初始化element,接着ReactComponentComponentWrapper通过递归,最终调用ReactDOMComponent的mountComponent方法来帮助生成真实DOM节点。

React 渲染原理(2)--执行setState之后做了什么

由于总结的内容比较多并且是重点,单独整理了一篇文章
【React进阶系列】 setState机制

至此,React 的所有知识点大概梳理了一下。熟练掌握React的核心知识对学习React Fiber很有帮助。对React精通的大佬可以绕过。
接下来通过和上面的知识进行对比的形式来讲解React 对 Fiber的改造。


React 16之前

React.png
React 16 之前的不足

当时被大家拍手叫好的 VDOM,为什么今日会略显疲态,这还要从它的工作原理说起。在 react 发布之初,设想未来的 UI 渲染会是异步的,从 setState() 的设计和 react 内部的事务机制可以看出这点。在 react@16 以前的版本,reconciler(现被称为 stack reconciler )采用自顶向下递归,从根组件或 setState() 后的组件开始,更新整个子树。如果组件树不大不会有问题,但是当组件树越来越大,递归遍历的成本就越高,持续占用主线程,这样主线程上的布局、动画等周期性任务以及交互响应就无法立即得到处理,造成顿卡的视觉效果。

理论上人眼最高能识别的帧数不超过 30 帧,电影的帧数大多固定在 24,浏览器最优的帧率是 60,即16.5ms 左右渲染一次。 浏览器正常的工作流程应该是这样的,运算 -> 渲染 -> 运算 -> 渲染 -> 运算 -> 渲染 …

image.png

但是当 JS 执行时间过长,就变成了这个样子,FPS(每秒显示帧数)下降造成视觉上的顿卡。

image.png

之前的问题主要的问题是任务一旦执行,就无法中断,js 线程一直占用主线程,导致卡顿。

可能有些接触前端不久的不是特别理解上面为什么 js 一直占用主线程就会卡顿,我这里还是简单的普及一下。

浏览器每一帧都需要完成哪些工作?

页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿。

1s 60 帧,所以每一帧分到的时间是 1000/60 ≈ 16 ms。所以我们书写代码时力求不让一帧的工作量超过 16ms。

image.png

浏览器一帧内的工作

浏览器在一帧内可能会做执行下列任务,而且它们的执行顺序基本是固定的:

在上一小节提到的调和阶段花的时间过长,也就是 js 执行的时间过长,那么就有可能在用户有交互的时候,本来应该是渲染下一帧了,但是在当前一帧里还在执行 JS,就导致用户交互不能麻烦得到反馈,从而产生卡顿感。

解决方案

把渲染更新过程拆分成多个子任务,每次只做一小部分,做完看是否还有剩余时间,如果有继续下一个任务;如果没有,挂起当前任务,将时间控制权交给主线程,等主线程不忙的时候在继续执行。 这种策略叫做 Cooperative Scheduling(合作式调度),操作系统常用任务调度策略之一。

在上面我们已经知道浏览器是一帧一帧执行的,在执行完子任务,下一帧到来之前,主线程通常会有一小段空闲时间,requestIdleCallback可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),执行一些任务。

image.png

从上图也可看出,和 requestAnimationFrame 每一帧必定会执行不同,requestIdleCallback 是捡浏览器空闲来执行任务。

如此一来,假如浏览器一直处于非常忙碌的状态,requestIdleCallback 注册的任务有可能永远不会执行。此时可通过设置 timeout (见下一节 API 介绍)来保证执行。
这个方案看似确实不错,但是怎么实现可能会遇到几个问题:

接下里整个 Fiber 架构就是来解决这些问题的。

什么是 Fiber

为了解决上一节提到解决方案遇到的问题,我们首先需要一种方法将任务分解为单元。从某种意义上说,这就是 Fiber,将可中断的任务拆分成多个子任务,通过按照优先级来自由调度子任务,分段更新,从而将之前的同步渲染改为异步渲染。

这是一种’契约‘调度,要求我们的程序和浏览器紧密结合,互相信任。比如可以由浏览器给我们分配执行时间片(通过 requestIdleCallback实现, 下文会介绍),我们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。

image.png
Fiber与requestIdleCallback

Fiber所做的就是需要分解渲染任务,然后根据优先级使用API调度,异步执行指定任务:

1.低优先级任务由 requestIdleCallback处理;
2.高优先级任务,如动画相关的由 requestAnimationFrame处理;

  1. requestIdleCallback可以在多个空闲期调用空闲期回调,执行任务;
  2. requestIdleCallback方法提供 deadline,即任务执行限制时间,以切分任务,避免长时间执行,阻塞UI渲染而导致掉帧;

requestIdleCallback API
var handle = window.requestIdleCallback(callback[, options])

IdleDeadline对象参考MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/IdleDeadline

示例
requestIdleCallback(myNonEssentialWork, { timeout: 2000 });

let tasks = {
  length: 4
}

function myNonEssentialWork (deadline) {
  // 当回调函数是由于超时才得以执行的话,deadline.didTimeout为true
  // deadline.timeRemaining() 获取每一帧还剩余的时间
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
    console.log('done', tasks, deadline.timeRemaining())
    tasks.length = tasks.length - 1
  }
  if (tasks.length > 0) {
    requestIdleCallback(myNonEssentialWork);
  }
}

超时的情况,其实就是浏览器很忙,没有空闲时间,此时会等待指定的 timeout 那么久再执行,通过入参 dealine 拿到的 didTmieout 会为 true,同时 timeRemaining () 返回的也是 0。超时的情况下如果选择继续执行的话,肯定会出现卡顿的,因为必然会将一帧的时间拉长。


React 的Fiber架构

新的的调和器(Fiber-Recocilation)

为了和之前的Task-Reconciler做区分,我们把Fiber的 Reconciler叫做Fiber-Reconciler。 是 React 里的调和器,这也是任务调度完成之后,如何去执行每个任务,如何去更新每一个节点的过程

🔴Stack-Recocilation:JSX中创建(或更新)一些元素,react会根据这些元素创建(或更新)Virtual DOM,然后react根据更新前后virtual DOM的区别,去修改真正的DOM。注意,在stack reconciler下,DOM的更新是同步的,通过递归的方式进行渲染,发现一个或几个instance有更新,会立即执行DOM更新操作。


image.png

🔴Fiber-Recocilation:React 16版本提出了一个更先进的调和器,它允许渲染进程分段完成,而不必须一次性完成,中间可以返回至主进程控制执行其他任务。而这是通过计算部分组件树的变更,并暂停渲染更新,询问主进程是否有更高需求的绘制或者更新任务需要执行,这些高需求的任务完成后才开始渲染。这一切的实现是在代码层引入了一个新的数据结构-Fiber对象,每一个组件实例对应有一个fiber实例,此fiber实例负责管理组件实例的更新,渲染任务及与其他fiber实例的联系。通过stateNode属性管理Instance自身的特性。通过child和sibling表征当前工作单元的下一个工作单元,return表示处理完成后返回结果所要合并的目标,通常指向父节点。整个结构是一个链表树,结构如下:

image.png

Fiber-Reconciler的阶段划分

为了和之前的task Reconciler做区分,我们把Fiber的 Reconciler叫做Fiber Reconciler。 是 React 里的调和器,这也是任务调度完成之后,如何去执行每个任务,如何去更新每一个节点的过程

如果你现在使用最新的 React 版本(v16), 使用 Chrome 的 Performance 工具,可以很清晰地看到reconciler 过程分为2个阶段(phase):Reconciliation(协调阶段) 和 Commit(提交阶段).

image.png

也就是说,在协调阶段如果时间片用完,React就会选择让出控制权。因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。

需要注意的是:因为协调阶段可能被中断、恢复,甚至重做,⚠️React 协调阶段的生命周期钩子可能会被调用多次!, 例如 componentWillMount 可能会被调用两次。

因此建议 协调阶段的生命周期钩子不要包含副作用. 索性 React 就废弃了这部分可能包含副作用的生命周期方法,例如componentWillMountcomponentWillUpdate. v17后我们就不能再用它们了, 所以现有的应用应该尽快迁移.

现在你应该知道为什么'提交阶段'必须同步执行,不能中断的吧? 因为我们要正确地处理各种副作用,包括DOM变更、还有你在componentDidMount中发起的异步请求、useEffect 中定义的副作用... 因为有副作用,所以必须保证按照次序只调用一次,况且会有用户可以察觉到的变更, 不容差池。


数据结构的演进

Stack-Recocilation运行时存在3种实例:
{
    DOM //真实DOM节点
     -------
    Instances //instance是组件的实例,但是注意function形式的component没有实例
     -------
    Elements //Elements其实就基本就可以解释为Virtual DOM,是利用js对象的形式来描述一个DOM节点(type,props,...children)
}

// Elements是的数据结构简化如下:
let vNode = {
    type: 'div',
    key: '1',
    props: {
        className: 'title'
    },
    children: [
        {type: 'p', key: '2',text: 'hello'}
    ]
}

在首次渲染过程中构建出vDOM tree,后续需要更新时(setState())tree diff,compontent diffelement diff根据vDOM tree的数据结构(层级,类型,key)对比前后生成的vDOM tree得到DOM change,并把DOM change应用(patch)到DOM树。

Fiber数据结构

Fiber把渲染/更新过程(递归diff)拆分成一系列小任务,每次检查树上的一小部分,做完看是否还有时间继续下一个任务,有的话继续,没有的话把自己挂起,主线程不忙的时候再继续。增量更新需要更多的上下文信息,之前的vDOM tree显然难以满足,所以扩展出了fiber tree(即Fiber上下文的vDOM tree),更新过程就是根据输入数据以及现有的fiber tree构造出新的fiber tree(workInProgress tree)。因此,Instance层新增了这些实例:

DOM //
 真实DOM节点
-------
effect
    每个workInProgress tree节点上都有一个effect list
    用来存放diff结果
    当前节点更新完毕会向上merge effect list(queue收集diff结果)
- - - -
workInProgress
    workInProgress tree是reconcile过程中从fiber tree建立的当前进度快照,用于断点恢复
- - - -
fiber
    fiber tree与vDOM tree类似,用来描述增量更新所需的上下文信息
-------
Elements
    描述UI长什么样子(type, props,...children)

截止目前,我们对Fiber应该有了初步的了解,简单介绍一下Fiber Node的数据结构,数据结构能一定程度反映其整体工作架构。
其实,一个fiber就是一个JavaScript对象,以键值对形式存储了一个关联组件的信息,包括组件接收的props,维护的state,最后需要渲染出的内容等。fiber 节点相当于以前的虚拟 dom 节点,结构如下:

interface Fiber {
  /**
   * ⚛️ 节点的类型信息
   */
  tag: number,   // Fiber 类型,以数字表示,可选择的如下
    - IndeterminateComponent
    - FunctionalComponent
    - ClassComponent // Menu, Table
    - HostRoot // ReactDOM.render 的第二个参数
    - HostPortal
    - HostComponent // div, span
    - HostText // 纯文本节点,即 dom  的 nodeName 等于 '#text'
    - CallComponent // 对应 call return 中的 call
    - CallHandlerPhase // call 中的 handler 阶段
    - ReturnComponent // 对应 call return 中的 return
    - Fragment 
    - Mode // AsyncMode || StrictMode
    - ContextConsumer
    - ContextProvider
    - ForwardRef
  type: any,  // 节点元素类型, /与 react element 里的 type 一致
  key: null | string, // fiber 的唯一标识
  stateNode: any,  // 对应组件或者 dom 的实例
  /**
   * ⚛️ 结构信息
   */ 
  // 单链表树结构
  return: Fiber | null,// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
  child: Fiber | null,// 指向自己的第一个子节点
  sibling: Fiber | null,  // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点

  /**
   * ⚛️ 更新相关
   */
   pendingProps: any,  // 新的、待处理的props
   updateQueue: UpdateQueue<any> | null,  // 该Fiber对应的组件产生的Update会存放在这个队列里面
   memoizedProps: any,  // 上一次渲染完成之后的props
   memoizedState: any, // 上一次渲染的时候的state
  /**
   * ⚛️  Effect 相关的
   */
// 和节点关系一样,React 同样使用链表来将所有有副作用的Fiber连接起来
  effectTag: SideEffectTag<number>, //当前节点的副作用类型,例如节点更新、删除、移动
  nextEffect: Fiber | null, // 单链表用来快速查找下一个side effect
  firstEffect: Fiber | null,  // 子树中第一个side effect
  lastEffect: Fiber | null, // 子树中最后一个side effect
}
/**
   * ⚛️ 替身
   */
 // 在Fiber树更新的过程中,每个Fiber都会有一个跟其对应的Fiber
  // 我们称他为`current <==> workInProgress`
  // 在渲染完成之后他们会交换位置
   alternate: Fiber | null,  // WIP 树里面的 fiber,如果不在更新期间,那么就等于当前的 fiber,如果是新创建的节点,那么就没有 

Fiber 包含的属性可以划分为 5 个部分:

Fiber类型

上一小节,Fiber对象中有个tag属性,标记fiber类型,而fiber实例是和组件对应的,所以其类型基本上对应于组件类型,源码见ReactTypeOfWork模块

export  const  IndeterminateComponent  =  0;  // 尚不知是类组件还是函数式组件 
export const FunctionalComponent = 1; // 函数式组件
export const ClassComponent = 2; // Class类组件
export const HostRoot = 3; // 组件树根组件,可以嵌套
export const HostPortal = 4; // 子树. Could be an entry point to a different renderer.
export const HostComponent = 5; // 标准组件,如地div, span等 
export const HostText = 6; // 文本
export const CallComponent = 7; // 组件调用
export const CallHandlerPhase = 8; // 调用组件方法
export const ReturnComponent = 9; // placeholder(占位符)
export const Fragment = 10; // 片段

在调度执行任务的时候会根据不同类型fiber,即fiber.tag值进行不同处理。


任务如何分片及分片的优先级

任务分片,或者叫工作单元(work unit),是怎么拆分的呢。因为在Reconciliation阶段任务分片可以被打断,用来执行优先级高的任务。如何拆分一个任务就很重要了。

为了达到任务分片的效果,就需要有一个调度器 (Scheduler) 来进行任务分配。任务的优先级有六种:

export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;
module.exports = {
    synchronous,//0,synchronous首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程
    task,//1,在next tick之前执行
    animation,//2,animation通过requestAnimationFrame来调度,这样在下一帧就能立即开始动画过程                                                                                                                                                                               
    high,//3,在不久的将来立即执行
    low,//4,稍微延迟执行也没关系
    offscreen,//5,下一次render时或scroll时才执行
}

也就是说,(不考虑突发事件的)正常调度是由工作循环来完成的,基本规则是:每个工作单元结束检查是否还有时间做下一个,没时间了就先“挂起”

优先级机制用来处理突发事件与优化次序,例如:

这些策略用来动态调整任务调度,是工作循环的辅助机制,最先做最重要的事情

任务调度的过程是:

在任务队列中选出高优先级的fiber node执行,调用requestIdleCallback获取所剩时间,若执行时间超过了deathLine,或者突然插入更高优先级的任务,则执行中断,保存当前结果,修改fiber node 的tag标记,设置为pending状态,迅速收尾并再调用一个requestIdleCallback,等主线程释放出来再继续
恢复任务执行时,检查tag是被中断的任务,会接着继续做任务或者重做


Fiber Tree

React 在 render 第一次渲染时,会通过 React.createElement 创建一颗 Element 树,可以称之为 Virtual DOM Tree,由于要记录上下文信息,加入了 Fiber,每一个 Element 会对应一个 Fiber Node,将 Fiber Node 链接起来的结构成为 Fiber Tree。它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current 树(当前树,记录当前页面的状态)。

Fiber Tree 一个重要的特点是链表结构,将递归遍历编程循环遍历,然后配合 requestIdleCallback API, 实现任务拆分、中断与恢复。

这个链接的结构是怎么构成的呢,这就要主要到之前 Fiber Node 的节点的这几个字段:

// 单链表树结构
{
   return: Fiber | null, // 指向父节点
   child: Fiber | null,// 指向自己的第一个子节点
   sibling: Fiber | null,// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
}

每一个 Fiber Node 节点与 Virtual Dom 一一对应,所有 Fiber Node 连接起来形成 Fiber tree, 是个单链表树结构,因为使用了链表结构,即使处理流程被中断了,我们随时可以从上次未处理完的Fiber继续遍历下去。如下图所示:

image.png

比如你在text(hello)中断了,那么下一次就会从 p 节点开始处理

这个数据结构调整还有一个好处,就是某些节点异常时,我们可以打印出完整的’节点栈‘,只需要沿着节点的return回溯即可。


Side Effect(副作用)

我们可以将 React 中的一个组件视为一个使用 state 和 props 来计算 UI 表示的函数。其他所有活动,如改变 DOM 或调用生命周期方法,都应该被视为副作用,或者简单地说是一种效果。文档中 是这样描述的:

您之前可能已经在 React 组件中执行数据提取,订阅或手动更改 DOM。我们将这些操作称为“副作用”(或简称为“效果”),因为它们会影响其他组件,并且在渲染过程中无法完成。

您可以看到大多 state 和 props 更新都会导致副作用。既然使用副作用是工作(活动)的一种类型,Fiber 节点是一种方便的机制来跟踪除了更新以外的效果。每个 Fiber 节点都可以具有与之相关的副作用,它们可在 effectTag 字段中编码。

因此,Fiber 中的副作用基本上定义了处理更新后需要为实例完成的 工作。对于宿主组件(DOM 元素),所谓的工作包括添加,更新或删除元素。对于类组件,React可能需要更新 refs 并调用 componentDidMountcomponentDidUpdate 生命周期方法。对于其他类型的 Fiber ,还有相对应的其他副作用。

Effects List

React 处理更新的素对非常迅速,为了达到这种水平的性能,它采用了一些有趣的技术。其中之一是构建具有副作用的 Fiber 节点的线性列表,从而能够快速遍历。遍历线性列表比树快得多,并且没有必要在没有副作用的节点上花费时间。

此列表的目标是标记具有 DOM 更新或其他相关副作用的节点。此列表是 finishedWork 树的子集,并使用 nextEffect 属性而不是 currentworkInProgress 树中使用的 child 属性进行链接。

Dan Abramov 为副作用列表提供了一个类比。他喜欢将它想象成一棵圣诞树,「圣诞灯」将所有有效节点捆绑在一起。为了使这个可视化,让我们想象如下的 Fiber 节点树,其中标亮的节点有一些要做的工作。例如,我们的更新导致 c2 被插入到 DOM 中,d2c1 被用于更改属性,而 b2 被用于触发生命周期方法。副作用列表会将它们链接在一起,以便 React 稍后可以跳过其他节点:

image.png

可以看到具有副作用的节点是如何链接在一起的。当遍历节点时,React 使用 firstEffect 指针来确定列表的开始位置。所以上面的图表可以表示为这样的线性列表:

image.png

如您所见,React 按照从子到父的顺序应用副作用。


react Fiber是如何工作的

举个例子
export class List extend React.component {
    render() {
        return (
            <div>
               <button value="平方" />
               <button value="字体" />
               <Item item={1}/>
               <Item item={2}/>
               <Item item={3}/>
            </div>
        )
    }
}
export class Item extend React.component {
    render() {
        return (
            <div>
                {this.props.item}
            </div>
        )
    }
}
export class Home extend React.component<HomeProps, any> {
    componentWillReceiveProps(nextProps: HomeProps) {}
    componentDidMount() {}
    componentDidUpdate() {}
    componentWillUnmount() {}
    .....
    render() {
        return (
            <div>
              <List/>
            </div>
        )
    }
}
ReactDom.render(<Home />, document.querySelector(selectors: '#hostRoot'))

当前页面包含一个列表,通过该列表渲染出一个button和一组Item,Item中包含一个div,其中的内容为数字。通过点击button,可以使列表中的所有数字进行平方。另外有一个按钮,点击可以调节字体大小。


image.png

页面渲染完成后,就会初始化生成一个fiber-tree。初始化fiber-tree和初始化Virtual DOM tree没什么区别,这里就不再赘述。

image.png

于此同时,react还会维护一个workInProgressTree。workInProgressTree用于计算更新,完成reconciliation过程。

image.png

用户点击平方按钮后,利用各个元素平方后的list调用setState,react会把当前的更新送入list组件对应的update queue中。但是react并不会立即执行对比并修改DOM的操作。而是交给scheduler去处理。

image.png

scheduler会根据当前主线程的使用情况去处理这次update。为了实现这种特性,使用了requestIdelCallbackAPI。对于不支持这个API的浏览器,react会加上pollyfill。

总的来讲,通常,客户端线程执行任务时会以帧的形式划分,大部分设备控制在30-60帧是不会影响用户体验;在两个执行帧之间,主线程通常会有一小段空闲时间,requestIdleCallback可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),执行一些任务

image.png
  1. 低优先级任务由requestIdleCallback处理;
  2. 高优先级任务,如动画相关的由requestAnimationFrame处理;
  3. requestIdleCallback可以在多个空闲期调用空闲期回调,执行任务;
  4. requestIdleCallback方法提供deadline,即任务执行限制时间,以切分任务,避免长时间执行,阻塞UI渲染而导致掉帧;

image.png

image.png

一旦reconciliation过程得到时间片,就开始进入work loop。work loop机制可以让react在计算状态和等待状态之间进行切换。为了达到这个目的,对于每个loop而言,需要追踪两个东西:下一个工作单元(下一个待处理的fiber);当前还能占用主线程的时间。第一个loop,下一个待处理单元为根节点。

image.png

因为根节点上的更新队列为空,所以直接从fiber-tree上将根节点复制到workInProgressTree中去。根节点中包含指向子节点(List)的指针。

image.png

根节点没有什么更新操作,根据其child指针,接下来把List节点及其对应的update queue也复制到workinprogress中。List插入后,向其父节点返回,标志根节点的处理完成。

image.png

根节点处理完成后,react此时检查时间片是否用完。如果没有用完,根据其保存的下个工作单元的信息开始处理下一个节点List。

image.png

接下来进入处理List的work loop,List中包含更新,因此此时react会调用setState时传入的updater funciton获取最新的state值,此时应该是[1,4,9]。通常我们现在在调用setState传入的是一个对象,但在使用fiber conciler时,必须传入一个函数,函数的返回值是要更新的state。react从很早的版本就开始支持这种写法了,不过通常没有人用。在之后的react版本中,可能会废弃直接传入对象的写法。

setState({}, callback); // stack conciler
setState(() => { return {} }, callback); // fiber conciler
复制代码

在获取到最新的state值后,react会更新List的state和props值,然后调用render,然后得到一组通过更新后的list值生成的elements。react会根据生成elements的类型,来决定fiber是否可重用。对于当前情况来说,新生成的elments类型并没有变(依然是Button和Item),所以react会直接从fiber-tree中复制这些elements对应的fiber到workInProgress 中。并给List打上标签,因为这是一个需要更新的节点。

image.png

List节点处理完成,react仍然会检查当前时间片是否够用。如果够用则处理下一个,也就是button。加入这个时候,用户点击了放大字体的按钮。这个放大字体的操作,纯粹由js实现,跟react无关。但是操作并不能立即生效,因为react的时间片还未用完,因此接下来仍然要继续处理button。

image.png

button没有任何子节点,所以此时可以返回,并标志button处理完成。如果button有改变,需要打上tag,但是当前情况没有,只需要标记完成即可。

image.png

老规矩,处理完一个节点先看时间够不够用。注意这里放大字体的操作已经在等候释放主线程了。

image.png

接下来处理第一个item。通过shouldComponentUpdate钩子可以根据传入的props判断其是否需要改变。对于第一个Item而言,更改前后都是1,所以不会改变,shouldComponentUpdate返回false,复制div,处理完成,检查时间,如果还有时间进入第二个Item。

第二个Item shouldComponentUpdate返回true,所以需要打上tag,标志需要更新,复制div,调用render,讲div中的内容从2更新为4,因为div有更新,所以标记div。当前节点处理完成。

image.png

对于上面这种情况,div已经是叶子节点,且没有任何兄弟节点,且其值已经更新,这时候,需要将此节点改变产生的effect合并到父节点中。此时react会维护一个列表,其中记录所有产生effect的元素。

image.png

合并后,回到父节点Item,父节点标记完成。

image.png

下一个工作单元是Item,在进入Item之前,检查时间。但这个时候时间用完了。此时react必须交换主线程,并告诉主线程以后要为其分配时间以完成剩下的操作。

image.png

主线程接下来进行放大字体的操作。完成后执行react接下来的操作,跟上一个Item的处理流程几乎一样,处理完成后整个fiber-tree和workInProgress如下:

image.png

完成后,Item向List返回并merge effect,effect List现在如下所示:

image.png

此时List向根节点返回并merge effect,所有节点都可以标记完成了。此时react将workInProgress标记为pendingCommit。意思是可以进入commit阶段了。

image.png

此时,要做的是还是检查时间够不够用,如果没有时间,会等到时间再去提交修改到DOM。进入到阶段2后,reacDOM会根据阶段1计算出来的effect-list来更新DOM。

更新完DOM之后,workInProgress就完全和DOM保持一致了,为了让当前的fiber-tree和DOM保持一直,react交换了current和workinProgress两个指针。

image.png

事实上,react大部分时间都在维持两个树(Double-buffering)。这可以缩减下次更新时,分配内存、垃圾清理的时间。commit完成后,执行componentDidMount函数。


下面是一个详细的执行过程图:

image.png

Reconciliation Phase(协调阶段)

Reconciliation Phase阶段以fiber tree为蓝本,把每个fiber作为一个工作单元,自顶向下逐节点构造workInProgress tree(构建中的新fiber tree),具体过程如下(以组件节点为例):

实际上1-7是Reconciliation阶段的工作循环,下一节讲重点讲。7是Reconciliation阶段的出口,工作循环每次只做一件事,做完看要不要喘口气。工作循环结束时,workInProgress tree的根节点身上的effect list就是收集到的所有side effect(因为每做完一个都向上归并)

alternate、current Tree及 workInProgress Tree的关系

在第一次渲染之后,React 最终得到一个 Fiber 树,它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current 树(当前树)。当 React 开始处理更新时,它会构建一个所谓的workInProgress tree(工作进度树)workInProgress tree是reconcile过程中从fiber tree建立的当前进度快照,用于断点恢复。

Fiber在update的时候,会从原来的Fiber(我们称为current)clone出一个新的Fiber(我们称为alternate)。两个Fiber diff出的变化(side effect)记录在alternate上。所以一个组件在更新时最多会有两个Fiber与其对应,在更新结束后alternate会取代之前的current的成为新的current节点。

所有工作都在 workInProgress 树的 Fiber 节点上执行。当 React 遍历 current 树时,对于每个现有 Fiber 节点,React 会创建一个构成 workInProgress 树的备用节点,这一节点会使用 render 方法返回的 React 元素中的数据来创建。处理完更新并完成所有相关工作后,React 将准备好一个备用树以刷新到屏幕。一旦这个 workInProgress 树在屏幕上呈现,它就会变成 current 树。

React 的核心原则之一是一致性。 React 总是一次性更新 DOM - 它不会显示部分中间结果。workInProgress 树充当用户不可见的「草稿」,这样 React 可以先处理所有组件,然后将其更改刷新到屏幕。

在源代码中,您将看到很多函数从 currentworkInProgress 树中获取 Fiber 节点。这是一个这类函数的签名:

function updateHostComponent(current, workInProgress, renderExpirationTime) {...}

每个Fiber节点持有备用域在另一个树的对应部分的引用。来自 current 树中的节点会指向 workInProgress 树中的节点,反之亦然。


工作循环的主要步骤

举个例子:

React.Component.prototype.setState = function( partialState, callback ) {
  updateQueue.pus( {
    stateNode: this,
    partialState: partialState
  } );
  requestIdleCallback(performWork); // 这里就开始干活了
}

function performWork(deadline) {
  workLoop(deadline)
  if (nextUnitOfWork || updateQueue.length > 0) {
    requestIdleCallback(performWork) //继续干
  }
}

setState先把此次更新放到更新队列 updateQueue 里面,然后调用调度器开始做更新任务。performWork 先调用 workLoop 对 fiber 树进行遍历比较,就是我们上面提到的遍历过程。当此次时间片时间不够遍历完整个 fiber 树,或者遍历并比较完之后workLoop 函数结束。接下来我们判断下 fiber 树是否遍历完或者更新队列 updateQueue 是否还有待更新的任务。如果有则调用 requestIdleCallback 在下个时间片继续干活。nextUnitOfWork 是个全局变量,记录 workLoop 遍历 fiber 树中断在哪个节点。

所有的 Fiber 节点都会在 工作循环 中进行处理。如下是该循环的同步部分的实现:

function workLoop(deadline) {
  if (!nextUnitOfWork) {
    //一个周期内只创建一次
    nextUnitOfWork = createWorkInProgress(updateQueue)
  }

  while (nextUnitOfWork && deadline.timeRemaining() > EXPIRATION_TIME) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }

  if (pendingCommit) {
    //当全局 pendingCommit 变量被负值
    commitAllwork(pendingCommit)
  }
}

刚开始遍历的时候判断全局变量 nextUnitOfWork 是否存在?如果存在表示上次任务中断了,我们继续,如果不存在我们就从更新队列里面取第一个任务,并生成对应的 fiber 根节点。接下来我们就是正式的工作了,用循环从某个节点开始遍历 fiber 树。performUnitOfWork 根据我们上面提到的遍历规则,在对当前节点处理完之后,返回下一个需要遍历的节点。循环除了要判断是否有下一个节点(是否遍历完),还要判断当前给你的时间是否用完,如果用完了则需要返回,让浏览器响应用户的交互事件,然后再在下个时间片继续。workLoop 最后一步判断全局变量 pendingCommit 是否存在,如果存在则把这次遍历 fiber 树产生的所有更新一次更新到真实的 dom 上去。注意 pendingCommit 在完成一次完整的遍历过程之前是不会有值的。

遍历树、初始化或完成工作主要用到 4 个函数:

1.workLoop阶段

构建workInProgress tree的过程就是diff的过程,对 Fiber tree前后进行比对主要是beginWork,源码如下:

function beginWork(fiber: Fiber): Fiber | undefined {
  if (fiber.tag === WorkTag.HostComponent) {
    // 宿主节点diff
    diffHostComponent(fiber)
  } else if (fiber.tag === WorkTag.ClassComponent) {
    // 类组件节点diff
    diffClassComponent(fiber)
  } else if (fiber.tag === WorkTag.FunctionComponent) {
    // 函数组件节点diff
    diffFunctionalComponent(fiber)
  } else {
    // ... 其他类型节点,省略
  }
}

宿主节点比对:

function diffHostComponent(fiber: Fiber) {
  // 新增节点
  if (fiber.stateNode == null) {
    fiber.stateNode = createHostComponent(fiber)
  } else {
    updateHostComponent(fiber)
  }

  const newChildren = fiber.pendingProps.children;

  // 比对子节点
  diffChildren(fiber, newChildren);
}

类组件节点比对也差不多:

function diffClassComponent(fiber: Fiber) {
  // 创建组件实例
  if (fiber.stateNode == null) {
    fiber.stateNode = createInstance(fiber);
  }

  if (fiber.hasMounted) {
    // 调用更新前生命周期钩子
    applybeforeUpdateHooks(fiber)
  } else {
    // 调用挂载前生命周期钩子
    applybeforeMountHooks(fiber)
  }

  // 渲染新节点
  const newChildren = fiber.stateNode.render();
  // 比对子节点
  diffChildren(fiber, newChildren);

  fiber.memoizedState = fiber.stateNode.state
}

子节点比对:

function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
  let oldFiber = fiber.alternate ? fiber.alternate.child : null;
  // 全新节点,直接挂载
  if (oldFiber == null) {
    mountChildFibers(fiber, newChildren)
    return
  }

  let index = 0;
  let newFiber = null;
  // 新子节点
  const elements = extraElements(newChildren)

  // 比对子元素
  while (index < elements.length || oldFiber != null) {
    const prevFiber = newFiber;
    const element = elements[index]
    const sameType = isSameType(element, oldFiber)
    if (sameType) {
      newFiber = cloneFiber(oldFiber, element)
      // 更新关系
      newFiber.alternate = oldFiber
      // 打上Tag
      newFiber.effectTag = UPDATE
      newFiber.return = fiber
    }

    // 新节点
    if (element && !sameType) {
      newFiber = createFiber(element)
      newFiber.effectTag = PLACEMENT
      newFiber.return = fiber
    }

    // 删除旧节点
    if (oldFiber && !sameType) {
      oldFiber.effectTag = DELETION;
      oldFiber.nextEffect = fiber.nextEffect
      fiber.nextEffect = oldFiber
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    if (index == 0) {
      fiber.child = newFiber;
    } else if (prevFiber && element) {
      prevFiber.sibling = newFiber;
    }

    index++
  }
}

function createWorkInProgress(updateQueue) {
  const updateTask = updateQueue.shift()
  if (!updateTask) return

  if (updateTask.partialState) {
    // 证明这是一个setState操作
    updateTask.stateNode._internalfiber.partialState = updateTask.partialState
  }

  const rootFiber =
    updateTask.fromTag === tag.HostRoot
      ? updateTask.stateNode._rootContainerFiber
      : getRoot(updateTask.stateNode._internalfiber)

  return {
    tag: tag.HostRoot,
    stateNode: updateTask.stateNode,
    props: updateTask.props || rootFiber.props,
    alternate: rootFiber // 用于链接新旧的 VDOM
  }
}

function getRoot(fiber) {
  let _fiber = fiber
  while (_fiber.return) {
    _fiber = _fiber.return
  }
  return _fiber
}

createWorkInProgress 拿出更新队列 updateQueue 第一个任务,然后看触发这个任务的节点是什么类型。如果不是根节点,则通过循环迭代节点的 return 找到最上层的根节点。最后生成一个新的 fiber 节点,这个节点就是当前 fiber 节点的 alternate 指向的,也就是说下面会在当前节点和这个新生成的节点直接进行 diff。

function performUnitOfWork(workInProgress) {
  const nextChild = beginWork(workInProgress)
  if (nextChild) return nextChild

  // 没有 nextChild, 我们看看这个节点有没有 sibling
  let current = workInProgress
  while (current) {
    //收集当前节点的effect,然后向上传递
    completeWork(current)
    if (current.sibling) return current.sibling
    //没有 sibling,回到这个节点的父亲,看看有没有sibling
    current = current.return
  }
}

函数 performUnitOfWorkworkInProgress 树接收一个 Fiber 节点,并通过调用 beginWork 函数启动工作。这个函数将启动所有 Fiber 执行工作所需要的活动。出于演示的目的,我们只 log 出 Fiber 节点的名称来表示工作已经完成。函数 beginWork 始终返回指向要在循环中处理的下一个子节点的指针或 null。
如果有下一个子节点,它将被赋值给 workLoop 函数中的变量 nextUnitOfWork。但是,如果没有子节点,React 知道它到达了分支的末尾,因此它可以完成当前节点。一旦节点完成,它将需要为同层的其他节点执行工作,并在完成后回溯到父节点。这是 completeUnitOfWork 函数执行的代码:

function completeUnitOfWork(workInProgress) {
    while (true) {
        let returnFiber = workInProgress.return;
        let siblingFiber = workInProgress.sibling;

        nextUnitOfWork = completeWork(workInProgress);

        if (siblingFiber !== null) {
            // If there is a sibling, return it
            // to perform work for this sibling
            return siblingFiber;
        } else if (returnFiber !== null) {
            // If there's no more work in this returnFiber,
            // continue the loop to complete the parent.
            workInProgress = returnFiber;
            continue;
        } else {
            // We've reached the root.
            return null;
        }
    }
}

你可以看到函数的核心就是一个大的 while 的循环。当 workInProgress 节点没有子节点时,React 会进入此函数。完成当前 Fiber 节点的工作后,它就会检查是否有同层节点。如果找的到,React 退出该函数并返回指向该同层节点的指针。它将被赋值给 nextUnitOfWork 变量,React将从这个节点开始执行分支的工作。我们需要着重理解的是,在当前节点上,React 只完成了前面的同层节点的工作。它尚未完成父节点的工作。只有在完成以子节点开始的所有分支后,才能完成父节点和回溯的工作。
从实现中可以看出,performUnitOfWorkcompleteUnitOfWork 主要用于迭代目的,而主要活动则在beginWorkcompleteWork 函数中进行。

function completeWork(fiber) {
  const parent = fiber.return

  // 到达顶端
  if (parent == null || fiber === topWork) {
    pendingCommit = fiber
    return
  }

  if (fiber.effectTag != null) {
    if (parent.nextEffect) {
      parent.nextEffect.nextEffect = fiber
    } else {
      parent.nextEffect = fiber
    }
  } else if (fiber.nextEffect) {
    parent.nextEffect = fiber.nextEffect
  }
}

2、 completeWork阶段

completeWork的工作主要是通过新老节点的prop或tag等,收集节点的effect-list。然后向上一层一层循环,merge每个节点的effect-list,当到达根节点#hostRoot时,节点上包含所有的effect-list。并把effect-list传给pendingcommit,进入commit阶段。

1203274-20180831173358256-1834919628.jpg

当回溯完,有了 pendingCommit,则 commitAllwork 会被调用。它做的工作就是循环遍历根节点的 effets 数据,里面保存着所有要更新的内容。commitWork 就是执行具体更新的函数,这里就不展开了(因为这篇主要想讲的是 fiber 更新的调度算法)。

所以你们看遍历 dom 数 diff 的过程是可以被打断并且在后续的时间片上接着干,只是最后一步 commitAllwork 是同步的不能打断的。这样 react 使用新的调度算法优化了更新过程中执行时间过长导致的页面卡顿现象。
接下来就是将所有打了 Effect 标记的节点串联起来,这个可以在completeWork中做, 例如:

completeWork
function completeWork(fiber) {
  const parent = fiber.return

  // 到达顶端
  if (parent == null || fiber === topWork) {
    pendingCommit = fiber
    return
  }

  if (fiber.effectTag != null) {
    if (parent.nextEffect) {
      parent.nextEffect.nextEffect = fiber
    } else {
      parent.nextEffect = fiber
    }
  } else if (fiber.nextEffect) {
    parent.nextEffect = fiber.nextEffect
  }
}

commitAllWork

function commitAllWork(fiber) {
  let next = fiber
  while(next) {
    if (fiber.effectTag) {
      // 提交,偷一下懒,这里就不展开了
      commitWork(fiber)
    }
    next = fiber.nextEffect
  }

  // 清理现场
  pendingCommit = nextUnitOfWork = topWork = null
}

commit (提交阶段)

commit阶段可以理解为就是将 Diff 的结果反映到真实 DOM 的过程。这一阶段从函数 completeRoot 开始。在这个阶段,React 更新 DOM 并调用变更生命周期之前及之后方法的地方。

当 React 进入这个阶段时,它有 2 棵树和副作用列表。第一个树表示当前在屏幕上渲染的状态,然后在 render 阶段会构建一个备用树。它在源代码中称为 finishedWorkworkInProgress,表示需要映射到屏幕上的状态。此备用树会用类似的方法通过 childsibling 指针链接到 current 树。

然后,有一个副作用列表 -- 它是 finishedWork 树的节点子集,通过 nextEffect 指针进行链接。需要记住的是,副作用列表是运行 render 阶段的结果。渲染的重点就是确定需要插入、更新或删除的节点,以及哪些组件需要调用其生命周期方法。这就是副作用列表告诉我们的内容,它页正是在 commit 阶段迭代的节点集合。

出于调试目的,可以通过 Fiber 根的属性 current访问 current 树。可以通过 current 树中 HostFiber 节点的 alternate 属性访问 finishedWork 树。

commit 阶段运行的主要函数是 commitRoot 。它执行如下下操作:

在调用变更前方法 getSnapshotBeforeUpdate 之后,React 会在树中提交所有副作用,这会通过两波操作来完成。第一波执行所有 DOM(宿主)插入、更新、删除和 ref 卸载。然后 React 将 finishedWork 树赋值给 FiberRoot,将 workInProgress 树标记为 current 树。这是在提交阶段的第一波之后、第二波之前完成的,因此在 componentWillUnmount 中前一个树仍然是 current,在 componentDidMount/Update 期间已完成工作是 current。在第二波,React 调用所有其他生命周期方法和引用回调。这些方法单独传递执行,从而保证整个树中的所有放置、更新和删除能够被触发执行。

以下是运行上述步骤的函数的要点:

function commitRoot(root, finishedWork) {
    commitBeforeMutationLifecycles()
    commitAllHostEffects();
    root.current = finishedWork;
    commitAllLifeCycles();
}

这些子函数中都实现了一个循环,该循环遍历副作用列表并检查副作用的类型。当它找到与函数目的相关的副作用时,就会执行。

在 commit 阶段,在 commitRoot 里会根据 effecteffectTag,具体 effectTag 见源码 ,进行对应的插入、更新、删除操作,根据 tag 不同,调用不同的更新方法。并把这些更新提交到当前节点的父亲。当遍历完这颗树的时候,再通过 return 回溯到根节点。这个过程中把所有的更新全部带到根节点,再一次更新到真实的 dom 中去。如下图所示:

image

从根节点开始:

⚛️ 1. div1 通过 child 到 div2。
⚛️ 2. div2 和自己的 alternate 比较完把更新 commit1 通过 return 提交到 div1。
⚛️ 3. div2 通过 sibling 到 ul1。
⚛️ 4. ul1 和自己的 alternate 比较完把更新 commit2 通过 return 提交到 div1。
⚛️ 5. ul1 通过 child 到 li1。
⚛️ 6. li1 和自己的 alternate 比较完把更新 commit3 通过 return 提交到 ul1。
⚛️ 7. li1 通过 sibling 到 li2。
⚛️ 8. li2 和自己的 alternate 比较完把更新 commit4 通过 return 提交到 ul1。
⚛️ 9. 遍历完整棵树开始回溯,li2 通过 return 回到 ul1。
⚛️ 10. 把 commit3 和 commit4 通过 return 提交到 div1。
⚛️ 11. ul1 通过 return 回到 div1。
⚛️ 12. 获取到所有更新 commit1-4,一次更新到真是的 dom 中去。


双缓冲原理

image.png

当 render 的时候有了这么一条单链表,当调用 setState 的时候又是如何 Diff 得到 change 的呢?
采用的是一种叫双缓冲技术(double buffering),这个时候就需要另外一颗树:WorkInProgress Tree,它反映了要刷新到屏幕的未来状态。
WorkInProgress Tree 构造完毕,得到的就是新的 Fiber Tree,然后喜新厌旧(把 current 指针指向WorkInProgress Tree,丢掉旧的 Fiber Tree)就好了。
这样做的好处:

能够复用内部对象(fiber),比如某颗子树不需要变动,React会克隆复用旧树中的子树。
节省内存分配、GC的时间开销,
就算运行中有错误,也不会影响 View 上的数据,比如当一个节点抛出异常,仍然可以继续沿用旧树的节点,避免整棵树挂掉

每个 Fiber上都有个alternate属性,也指向一个 Fiber,创建 WorkInProgress 节点时优先取alternate,没有的话就创建一个。
创建 WorkInProgress Tree 的过程也是一个 Diff 的过程,Diff 完成之后会生成一个 Effect List,这个 Effect List 就是最终 Commit 阶段用来处理副作用的阶段。

Dan 在 Beyond React 16 演讲中用了一个非常恰当的比喻,那就是Git 功能分支,你可以将 WIP 树想象成从旧树中 Fork 出来的功能分支,你在这新分支中添加或移除特性,即使是操作失误也不会影响旧的分支。当你这个分支经过了测试和完善,就可以合并到旧分支,将其替换掉. 这或许就是’提交(commit)阶段‘的提交一词的来源吧?:

image.png

后记

本开始想一篇文章把 Fiber 讲透的,但是写着写着发现确实太多了,想写详细,估计要写几万字,所以我这篇文章的目的仅仅是在没有涉及到源码的情况下梳理了大致 React 的工作流程,对于细节,比如如何调度异步任务、如何去做 Diff 等等细节将以小节的方式一个个的结合源码进行分析。
说实话,自己不是特别满意这篇,感觉头重脚轻,在之后的学习中会逐渐完善这篇文章。,这篇文章拖太久了,请继续后续的文章。

站在巨人肩上

⚛️React 拾遗:React.createElement 与 JSX
⚛️ React的React.createElement源码解析(一)
⚛️ React 组件Component,元素Element和实例Instance的区别
⚛️ React Fiber 那些事: 深入解析新的协调算法
⚛️ React-从源码分析React Fiber工作原理
⚛️浅谈 React Fiber
⚛️ React Fiber架构
⚛️ 完全理解React Fiber
⚛️ 这可能是最通俗的 React Fiber(时间分片) 打开方式
⚛️ Deep In React 之浅谈 React Fiber 架构(一)
⚛️ React Fiber初探
⚛️React Diff 算法
⚛️ React16性能改善的原理(二)
⚛️ 浅谈React16框架 - Fiber
⚛️React的第一次渲染过程浅析
⚛️ react的更新机制
⚛️ 【React进阶系列】 setState机制
⚛️深入React技术栈之setState详解
⚛️ 【React深入】setState的执行机制

上一篇下一篇

猜你喜欢

热点阅读