React Fiber详解

问题是什么?

我们先看一个例子:
https://claudiopro.github.io/react-fiber-vs-stack-demo/stack.html

在上面这张图片中,页面出现一卡一卡的现象。

为什么人眼会感觉到卡顿?

当浏览器每秒刷新的次数低于60hz人眼就会感知卡顿掉帧等情况。

FPS

浏览器每秒刷新的次数称为 FPS(frame per second)。

浏览器的一帧说的就是一次完整的重绘。

理论上FPS越高人眼觉得界面越流畅,在两次屏幕硬件刷新之间,浏览器正好进行一次刷新(重绘),网页也会很流畅,当然这种是理想模式, 如果两次硬件刷新之间浏览器重绘多次是没意义的,只会消耗资源,如果浏览器重绘一次的时间是硬件多次刷新的时间,那么人眼将感知卡顿掉帧等, 所以浏览器对一次重绘的渲染工作需要在16ms(1000ms/60)之内完成,也就是说每一次重绘小于16ms才不会卡顿掉帧。

实际上,对用户来说,不良的体验不只是视觉上表现为卡顿与掉帧,因为在浏览器中,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。通常这时,对于用户在输入框输入内容这个行为来说,就体现为按下了键盘按键但是页面上不实时显示输入。
用户的交互得不到及时的响应,这对网页的用户体验来说是非常不利的。

React 15架构

Reconciler 协调器

每当有更新发生时,Reconciler会做如下工作:

  • 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
  • 将虚拟DOM和上次更新时的虚拟DOM对比
  • 通过对比找出本次更新中变化的虚拟DOM
  • 通知Renderer将变化的虚拟DOM渲染到页面上

Renderer(渲染器)

  • ReactDOM渲染器,浏览器环境渲染
  • ReactNative渲染器,渲染App原生组件
  • ReactTest渲染器,渲染出纯Js对象用于测试
  • ReactArt渲染器,渲染到Canvas, SVG 或 VML (IE8)

在每16.6ms时间内,需要完成如下工作:
JS脚本执行 ----- 样式布局 ----- 样式绘制

对于React的更新来说,递归遍历应用的所有节点由于递归执行,计算出差异,然后再更新 UI。递归是不能被打断的,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。

另一方面,递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,当层级很多的,可能会出现爆栈(stack overflow)的错误。当然这是递归的另一个缺点,但并不是React要优化的主要原因。

如何解决?

既然递归的不可打断的计算更新阻塞了用户的交互,有没有可能做到这样的效果:当浏览器有空闲时间就正常进行计算更新,而当有其他优先级更高的事件需要响应时就先暂停计算,去响应优先级高的事件,响应结束之后再接着刚才的计算继续进行,直到结束。

React 16的设计思想

React 16实现的思路是这样的:将运算切割为多个步骤,分批完成。说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。这就是React 16中的Fiber设计思想。

有了解题思路后,我们再来看看 React 16具体是怎么做的。

React 16的实现

为了加以区分,以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:
React Fiber详解_第1张图片

而 Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行:
React Fiber详解_第2张图片

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

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行
    优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。

Fiber Reconciler 在执行过程中,会分为 2 个阶段。
React Fiber详解_第3张图片

  1. 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  2. 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。
    阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

看一下React 16下的效果
https://claudiopro.github.io/react-fiber-vs-stack-demo/fiber.html

React Fiber详解_第4张图片

可以看到,动画变得顺滑很多。

接下来看下Fiber的具体细节。

Fiber的实现细节

Fiber Reconciler 在阶段一进行 Diff 计算的时候,会生成一棵 Fiber 树。这棵树是在 Virtual DOM 树的基础上增加额外的信息来生成的,它本质来说是一个链表。

Fiber的结构

每个Fiber节点有个对应的React element,多个Fiber节点是如何连接形成树呢?靠如下三个属性:

// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;

举个例子,如下的组件结构:

function App() {
  return (
    <div>
      i am
      <span>KaSong</span>
    </div>
  )
}

对应的Fiber树结构:
React Fiber详解_第5张图片

作为一种静态的数据结构,保存了组件相关的信息:

// Fiber对应组件的类型 Function/Class/Host...
this.tag = tag;
// key属性
this.key = key;
// 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.type = null;
// Fiber对应的真实DOM节点
this.stateNode = null;

作为动态的工作单元,Fiber中如下参数保存了本次更新相关的信息。

// 保存本次更新造成的状态改变相关信息
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// 保存本次更新会造成的DOM操作
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;

Fiber架构的工作原理

更新过程

Fiber 树在首次渲染的时候会一次过生成。在后续需要 Diff 的时候,会根据已有树和最新 Virtual DOM 的信息,生成一棵新的树。这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程。

如果过程中有优先级更高的任务需要进行,则 Fiber Reconciler 会丢弃正在生成的树,在空闲的时候再重新执行一遍。

在构造 Fiber 树的过程中,Fiber Reconciler 会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。

双缓存

在内存中构建并直接替换的技术叫做双缓存。
React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。

双缓存Fiber树

在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。

current Fiber树中的Fiber节点被称为current fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React应用的根节点通过current指针在不同Fiber树的rootFiber间切换来实现Fiber树的切换。

当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。

每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。

空闲时段

如何知道浏览器现在是空闲的?

window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。

哪些场景下浏览器是空闲

场景一
当浏览器一帧渲染所用时间小于屏幕刷新率(对于具有60Hz 的设备,一帧间隔应该小于16ms)时间,到下一帧渲染渲染开始时出现的空闲时间,如图idle period,
React Fiber详解_第6张图片

