likes
comments
collection
share

react调度源码-切片原理

作者站长头像
站长
· 阅读数 54

前言

我们知道在React16之前,dom的渲染是一个单纯的栈结构,这样使得我们在执行完任务之前都不能进行其它的工作,这其中也包括浏览器的一些渲染工作,且随着我们项目结构的复杂,这个渲染过程也会变得更加漫长,这里就会导致一个关键帧丢失,页面卡顿。这也是fiber出现的原因,同时在react调度的帮助下,实现时间切片,能够中断或者继续任务的进行。下面结合react18.2的源码对其中的过程进行,详细的讲解。

基本原理

react调度源码-切片原理

我们的显示器大多是60HZ,即每经过16.66ms浏览器就会绘制一次页面,称为,页面都是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到60时,页面就是流畅的,小于这个值用户就会感到卡顿。而在每一帧中,JS脚本的执行和浏览器的布局和绘制不能同时执行,当我们JS脚本执行时间过长的时候就会导致没有时间执行绘制任务,就会造成浏览器的丢帧,造成页面卡顿。除去浏览器必要的渲染时间,剩余的时间被称为空闲时间,理想情况下我们在该阶段运行我们的JS脚本是不会造成浏览器的丢帧,在每一帧的空闲时间运行JS脚本,这个就是实现时间切片的基本原理

requestIdleCallback

浏览器为我们提供了一个方法requestIdleCallback,它可以在浏览器空闲的时间执行其中的函数。

这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

这么看来requestIdleCallback简直是完美的方案,但是React在实现时间切片的时候并没有考虑该方案,主要是该方法有如下的缺点

  • 兼容性不好,只在一些高版本的主流浏览器存在。

react调度源码-切片原理

  • FPS只有20,大概50ms能够刷新一次,这个并不能满足页面流畅度的要求。

也是上面的原因,React放弃这种方案,改为自己实现,下面介绍React实现原理。

MessageChannel

MessageChannel是一个构造函数,正如它的名字会返回这个通道的两个端点。

Channel Messaging API 的 MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。

使用方法

react调度源码-切片原理

    let messageChannel = new MessageChannel();
    var port1 = messageChannel.port1;
    var port2 = messageChannel.port2;
    //在port1里监听port2发过来的消息
    port1.onmessage = (event) => {
      console.log('port1接收到了来自port2的数据' + event.data);
    }
    //通过port2向port1发送消息
    port2.postMessage('发送给port1的数据');

react调度源码-切片原理

这两个端点是一个订阅-发布的结构,port1注册事件,监听动作,port2触发事件,派发动作。同时这个函数也是独立于布局绘制之外的函数,React在每帧申请了5ms的时候来运行JS脚本,实现时间切片。

React实现

结合源码解释一下React是如何实现的

// src/scheduler/src/forks/Scheduler.js
const channel = new MessageChannel();
var port2 = channel.port2;
var port1 = channel.port1;
// 注册事件
port1.onmessage = performWorkUntilDeadline;

//如果5ms内没有完成,React也会放弃控制权,把控制交还给浏览器
const frameInterval = 5;

// 触发事件
function schedulePerformWorkUntilDeadline() {
  port2.postMessage(null);
}

React使用MessageChannel创造一个channel实例,通过port1挂载事件,这个事件会一直执行任务,直到时间耗尽,也就是超过5ms,通过函数schedulePerformWorkUntilDeadline触发port2port1发送开始的消息,执行performWorkUntilDeadline,实现时间的切片。

其实MessageChannel依旧是存在兼容性问题,React在这里的处理也是使用setTimeout这个timer函数,

const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null;

function requestHostTimeout(
  callback: (currentTime: number) => void,
  ms: number,
) {
  // $FlowFixMe[not-a-function] nullable value
  taskTimeoutID = localSetTimeout(() => {
    callback(getCurrentTime());
  }, ms);
}

源码分析

上面也带大家了解了一下基础知识,下面就结合源码详细介绍每一步的实现,需要说明的是,这里为了解耦简化了其中一部分流程,以供大家理解原理。

时间控制

上文已经提到过,React会在每一帧中申请5ms的时间来运行我们任务,那React又是怎么控制这个时间的呢?

其实这里是用到了一个performance的一个api,它能够获取到当前页面中与性能相关的信息,其提供的now()方法可以提供一个高精度的,且不受系统影响的时间。

和 JavaScript 中其他可用的时间类函数(比如Date.now)不同的是,window.performance.now()返回的时间戳没有被限制在一毫秒的精确度内,相反,它们以浮点数的形式表示时间,精度最高可达微秒级。

另外一个不同点是,window.performance.now()是以一个恒定的速率慢慢增加的,它不会受到系统时间的影响(系统时钟可能会被手动调整或被 NTP 等软件篡改)。另外,performance.timing.navigationStart + performance.now() 约等于 Date.now()。

// scheduler/src/forks/Scheduler.js
// 如果5ms内没有完成,React也会放弃控制权,把控制交给浏览器
const frameInterval = 5;
// 当前任务开始的时间 默认是-1,之后会作为属性加到任务中去
let startTime = -1;

// 获取当前时间
function getCurrentTime() {
  return performance.now();
}

// 判断是否还有剩余时间
function shouldYieldToHost() {
  // 用当前时间减去开始的时间就是过去的时间
  const timeElapsed = getCurrentTime() - startTime;
  // 如果流逝或者说经过的时间小于5毫秒,那就不需要放弃执行
  if (timeElapsed < frameInterval) {
    return false;
  }

  // 否则就是表示5毫秒用完了,需要放弃执行
  return true;
}

