2021-「React Fiber」 详细解析

原文地址原文地址 TODO!

「React Fiber」 详细解析

距离React Fiber发布已经两年多的时间了,你有没有真的了解它呢?

React Fiber是什么?官方的解释是 “React Fiber是对核心算法的一次重新实现”

使用React框架的开发者都知道,React是靠数据驱动视图改变的一种框架,它的核心驱动方法就是用其提供的setState方法设置state中的数据从而驱动存放在内存中的虚拟DOM树的更新。

更新方法就是通过ReactDiff算法比较旧虚拟DOM树和新虚拟DOM树之间的Change ,然后批处理这些改变。

Fiber诞生之前,React处理一次setState()(首次渲染)时会有两个阶段:

  • 调度阶段(Reconciler:这个阶段React用新数据生成新的Virtual DOM,遍历Virtual DOM,然后通过Diff算法,快速找出需要更新的元素,放到更新队列中去。
  • 渲染阶段(Renderer:这个阶段React根据所在的渲染环境,遍历更新队列,将对应元素更新。在浏览器中,就是更新对应的DOM元素。

表面上看,这种设计也是挺合理的,因为更新过程不会有任何I/O操作,完全是CPU计算,所以无需异步操作,执行到结束即可。

这个策略像函数调用栈一样,会深度优先遍历所有的Virtual DOM节点,进行Diff 。它一定要等整棵Virtual DOM计算完成之后,才将任务出栈释放主线程。对于复杂组件,需要大量的diff计算,会严重影响到页面的交互性。

举个例子:

假设更新一个组件需要1ms,如果有200个组件要更新,那就需要200ms,在这200ms的更新过程中,浏览器唯一的主线程都在专心运行更新操作,无暇去做任何其他的事情。想象一下,在这200ms内,用户往一个input元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被React占用,抽不出空,最后的结果就是用户敲了按键看不到反应,等React更新过程结束之后,那些按键会一下出现在input元素里,这就是所谓的界面卡顿。

React Fiber,就是为了解决渲染复杂组件时严重影响用户和浏览器交互的问题。

Fiber产生的原因?

为了解决这个问题,react推出了Fiber,它能够将渲染工作分割成块并将其分散到多个帧中。同时加入了在新更新进入时暂停,中止或重复工作的能力和为不同类型的更新分配优先级的能力。

至于上面提到的为什么会影响到用户体验,这里需要简单介绍一下浏览器的工作模式:

因为浏览器的页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到60时,页面是流畅的,小于这个值时,用户会感觉到卡顿,转换成时间就是16ms内如果当前帧内执行的任务没有完成,就会造成卡顿。

一帧中执行的工作主要以下图所示的任务执行顺序单线程依次执行。

如果其中一项任务执行的过久,导致总时长超过了16ms,用户就会感觉到卡顿了

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

img

Fiber的设计思路

React为了解决这个问题,根据浏览器的每一帧执行的特性,构思出了Fiber来将一次任务拆解成单元,以划分时间片的方式,按照Fiber的自己的调度方法,根据任务单元优先级,分批处理或吊起任务,将一次更新分散在多次时间片中,另外,在浏览器空闲的时候,也可以继续去执行未完成的任务,充分利用浏览器每一帧的工作特性。

它的实现的调用栈示意图如下所示,一次更新任务是分时间片执行的,直至完成某次更新。

这样React更新任务就只能在规定时间内占用浏览器线程了,如果说在这个时候用户有和浏览器的页面交互,浏览器也是可以及时获取到交互内容。

img

Fiber具体都做了什么?

Reactrender第一次渲染时,会通过React.createElement创建一颗Element树,可以称之为Virtual DOM Tree.同时也会基于Virtual DOM Tree构建一个“结构相同” Fiber Tree。

Virtual DOM Tree虚拟DOM树 虚拟DOM树的存在就是为了解决js直接操作真实DOM而引起的计算机计算能力的浪费。 因为通过js直接修改DOM ,会引起整颗DOM树计算和改变,而虚拟DOM树的存在可以让真实DOM只改变必要改变的部分。

1、Fiber的调度单元: Fiber Node

Fiber Node,是Fiber Tree的基本构成单元,也可以类比成Virtual DOM Tree的一个节点(实际比它的节点多了很多上下文信息),也是Fiber中的一个工作单元。一个Fiber Node包含了如下内容

{
  ...
  // 跟当前Fiber相关本地状态(比如浏览器环境就是DOM节点)
  stateNode: any,

    // 单链表树结构
  return: Fiber | null,// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
  child: Fiber | null,// 指向自己的第一个子节点
  sibling: Fiber | null,  // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点

  // 更新相关
  pendingProps: any,  // 新的变动带来的新的props
  memoizedProps: any,  // 上一次渲染完成之后的props
  updateQueue: UpdateQueue<any> | null,  // 该Fiber对应的组件产生的Update会存放在这个队列里面
  memoizedState: any, // 上一次渲染的时候的state

  // Scheduler 相关
  expirationTime: ExpirationTime,  // 代表任务在未来的哪个时间点应该被完成,不包括他的子树产生的任务
  // 快速确定子树中是否有不在等待的变化
  childExpirationTime: ExpirationTime,

 // 在Fiber树更新的过程中,每个Fiber都会有一个跟其对应的Fiber
  // 我们称他为`current <==> workInProgress`
  // 在渲染完成之后他们会交换位置
  alternate: Fiber | null,

  // Effect 相关的
  effectTag: SideEffectTag, // 用来记录Side Effect
  nextEffect: Fiber | null, // 单链表用来快速查找下一个side effect
  firstEffect: Fiber | null,  // 子树中第一个side effect
  lastEffect: Fiber | null, // 子树中最后一个side effect
  ....
};

其中有几个属性需要重点关注:return(父节点)child(子节点)sibling(兄弟节点)、stateNode(对应的DOM节点expirationTime (到期时间)Effect (变更)

  • return:向上链接整颗树
  • child:向下链接整棵树
  • sibling:横向链接整颗树
  • stateNode:与DOM树相连
  • expirationTime:计算节点更新的优先级
  • Effect**:**记录节点的变更

通过节点上的child(孩子、return(父)和sibling (兄弟)属性串联着其他节点,形成了一棵Fiber Tree (类似Virtual DOM tree)

Fiber Tree是由Fiber Node构成的,更像是一个单链表构成的树,便于向上/向下/向兄弟节点转换

img

简单总结一下:

组件是React应用中的基础单元,应用以组件树形式组织,渲染组件;

Fiber调和的基础单元则是fiber(调和单元,应用与Fiber Tree形式组织,应用Fiber算法;

组件树和fiber树结构对应,一个组件实例有一个对应的fiber实例;

Fiber负责整个应用层面的调和,fiber实例负责对应组件的调和;

2、规定调度顺序:expirationTime到期时间

每个Fiber Node都会有一个ExpirationTime到期时间来确定当前时间片下是否执行该节点的更新任务。

它是以任务什么时候该执行完为描述信息的,到期时间越短,则代表优先级越高。

React中,为防止某个update因为优先级的原因一直被打断而未能执行。React会设置一个ExpirationTime,当时间到了ExpirationTime的时候,如果某个update还未执行的话,React将会强制执行该update,这就是ExpirationTime的作用。

每一次update之前,Fiber都会根据当下的时间(通过requestCurrentTime获取到)和 更新的触发条件为每个入更新队列的Fiber Node计算当下的到期时间。

到期时间的计算有两种方式,一种是对交互引起的更新做计算computeInteractiveExpiration ,另一种对普通更新做计算computeAsyncExpiration

function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) {
  let expirationTime;
    // ......
    if (fiber.mode & ConcurrentMode) {
      if (isBatchingInteractiveUpdates) {
        // 交互引起的更新
        expirationTime = computeInteractiveExpiration(currentTime);
      } else {
        // 普通异步更新
        expirationTime = computeAsyncExpiration(currentTime);
      }
    }
    // ......
  }
  // ......
  return expirationTime;
}

computeInteractiveExpiration

export const HIGH_PRIORITY_EXPIRATION = __DEV__ ? 500 : 150;
export const HIGH_PRIORITY_BATCH_SIZE = 100;

export function computeInteractiveExpiration(currentTime: ExpirationTime) {
  return computeExpirationBucket(
    currentTime,
    HIGH_PRIORITY_EXPIRATION, //150
    HIGH_PRIORITY_BATCH_SIZE //100
  );
}

computeAsyncExpiration

export const LOW_PRIORITY_EXPIRATION = 5000;
export const LOW_PRIORITY_BATCH_SIZE = 250;

export function computeAsyncExpiration(
  currentTime: ExpirationTime
): ExpirationTime {
  return computeExpirationBucket(
    currentTime,
    LOW_PRIORITY_EXPIRATION, //5000
    LOW_PRIORITY_BATCH_SIZE //250
  );
}

查看上面两种方法,我们发现其实他们调用的是同一个方法:computeExpirationBucket,只是传入的参数不一样,而且传入的是常量。computeInteractiveExpiration传入的是150、100,computeAsyncExpiration传入的是5000、250。说明前者的优先级更高。那么我把前者称为高优先级更新(交互引起,后者称为低优先级更新(其他更新

computeExpirationBucket

const UNIT_SIZE = 10;
const MAGIC_NUMBER_OFFSET = 2;

function ceiling(num: number, precision: number): number {
  return (((num / precision) | 0) + 1) * precision;
}

function computeExpirationBucket(
  currentTime,
  expirationInMs,
  bucketSizeMs
): ExpirationTime {
  return (
    MAGIC_NUMBER_OFFSET +
    ceiling(
      currentTime - MAGIC_NUMBER_OFFSET + expirationInMs / UNIT_SIZE,
      bucketSizeMs / UNIT_SIZE
    )
  );
}

最终的公式是:((((currentTime - 2 + 5000 / 10) / 25) | 0) + 1) * 25

其中只有只有currentTime是变量, currentTime是通过浏览提供的API(requestCurrentTime)获取的当前时间。

简单来说,以低优先级来说,最终结果是以25为单位向上增加的,比如说我们输入102 - 126之间,最终得到的结果都是625,但是到了127得到的结果就是650了,这就是除以25取整的效果。 即,低优先级更新的expirationTime间隔是25ms,抹平了25ms内计算过期时间的误差,React让两个相近(25ms内)的得到update相同的expirationTime ,目的就是让这两个update自动合并成一个Update ,从而达到批量更新。

高优先级是10ms的误差.

也就是说expirationTime的计算是将一个时间段内的所有任务都统一成一个expirationTime ,并且允许一定误差的存在。

随着时间的流逝,一个更新的优先级会越来越高,这样就可以避免starvation问题(即低优先级的工作一直被高优先级的工作打断,而无法完成

另外,之前存在过一个PriorityLevel的优先级评估变量,但在16.x中使用的是expirationTime来评估,但为了兼容仍然会考虑PriorityLevel来计算expirationTime

3、workInProgress Tree : 保存更新进度快照

workInProgress Tree保存当先更新中的进度快照,用于下一个时间片的断点恢复,Fiber Tree的构成几乎一样,在一次更新的开始时跟Fiber Tree是一样的.

4、Fiber TreeWorkInProgress tree的关系

在首次渲染的过程中,React通过react-dom中提供的方法创建组件和与组件相应的Fiber (Tree) ,此后就不会再生成新树,运行时永远维护这一棵树,调度和更新的计算完成后Fiber Tree会根据effect去实现更新。

workInProgress Tree在每一次刷新工作栈( prepareFreshStack )时候都会重新根据当前的fiber tree构建一次。

这两棵树构成了双缓冲树,fiber tree为主,workInProgress tree为辅。

双缓冲具体指的是workInProgress tree构造完毕,得到的就是新的fiber tree ,每个fiber上都有个alternate属性,也指向一个fiber ,创建workInProgress节点时优先取alternate ,没有的话就创建一个。

fiberworkInProgress互相持有引用,把current指针指向workInProgress tree ,丢掉旧的fiber tree 。旧fiber就作为新fiber更新的预留空间,达到复用fiber实例的目的。

img

一次更新的操作都是在workInProgress Tree上完成的,当更新完成后再用workInProgress Tree替换掉原有的Fiber Tree

这样做的好处:

  1. 能够复用内部对象(fiber)
  2. 节省内存分配、GC的时间开销
  3. 就算运行中有错误,也不会影响View上的数据

5、更新

怎么触发的更新

  • this.setState();
  • props的改变(因为props改变也是由父组件的setState引起的, 其实也是第一种);
  • this.forceUpdate();

触发更新后Fiber做了什么

首先,当前是哪个组件触发的更新, React是知道的( this指向),于是React会针对当前组件计算其相应的到期时间(上面提到了计算方法),并且基于这个到期时间,创建一个更新update ,将引起改变的payload (比如说state/props ),作为此次更新的一个属性,并插入当前组件对应的Fiber Node的更新队列(它是一个单向链表数据结构。只要有setState或者其他方式触发了更新,就会在fiber上的updateQueue里插入一个update,这样在更新的时候就可以合并一起更新)中,之后开始调度任务。

整个调度的过程是计算并重新构建workInProgress Tree的过程,在workInProgress Tree和原有Fiber Tree对比的时候记录下Diff,标记对应的Effect, 完成之后会生成一个Effect List,这个Effect List就是最终Commit阶段用来处理副作用的阶段, 如果在这个过程中有了交互事件等高优先级的任务进来,那么fiber会终止当前任务, 执行更紧急的任务, 但为了避免 “饥饿现象”, 上一个吊起的任务的优先级会被相应的提升。

let workInProgress = current.alternate;
if (workInProgress === null) {
  //...这里很有意思
  workInProgress.alternate = current;
  current.alternate = workInProgress;
} else {
  // We already have an alternate.
  // Reset the effect tag.
  workInProgress.effectTag = NoEffect;

  // The effect list is no longer valid.
  workInProgress.nextEffect = null;
  workInProgress.firstEffect = null;
  workInProgress.lastEffect = null;
}

6、effect

每一个Fiber Node都有与之相关的effecteffect是用于记录由于stateprops改变引起的工作类型, 对于不同类型的Fiber Node有不同的改变类型,比如对DOM元素,工作包括添加,更新或删除元素。对于class组件,React可能需要更新ref并调用componentDidMountcomponentDidUpdate生命周期方法。

每个Fiber Node都有个nextEffect用来快速查找下一个改变effect,他使得更新的修改能够快速遍历整颗树,跳过没有更改的Fiber Node

例如,我们的更新导致c2被插入到DOM中,d2c1被用于更改属性,而b2被用于触发生命周期方法。副作用列表会将它们链接在一起,以便React稍后可以跳过其他节点。

img

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

img

7、获取浏览器的控制权 — requestIdleCallbackrequestAnimationFrame

构建出Effect List就已经完成了一次更新的前半部分工作调和,在这个过程中,React通过浏览器提供的Api来开始于暂停其中的调和任务。

requestIdleCallback(callback)这是浏览器提供的API ,他在window对象上,作为参数写给这个函数的回调函数,将会在浏览器空闲的时候执行。回调函数会有一个deadline参数,deadline.timeRemaining()会告诉外界,当前时间片还有多少时间。利用这个API ,结合Fiber拆分好的工作单元,在合适的时机来安排工作。

img

不过这个API只负责低优先的级的任务处理,而高优先级的(比如动画相关)则通过requestAnimationFrame来控制 。

如果浏览器支持这两个API就直接使用,如果不支持就要重新定义了,如果没有自行定义的https://juejin.im/post/5a2276d5518825619a027f57

8、调度器(Scheduler)

  1. 调和器主要作用就是在组件状态变更时,调用组件树各组件的render方法,渲染,卸载组件,而Fiber使得应用可以更好的协调不同任务的执行,调和器内关于高效协调的实现,我们可以称它为调度器(SchedulerFiber中的调度器主要的关注点是:

    1. 合并多次更新:没有必要在组件的每一个状态变更时都立即触发更新任务,有些中间状态变更其实是对更新任务所耗费资源的浪费,就比如用户发现错误点击时快速操作导致组件某状态从AB再至C,这中间的B状态变更其实对于用户而言并没有意义,那么我们可以直接合并状态变更,直接从AC只触发一次更新;
    2. 任务优先级:不同类型的更新有不同优先级,例如用户操作引起的交互动画可能需要有更好的体验,其优先级应该比完成数据更新高;
    3. 推拉式调度:基于推送的调度方式更多的需要开发者编码间接决定如何调度任务,而拉取式调度更方便React框架层直接进行全局自主调度;

调度的实现逻辑主要是

    1. 通过fiber.return属性,从当前fiber实例层层遍历至组件树根组件;
    2. 依次对每一个fiber实例进行到期时间判断,若大于传入的期望任务到期时间参数,则将其更新为传入的任务到期时间;
    3. 调用requestWork方法开始处理任务,并传入获取的组件树根组件FiberRoot对象和任务到期时间;

Fiber执行流程

img

Fiber总的来说可以分成两个部分,一个是调和过程(可中断,一个是提交过程(不可中断

在调和过程中以fiber tree为基础,把每个fiber作为一个工作单元,自顶向下逐节点构造workInProgress tree(构建中的新fiber tree

具体过程如下:

img

通过每个节点更新结束时向上归并effect list来收集任务结果,reconciliation结束后,根节点的effect list里记录了包括DOM change在内的所有side effect

所以,构建workInProgress tree的过程就是diff的过程,通过requestIdleCallback来调度执行一组任务,每完成一个任务后回来看看有没有插队的(更紧急的,每完成一组任务,把时间控制权交还给主线程,直到下一次requestIdleCallback回调再继续构建workInProgress tree

而提交过程阶段是一口气直接做完(同步执行,不被控制和中止,这个阶段的实际工作量是比较大的,所以尽量不要在后3个生命周期函数里干重活儿

  1. 处理effect list(包括3种处理:更新DOM树、调用组件生命周期函数以及更新ref等内部状态)
  2. 该阶段结束时,所有更新都commitDOM树上了。

DEMO对比:

Fiber为什么是React性能的一个飞跃?

什么是Fiber

Fiber的英文含义是“纤维”,它是比线程(Thread)更细的线,比线程(Thread)控制得更精密的执行模型。在广义计算机科学概念中,Fiber又是一种协作的(Cooperative)编程模型(协程,帮助开发者用一种【既模块化又协作化】的方式来编排代码。

React中,Fiber就是React 16实现的一套新的更新机制,让React的更新过程变得可控,避免了之前采用递归需要一气呵成影响性能的做法

React Fiber中的时间分片

把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。

React Fiber把更新过程碎片化,每执行完一段更新过程,就把控制权交还给React负责任务协调的模块,看看有没有其他紧急任务要做,如果没有就继续去更新,如果有紧急任务,那就去做紧急任务。

Stack Reconciler

基于栈的Reconciler,浏览器引擎会从执行栈的顶端开始执行,执行完毕就弹出当前执行上下文,开始执行下一个函数,直到执行栈被清空才会停止。然后将执行权交还给浏览器。由于React将页面视图视作一个个函数执行的结果。每一个页面往往由多个视图组成,这就意味着多个函数的调用。

如果一个页面足够复杂,形成的函数调用栈就会很深。每一次更新,执行栈需要一次性执行完成,中途不能干其他的事儿,只能"一心一意"。结合前面提到的浏览器刷新率,JS一直执行,浏览器得不到控制权,就不能及时开始下一帧的绘制。如果这个时间超过16ms,当页面有动画效果需求时,动画因为浏览器不能及时绘制下一帧,这时动画就会出现卡顿。不仅如此,因为事件响应代码是在每一帧开始的时候执行,如果不能及时绘制下一帧,事件响应也会延迟。

Fiber Reconciler

链表结构

React Fiber用链表遍历的方式替代了React 16之前的栈递归方案。在React 16中使用了大量的链表。

  • 使用多向链表的形式替代了原来的树结构;
<div id="A">
  A1
  <div id="B1">
    B1
    <div id="C1"></div>
  </div>
  <div id="B2">B2</div>
</div>

img

  • 副作用单链表;

img

  • 状态更新单链表;

img

链表是一种简单高效的数据结构,它在当前节点中保存着指向下一个节点的指针;遍历的时候,通过操作指针找到下一个元素。

img

链表相比顺序结构数据格式的好处就是:

  1. 操作更高效,比如顺序调整、删除,只需要改变节点的指针指向就好了。
  2. 不仅可以根据当前节点找到下一个节点,在多向链表中,还可以找到他的父节点或者兄弟节点。

但链表也不是完美的,缺点就是:

  1. 比顺序结构数据更占用空间,因为每个节点对象还保存有指向下一个对象的指针。
  2. 不能自由读取,必须找到他的上一个节点。

React空间换时间,更高效的操作可以方便根据优先级进行操作。同时可以根据当前节点找到其他节点,在下面提到的挂起和恢复过程中起到了关键作用

斐波那契数列的Fiber

递归形式的斐波那契数列写法:

function fib(n) {
  if (n <= 2) {
    return 1;
  } else {
    return fib(n - 1) + fib(n - 2);
  }
}

采用Fiber的思路将其改写为循环(这个例子并不能和React Fiber的对等

function fib(n) {
  let fiber = { arg: n, returnAddr: null, a: 0 },
    consoled = false;

  // 标记循环
  rec: while (true) {
    // 当展开完全后,开始计算
    if (fiber.arg <= 2) {
      let sum = 1;
      // 寻找父级
      while (fiber.returnAddr) {
        if (!consoled) {
          // 在这里打印查看形成的链表形式的 fiber 对象
          consoled = true;
          console.log(fiber);
        }
        fiber = fiber.returnAddr;
        if (fiber.a === 0) {
          fiber.a = sum;
          fiber = { arg: fiber.arg - 2, returnAddr: fiber, a: 0 };
          continue rec;
        }
        sum += fiber.a;
      }
      return sum;
    } else {
      // 先展开
      fiber = { arg: fiber.arg - 1, returnAddr: fiber, a: 0 };
    }
  }
}

React Fiber是如何实现更新过程可控?

更新过程的可控主要体现在下面几个方面:

  • 任务拆分
  • 任务挂起、恢复、终止
  • 任务具备优先级

任务拆分

React Fiber机制中,它采用"化整为零“的思想,将调和阶段(Reconciler)递归遍历VDOM这个大任务分成若干小任务,每个任务只负责一个节点的处理。

任务挂起、恢复、终止

workInProgress tree

workInProgress代表当前正在执行更新的Fiber。在render或者setState后,会构建一颗Fiber树,也就是workInProgress tree,这棵树在构建每一个节点的时候会收集当前节点的副作用,整棵树构建完成后,会形成一条完整的副作用链

currentFiber tree

currentFiber表示上次渲染构建的Filber在每一次更新完成后workInProgress会赋值给currentFiber。在新一轮更新时workInProgress tree再重新构建,新workInProgress的节点通过alternate属性和currentFiber的节点建立联系。

在新workInProgress tree的创建过程中,会同currentFiber的对应节点进行Diff比较,收集副作用。同时也会复用currentFiber对应的节点对象,减少新创建对象带来的开销。也就是说无论是创建还是更新、挂起、恢复以及终止操作都是发生在workInProgress tree创建过程中的workInProgress tree构建过程其实就是循环的执行任务和创建下一个任务。

挂起

当第一个小任务完成后,先判断这一帧是否还有空闲时间,没有就挂起下一个任务的执行,记住当前挂起的节点,让出控制权给浏览器执行更高优先级的任务。

恢复

在浏览器渲染完一帧后,判断当前帧是否有剩余时间,如果有就恢复执行之前挂起的任务。如果没有任务需要处理,代表调和阶段完成,可以开始进入渲染阶段。

如何判断一帧是否有空闲时间的呢?

使用前面提到的RIC (RequestIdleCallback)浏览器原生APIReact源码中为了兼容低版本的浏览器,对该方法进行了Polyfill

恢复执行的时候又是如何知道下一个任务是什么呢?

是在前面提到的链表。在React Fiber中每个任务其实就是在处理一个FiberNode对象,然后又生成下一个任务需要处理的FiberNode

终止

其实并不是每次更新都会走到提交阶段。当在调和过程中触发了新的更新,在执行下一个任务的时候,判断是否有优先级更高的执行任务,如果有就终止原来将要执行的任务,开始新的workInProgressFiber树构建过程,开始新的更新流程。这样可以避免重复更新操作。这也是React 16以后生命周期函数componentWillMount有可能会执行多次的原因。

img

任务具备优先级

React Fiber除了通过挂起,恢复和终止来控制更新外,还给每个任务分配了优先级。具体点就是在创建或者更新FiberNode的时候,通过算法给每个任务分配一个到期时间(expirationTime。在每个任务执行的时候除了判断剩余时间,如果当前处理节点已经过期,那么无论现在是否有空闲时间都必须执行该任务。过期时间的大小还代表着任务的优先级

任务在执行过程中顺便收集了每个FiberNode的副作用,将有副作用的节点通过firstEffect、lastEffect、nextEffect形成一条副作用单链表 A1(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A

其实最终都是为了收集到这条副作用链表,有了它,在接下来的渲染阶段就通过遍历副作用链完成DOM更新。这里需要注意,更新真实DOM的这个动作是一气呵成的,不能中断,不然会造成视觉上的不连贯(commit

<div id="A1">
  A1
  <div id="B1">
    B1
    <div id="C1">C1</div>
    <div id="C2">C2</div>
  </div>
  <div id="B2">B2</div>
</div>

img

直观展示

正是基于以上这些过程,使用Fiber,我们就有了在社区经常看到的两张对比图

动图封面

动图封面

清晰展示及交互、源码可通过下面两个链接进入,查看网页源代码。

Fiber结构长什么样?

基于时间分片的增量更新需要更多的上下文信息,之前的vDOM tree显然难以满足,所以扩展出了fiber tree(即Fiber上下文的vDOM tree,更新过程就是根据输入数据以及现有的fiber tree构造出新的fiber tree(workInProgress tree

FiberNode上的属性有很多,根据笔者的理解,以下这么几个属性是值得关注的:return、child、sibling(主要负责fiber链表的链接;stateNode;effectTag;expirationTime;alternate;nextEffect。各属性介绍参看下面的class FiberNode

class FiberNode {
  constructor(tag, pendingProps, key, mode) {
    // 实例属性
    this.tag = tag; // 标记不同组件类型,如函数组件、类组件、文本、原生组件...
    this.key = key; // react 元素上的 key 就是 jsx 上写的那个 key ,也就是最终 ReactElement 上的
    this.elementType = null; // createElement的第一个参数,ReactElement 上的 type
    this.type = null; // 表示fiber的真实类型 ,elementType 基本一样,在使用了懒加载之类的功能时可能会不一样
    this.stateNode = null; // 实例对象,比如 class 组件 new 完后就挂载在这个属性上面,如果是RootFiber,那么它上面挂的是 FiberRoot,如果是原生节点就是 dom 对象
    // fiber
    this.return = null; // 父节点,指向上一个 fiber
    this.child = null; // 子节点,指向自身下面的第一个 fiber
    this.sibling = null; // 兄弟组件, 指向一个兄弟节点
    this.index = 0; //  一般如果没有兄弟节点的话是0 当某个父节点下的子节点是数组类型的时候会给每个子节点一个 index,index 和 key 要一起做 diff
    this.ref = null; // reactElement 上的 ref 属性
    this.pendingProps = pendingProps; // 新的 props
    this.memoizedProps = null; // 旧的 props
    this.updateQueue = null; // fiber 上的更新队列执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会形成一个链表结构,最后做批量更新
    this.memoizedState = null; // 对应  memoizedProps,上次渲染的 state,相当于当前的 state,理解成 prev 和 next 的关系
    this.mode = mode; // 表示当前组件下的子组件的渲染方式
    // effects
    this.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新(更新、删除等)
    this.nextEffect = null; // 指向下个需要更新的fiber
    this.firstEffect = null; // 指向所有子节点里,需要更新的 fiber 里的第一个
    this.lastEffect = null; // 指向所有子节点中需要更新的 fiber 的最后一个
    this.expirationTime = NoWork; // 过期时间,代表任务在未来的哪个时间点应该被完成
    this.childExpirationTime = NoWork; // child 过期时间
    this.alternate = null; // current 树和 workInprogress 树之间的相互引用
  }
}

img

图片来源:完全理解React Fiber

function performUnitWork(currentFiber) {
  //beginWork(currentFiber) //找到儿子,并通过链表的方式挂到currentFiber上,没有儿子就找后面那个兄弟
  //有儿子就返回儿子
  if (currentFiber.child) {
    return currentFiber.child;
  }
  //如果没有儿子,则找弟弟
  while (currentFiber) {
    //一直往上找
    //completeUnitWork(currentFiber);//将自己的副作用挂到父节点去
    if (currentFiber.sibling) {
      return currentFiber.sibling;
    }
    currentFiber = currentFiber.return;
  }
}

Concurrent Mode (并发模式)

Concurrent Mode指的就是React利用上面Fiber带来的新特性开启的新模式(mode)react17开始支持concurrent mode,这种模式的根本目的是为了让应用保持cpuio的快速响应,它是一组新功能,包括Fiber、Scheduler、Lane,可以根据用户硬件性能和网络状况调整应用的响应速度,核心就是为了实现异步可中断的更新concurrent mode也是未来react主要迭代的方向。

目前React实验版本允许用户选择三种mode

  1. Legacy Mode:就相当于目前稳定版的模式
  2. Blocking Mode:应该是以后会代替Legacy Mode而长期存在的模式
  3. Concurrent Mode:以后会变成default的模式

Concurrent Mode其实开启了一堆新特性,其中有两个最重要的特性可以用来解决我们开头提到的两个问题:

  1. SuspenseSuspenseReact提供的一种异步处理的机制,它不是一个具体的数据请求库。它是React提供的原生的组件异步调用原语。
  2. useTrasition:让页面实现 Pending -> Skeleton -> Complete 的更新路径,用户在切换页面时可以停留在当前页面,让页面保持响应。 相比展示一个无用的空白页面或者加载状态,这种用户体验更加友好。

其中Suspense可以用来解决请求阻塞的问题,UI卡顿的问题其实开启concurrent mode就已经解决的,但如何利用concurrent mode来实现更友好的交互还是需要对代码做一番改动的。

上一页