【React Fiber】Fiber源码调试,任务切片、时间切片带来实质性效果一目了然(图解)

2,176 阅读10分钟

前言:本文通过在源码里面直接log查看react内部是如何运行的,通过性能分析对比查看fiber带来的实质性效果。

调试源码版本:17.0.0

一、fiber作用,带来了哪些实质性效果(一定要知道)

因为浏览器渲染帧数(fps)是60帧每秒,即每帧是16ms,如果期间执行任务时间过久,会造成掉帧(卡顿)现象。

场景1:在执行连续动画时候,如果动画稍微比较复杂,在改变动画时候还有数据变换更新dom这些事情时候,如果数量较大就会出现明显的掉帧效果,来看下示例对比用fiber和没用fiber的区别

没有fiber ExampleFiber Example
nofiber.giffiber.gif

这个示例在通过连续设置css的transform属性时候,同时也去改变里面的数据对应显示到界面上,可以很清楚的看到没有fiber时候掉帧现象严重。使用了fiber变得非常丝滑。

题外话:这个连续动画是这个api实现的window.requestAnimationFrame(),它可以绘制每一帧连续的动画。api看这里

为什么会有这两种不同的效果?没有fiber会卡?通过性能分析图看下:

没有fiber情况下的性能图: image.png 圈红的地方是丢帧的阶段,每次变换大小重新渲染过程大概丢了9帧画面,为什么?因为它执行渲染更新的任务时常太久,阻塞了ui的渲染,线程一直被占用着。看下执行渲染的任务细节:

image.png

一个任务大概需要417毫秒,执行这个任务过程中浏览器没有喘息的机会,这个非常耗时。

场景2:例如用户操作dom点击事件改变了数据(我们都知道react是用数据驱动视图),但是还有其他任务在执行,这时候如果那个任务过久,就会让用户感觉到点击会慢卡的感觉。

为了更好的提高性能,解决以上的示例问题,react开发出了fiber,进而通过协调器做任务调度

有了这些铺垫之后就比好深入其境理解了。

二、fiber结构

(下面所有贴的代码都只是关键部分的示例代码)

fiber结构是虚拟dom的进阶版,他的数据结构比虚拟dom多了更多的属性。fiber初始化的数据结构:

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // 虚拟dom的基本信息
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null; 

  // Fiber
  this.return = null; // 前一个节点信息
  this.child = null;  // 下一个节点信息
  this.sibling = null;  // 当前节点的兄弟节点信息
  this.index = 0;
  this.ref = null;
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;
  this.mode = mode;

  // Effects
  this.flags = NoFlags;
  this.nextEffect = null;
  this.firstEffect = null;
  this.lastEffect = null;
  this.lanes = NoLanes;  // 任务调度优先级
  this.childLanes = NoLanes;  // 任务调度优先级
  this.alternate = null;  // 保存对应节点工作中的副本,用来中断后继续处理
}

三、开始工作,先生成fiber链表

生成fiber的主要作用是用于支持做任务调度,哪个任务优先级高的先处理

从渲染开始,fiber就会把所有的react代码内容生成一个fiber链表的数据结构(便于理解可以认为是一个对象)

例如你的代码:

import * as React from 'react';

export default function TestApp() {
  return <div>
    <p>文字1</p>
    <button>按钮1</button>
  </div>
}

会被调用createFiber方法返回一个fiber:

const createFiber = function (
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
): Fiber {
  return new FiberNode(tag, pendingProps, key, mode);
};

function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode,
    );
    console.log('workInProgress',workInProgress)
    // ...
}

生成这么一个fiber节点:

image.png

它们的链表图示关系如下:

image.png

四、任务切片

根据生成fiber节点后会开始进行任务切片,就是我们所说的分割为许多一小个工作单元

// ReactFiberWorkLoop.old.js文件
function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}
// 执行工作 => unitOfWork 译为工作单元
function performUnitOfWork(unitOfWork: Fiber): void {
  // 打印看下每个工作单元的类型
  console.log('--ReactFiberWorkLoop--',unitOfWork.type); 
  // ...
}

workLoopSync方法默认被执行,进入while循环把链表拆分成如下的任务单元:

image.png

相当于每一个节点(可以理解为jsx的dom节点)被划分成了一个个任务单元(一个dom属性、交互事件都形成一个单元)。

image.png 有的dom需要更新,更新的过程就是一整个任务。就是任务切片

注意这个方法是同步的,执行任务过程它不会被中断,会等一个任务全部执行完成(不管这个任务要花费多长时间)才会继续执行下一个任务。性能分析图可以直观的看到它的每一个任务时长。

五、不可中断的任务切片

我们把代码稍微改一下,500ms后自动更新数据,渲染10000个节点数据更新后的节点:

import * as React from 'react';

export default function TestApp() {

  const [data, setData] = React.useState(1);

  React.useEffect(() => {
    setTimeout(() => {
      setData(data + 1)
    }, 500)
  }, []);

  return <div>
    {new Array(10000).fill('1').map(item => {
      return <span>{data}</span>
    })}
    <button>按钮1</button>
  </div>
}

通过性能分析查看react执行更新任务时处理10000个dom大概需要186ms才能处理完

image.png

这是一个非常耗时的过程,因为每一帧画面是16ms,慢了10倍。这时候假设用户也刚好在这个500ms时候做了操作,就会导致用户的操作会在186ms之后才开始去执行,造成掉帧卡顿的现象!

我们就来模拟下这种任务情况的下用户操作:

import * as React from 'react';