利用performance.now()产生的高精度时间,和任务的开始时间做减法,如果还有剩余时间就继续执行下一个任务,直到时间耗尽,这里我们也可以得到一个结论:

React时间切片也是有限度的,他不会严格执行5ms,一般会大于该时间;且React是以任务为维度的,即任务函数一旦运行,这个过程是不可以被打断的。例如:整个Fiber构建的过程是切片的,但是某一个节点子Fiber的构建是不可以被打断的。

开始调度

// scheduler/src/forks/Scheduler.js
// 任务ID计数器
let taskIdCounter = 1;
// 任务的最小堆
const taskQueue = [];
export function scheduleCallback(priorityLevel, callback) {
  // 获取当前时间
  const currentTime = getCurrentTime();
  // 此任务的开始时间
  const startTime = currentTime;
  // 超时时间
  let timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT; // -1
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT; // 250ms
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT; // 1073741823ms
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT; // 10000ms
      break;
    case NormalPriority:
      timeout = NORMAL_PRIORITY_TIMEOUT; // 5000ms
      break;
  }

  // 计算此任务的过期时间
  const expirationTime = startTime + timeout;
  const newTask = {
    id: taskIdCounter++,
    callback, // 回调函数或者说任务函数
    priorityLevel, // 任务级别
    startTime, // 任务开始时间
    expirationTime, // 任务的过期时间
    sortIndex: expirationTime, // 排序依赖
  };
  // 向任务最小堆添加任务,排序的依据是过期时间
  push(taskQueue, newTask);
  // flushWork执行工作,刷新工作,执行任务
  requestHostCallback(workLoop);
  return newTask;
}

这里引入了优先级的概念,其实就是React对于不同事件定义了不同的过期时间,然后在加入最小堆(如果不理解最小堆的概念可以看一下我的上一篇文章:传送门)的时候最为排序依据。当外界调用scheduleCallback向任务堆中添加新任务后执行requestHostCallback开始持续调度我们任务堆。

时间切片

// 0.构造通道
const channel = new MessageChannel();
var port2 = channel.port2;
var port1 = channel.port1;
// 1.开始调度
function requestHostCallback(workLoop) {
  // 先缓存回调函数
  scheduleHostCallback = workLoop;
  // 执行工作直到截止时间
  schedulePerformWorkUntilDeadline();
}
// 2.发送切片信号,开始本帧的任务
function schedulePerformWorkUntilDeadline() {
  port2.postMessage(null);
}
// 0.监听任务信号(这里应该是构造通道时监听,放在这里方便理解)
port1.onmessage = performWorkUntilDeadline;
// 4.工作到时间耗尽
function performWorkUntilDeadline() {
  if (scheduleHostCallback) {
    // 先获取开始执行任务的时间
    // 表示时间片的开始
    startTime = getCurrentTime();
    // 是否有更多的工作要做
    let hasMoreWork = true;
    try {
      // 执行 flushWork,并判断有没有返回值
      hasMoreWork = scheduleHostCallback(startTime);
    } finally {
      // 执行完以后如果为true,说明还有更多工作要做
      if (hasMoreWork) {
        // 继续执行
        schedulePerformWorkUntilDeadline();
      } else {
        scheduleHostCallback = null;
      }
    }
  }
}

这里我们可以看到一个时间切片内的任务执行过程:

  • 构造通道,监听任务信号;
  • 开始调度,执行requestHostCallback函数;
  • 发送切片信号,开始本帧的任务;
  • 工作到时间耗尽performWorkUntilDeadline

这里我们可以看到有一个hasMoreWork的字段来判断本帧内是否还会有下一个要被执行的任务,这个就是workLoop的一个工作循环,他会持续调度任务堆,下面看一下该函数如何工作。

持续调度

function workLoop(startTime) {
  let currentTime = startTime;
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    // 如果此任务的过期时间小于当前时间, 也就是说没有过期,并且需要放弃执行 时间片到期
    if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
      // 跳出工作循环
      break;
    }
    // 取出当前的任务中的回调函数 performConcurrentWorkOnRoot
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      // 执行工作,如果返回新的函数,则表示当前的工作没有完成
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      const continuationCallback = callback(didUserCallbackTimeout);
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
        return true; // 还有任务要执行
      }
      // 如果此任务已经完成,则不需要再继续执行了,可以把此任务弹出
      if (currentTask === peek(taskQueue)) {
        pop(taskQueue);
      }
    } else {
      pop(taskQueue);
    }
    // 如果当前的任务执行完了,或者当前任务不合法,取出下一个任务执行
    currentTask = peek(taskQueue);
  }
  // 如果循环结束还有未完成的任务,那就表示hasMoreWork=true
  if (currentTask !== null) {
    return true;
  }
  // 没有任何要完成的任务了
  return false;
}

这里的主体是一个while循环,在本帧时间内会不断从最小堆中取出优先级最高的任务,执行其中的函数。这里用到的shouldYieldToHost就是上面提到的用来判断是否还有剩余时间的函数。

总结

react调度源码-切片原理

整个的构建流程也是如上图所示,本文也是按照上面的流程进行的详细讲解。

  1. scheduleCallback开始调度,保存任务开始时间。
  2. requestHostCallback通过port2.postMessage(null)发送开始信号。
  3. performWorkUntilDeadline迭代任务,直到本帧时间结束或者没有任务。
转载自:https://juejin.cn/post/7231920813218119717
评论
请登录