场景二
当浏览器没有可渲染的任务,主线程一直处于空闲状态,事件队列为空。为了避免在不可预测的任务(例如用户输入的处理)中引起用户可察觉的延迟,这些空闲周期的长度应限制为最大值50ms,也就是timeRemaining最大不超过50(也就是20fps,这也是react polyfill的原因之一),当空闲时段结束时,可以调度另一个空闲时段,如果它保持空闲,那么空闲时段将更长,后台任务可以在更长时间段内发生。如图:
React Fiber详解_第7张图片

注意:timeRemaining最大为50毫秒,是根据研究 [RESPONSETIME ] 得出的,该研究表明,对用户输入的100毫秒以内的响应通常被认为对人类是瞬时的,就是人类不会有察觉。将闲置截止期限设置为50ms意味着即使在闲置任务开始后立即发生用户输入,用户代理仍然有剩余的50ms可以在其中响应用户输入而不会产生用户可察觉的滞后。

requestIdleCallback 用法:

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

callback: 一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。
其中 IdleDeadline 对象包含:
didTimeout,布尔值,表示任务是否超时,结合 timeRemaining 使用。
timeRemaining(),表示当前帧剩余的时间,也可理解为留给任务的时间还有多少。
options的参数
timeout: 表示超过这个时间后,如果任务还没执行,则强制执行,不必等待空闲。尚未通过超时毫秒数调用回调,那么回调会在下一次空闲时期被强制执行。如果明确在某段时间内执行回调,可以设置timeout值。在浏览器繁忙的时候,requestIdleCallback超时执行就和setTimeout效果一样。

对于用户响应的行为,50ms的timeRemaining是能够做到使用户感觉瞬时的,但是对于人眼能够感知到的卡顿,需要FPS至少为60帧/秒,即每次最多16.67ms,这一点timeRemaining 无法做到,所以React在此之上实现了自己的pollyfill。

react如何实现requestIdleCallback

源码在packages/scheduler/src/forks/SchedulerHostConfig.default.js下,分别对非DOM和DOM环境有不同的实现。

export let requestHostCallback; // 类似requestIdleCallback
export let cancelHostCallback; // 类似cancelIdleCallback
export let requestHostTimeout; // 非dom环境的实现
export let cancelHostTimeout;  // 取消requestHostTimeout
export let shouldYieldToHost;  // 判断任务是否超时,需要被打断
export let requestPaint; // 
export let getCurrentTime; // 获取当前时间
export let forceFrameRate; // 根据fps计算帧时间
// 非dom环境
if (typeof window === 'undefined' || typeof MessageChannel !== 'function') {
    let _callback = null; // 正在执行的回调
  let _timeoutID = null;
  const _flushCallback = function() {
    // 如果回调存在则执行,
    if (_callback !== null) {
      try {
        const currentTime = getCurrentTime();
        const hasRemainingTime = true;
        // hasRemainingTime 类似deadline.didTimeout
        _callback(hasRemainingTime, currentTime);
        _callback = null;
      } catch (e) {
        setTimeout(_flushCallback, 0);
        throw e;
      }
    }
  };
  
  // ...
  
  requestHostCallback = function(cb) {
    // 若_callback存在,表示当下有任务再继续,
    if (_callback !== null) {
      // setTimeout的第三个参数可以延后执行任务。
      setTimeout(requestHostCallback, 0, cb);
    } else {
      // 否则直接执行。
      _callback = cb;
      setTimeout(_flushCallback, 0);
    }
  };
  cancelHostCallback = function() {
    _callback = null;
  };
  requestHostTimeout = function(cb, ms) {
    _timeoutID = setTimeout(cb, ms);
  };
  cancelHostTimeout = function() {
    clearTimeout(_timeoutID);
  };
  shouldYieldToHost = function() {
    return false;
  };
  requestPaint = forceFrameRate = function() {};
} else {
  // 一大堆的浏览器方法的判断,有performance, requestAnimationFrame, cancelAnimationFrame
  // ...
  const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // yieldInterval每帧的时间,deadline为最终期限时间
      deadline = currentTime + yieldInterval;
      const hasTimeRemaining = true;
      try {
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        if (!hasMoreWork) {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // 如果有更多的工作,就把下一个消息事件安排在前一个消息事件的最后
          port.postMessage(null);
        }
      } catch (error) {
        // 如果调度任务抛出,则退出当前浏览器任务,以便观察错误。
        port.postMessage(null);
        throw error;
      }
    } else {
      isMessageLoopRunning = false;
    }
    needsPaint = false;
  };
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  requestHostCallback = function(callback) {
    scheduledHostCallback = callback;
    if (!isMessageLoopRunning) {
        isMessageLoopRunning = true;
        port.postMessage(null);
    }
    };
  
}

总结:非DOM模式下requestHostCallback是setTimeout模拟实现的,而在DOM下是基于MessageChannel消息的发布订阅模式postMessage和onmessage实现的。

总结

  1. fiber采用链表结构实现,是一段一段执行的,它的执行是可打断的,每段执行时间控制在16.6ms以内,防止阻塞浏览器绘制或其他优先级更高的任务。
  2. fiber使用双缓存,当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。
  3. window.requestIdleCallback()这个方法将在浏览器的空闲时段内调用的函数排队,但该API提供的FPS不能满足60帧/秒的要求,所以React 16写了自己的pollyfill.。
  4. 从react源码中可以看到,非DOM模式下requestHostCallback是setTimeout模拟实现的,而在DOM下是基于MessageChannel消息的发布订阅模式postMessage和onmessage实现的。

参考链接

熟悉requestidlecallback到了解react ric polyfill实现
React理念

React Fiber原理介绍

你可能感兴趣的:(js,前端,javascript)