export default function TestApp() {

  const [data, setData] = React.useState(1);
  const btnRef = React.useRef();

  React.useEffect(() => {
    // 监听点击事件
    btnRef.current.addEventListener('click', () => {
      setData(3)
    });

    // 500ms时改变数据
    setTimeout(() => {
      console.log('定时器触发后可以立即看到这段话');
      setData(2)
    }, 500);

    // 500ms时模拟用户点击
    setTimeout(() => {
      btnRef.current.click();
    }, 500)
  }, []);

  return <div>
    {new Array(data === 2 ? 20000 : 100).fill('1').map((item, index) => {
      return <span key={index}>{data}</span>
    })}
    <button ref={btnRef}>按钮</button>
  </div>
}

500ms时候将data改为2,当数据为2时候会渲染两万个节点(用这样模拟react处理复杂的任务过程),并且同时模拟用户点击,将data改为3,只渲染100个节点。来看下效果(注意看卡顿效果考试要考):

动画1.gif

在定时器同时触发了,初始化后的500ms时候log是立即打印出来了,但是最终dom更新却延迟了。就是说用户点击行为会有延迟。通过性能分析我们可以看到具体的延迟时间以及延迟任务

image.png 两个定时器都按代码先后顺序同时触发了,但是第一个定时器中的react更新任务用了3.14秒,这意味这用户的交互操作会等待3.14秒后才有反应,这种卡顿是非常致命的,并且越大数目节点约明显。有小伙伴会想,那为什么我平常开发看不到这种卡顿,原因两个:1、你的项目不过“大”,正常不会有两万个节点;2、用户交互行为正好是和其他任务一起才会这样,就是说刚好“掐点”。

但是对于react框架来说,这是一个致命性的框架性能问题,所以需要解决。

六、时间切片

只有在concurrent模式时候才会有时间切片,时间切片就是把上面的任务切片,按照每5ms执行一个任务切片(一个fiber结构的虚拟dom更新计算任务),即为时间切片。

📂packages => 📂scheduler => 📂forks =>下的SchedulerHostConfig.default.js文件代码片段

// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let yieldInterval = 5;

forceFrameRate = function(fps) {
  if (fps < 0 || fps > 125) {
    // Using console['error'] to evade Babel and ESLint
    console['error'](
      'forceFrameRate takes a positive int between 0 and 125, ' +
        'forcing frame rates higher than 125 fps is not supported',
    );
    return;
  }
  if (fps > 0) {
    yieldInterval = Math.floor(1000 / fps);
  } else {
    // reset the framerate
    yieldInterval = 5;
  }
};

image.png

yieldInterval表示react的时间切片间隔,默认情况下是5ms毫秒,通过上面代码可以看出调度器会定期生成时间为每5ms,渲染一帧画面是16ms,那么就是一帧画面调度3次。一般不会强制频率和帧数一致。

如图所示大概一个任务是5ms就是一个时间切片,为什么会5毫秒多,因为fiber也需要时间。 image.png

时间切片原理:通过浏览器的requestIdleCallback,这个函数将在浏览器空闲时期被调用

window.requestIdleCallback(callback, { timeout }) callback表示调用是运行的回调函数,函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。timeout表示函数执行超时时间,意思是如果timeout我写了5,执行超过5s,那么就会被其他事情插队先处理,下个循环在执行。

但是由于兼容性问题,react在内部自己实现了这个方法。

上面示例fiber Exemple的性能图(一帧): 它很均衡,每16ms渲染出画面(这里因为是示例中用了requestAnimationFrame强制任务和帧对齐所以是16ms) image.png 就从这16ms里说,执行两个任务,触发动画帧然后重绘(图中紫色的就是),接着执行react的更新任务完成后,浏览器空闲了,调用了requestIdleCallback方法,又重新开始触发了动画帧绘制方法,如此循环反复。

七、任务调度,使用Concurrent模式通过时间切片中断任务

任务调度主要为了如上的性能问题,它会按照任务优先级通过时间切片来中断和调度任务。到这里你就会明白为什么fiber会和任务调度关联。

任务调度主要有以下功能:

  • 暂停工作,稍后再回来
  • 如果不再需要,中止工作 (当前示例)
  • 重用之前完成的工作

保持刚刚的测试代码不变,不同的是要去入口文件把ReactDOM.render改为ReactDOM.createRoot来渲染

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';

// ReactDOM.render( <App/>,document.getElementById('root'));
ReactDOM.createRoot( document.getElementById('root') ).render( <App /> );

来再次看下改完后的效果:

动画3.gif

刷新页面在初次渲染11111完成后的500ms立马控制台出现了打印结果,同时将data从1改为3,这两个是完全同时的!如此丝滑!惊了吗?这背后就是任务中断的功劳,我们继续打开性能分析看下:

image.png

499.9ms后两个定时器同时触发,第一个时器是处理react内部任务,第二个处理用户点击事件,用户交互事件优先级更高,此时直接终止(其他情况会中断)了第一个任务,先处理用户交互,所以看起来非常丝滑,用户交互也不会被阻塞而造成卡顿。

中断任务: 每次通过shouldYield()方法来控制是否退出切片暂停循环切片任务。

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

注意:不同版本的fiber略微有些不同。且当前fiber也还在改进中,比如开启时间切片需要使用reactDom.createRoot方式。这一点让我很疑惑。

最后:在调试源码时候不理解为什么有的地方不运行或者是不明白其作用,读庞大的源码库就像过眼云烟,只有你真正知道实际使用场景就能知道为什么要这么做,而并非为了读源码而读源码,这实际上毫无意义。

理解能力有限。希望有错误可以帮忙指出

参考了以下几篇文章:

  1. 任务切片
  2. 任务优先级
  3. 源码调试
  4. 任务调度
  5. 深入了解react fiber

感谢以上文章的作者。