五千字长文,解密 React Scheduler 如何运行

1,688 阅读9分钟

背景

React 当中很关键的一步是关于,它的任务(异步任务)调度的逻辑,在面试过程中经常被问到,我们不妨从源码中深入理解,到底它是如何工作的,如何将异步任务,浏览器,空闲时间更新,联系在一起的。 我们本文章,只是关于 React 任务调度,不涉及其他相关 例如 Fiber 架构 setState 等其他概念。专注于介绍明白 React 是如何进行任务调度的。

概念

React Scheduler 是什么呢 React16.5 之后把scheduler单独发一个包了,叫scheduler。它是 React 中 可以理解成独立抽离出来的一个模块。下面用到的 代码版本是 16.6.0 版本。

各位可以在 官方自行下载参考。

下载地址

官方概念解释

image.png

React Scheduler 作用是用于在浏览器环境中进行协作调度的, 简单从概念翻译过来,就很好理解啦。

核心逻辑

看过不少科普React 源码的文章,很多人都知道 React 是通过模拟实现 requestIdleCallback ,在主要的线程时间里面,将控制权交给浏览器去更新动画或者用户的输入更新,然后在空闲的时间里面,去调度执行 React 本身需要渲染更新的任务的。

所以我们通过核心逻辑,先总结 4 点重要的概念。

  • 维护时间切片
  • Expiration Time 优先级
  • 模拟实现 requestIdleCallback
  • 调度列表和超时的判断 requestAnimationFrame

下面我们一点点开始解释

时间片概念

在正式开始本文之前,先科普时间片的概念,首先 1s 是 等于 1000ms (毫秒)下面 ms 单位将是毫秒为单位,然后帧数其实是不固定的,这些和显示设备之间存在关系,那么我用下面的例子作为举例吧。

  • 如果 1s 等于 120帧 那么 1帧 就等于 8ms
  • 如果 1s 等于 60帧 那么 1帧 就等于 16ms
  • 如果 1s 等于 30帧 那么 1帧 就等于 33ms

一般 30 帧 的 情况下,我们是不会觉得卡顿的,那么 一帧 就等于 一个时间切片 大约是 33ms

1 秒 30帧的情况

假如 1000 / 30 = 33ms 每个时间切片 每一帧的时间就是 33ms,如果其中 11ms 必须留给浏览器去渲染自己的动画,剩下22ms 是 React 去渲染,保证渲染流畅

假如 React 渲染花费的时间很长,超过了 33ms 是 35ms,因为浏览器是单线程的,那么时间占满了话,浏览器就没有时间去运行刷新自己动画,这样浏览器只能去占用下一帧的时间去刷新自己的动画了,这样就会出现卡顿。

image.png

ReactScheduler 就是保证每一帧 浏览器不超过特定的时限。我们需要知道它的作用到底是什么

Expiration Time 优先级的原理 &scheduleCallbackWithExpirationTime 方法逻辑

scheduleCallbackWithExpirationTime 名称可以知道,是调度 Callback 通过 ExpirationTime 目的自然是通过,ExpirationTime来调度任务了

ReactFiberScheduler 1840 行左右,可以观察到源码,接受 FiberRootExpirationTime 两个参数,它主要的作用是比较 ExpirationTime ,然后根据优先级,进行不同的操作。 关于 ExpirationTime 之前有文章已经介绍过 它的含义,可以下面的传送门跳转

ExpirationTime(一)
ExpirationTime(二)

function scheduleCallbackWithExpirationTime(
  root: FiberRoot,
  expirationTime: ExpirationTime,
) {
  if (callbackExpirationTime !== NoWork) {
    // 代表之前已经有一个 callback 在执行
    if (expirationTime > callbackExpirationTime) {
      // 判断当前的 expirationTime 是否比 之前上一个的大,证明它优先级比较低,所以 return 继续执行上一个 expirationTime
      return;
    } else {
      if (callbackID !== null) {
        // 如果当前的 expirationTime 比之前的 要小的情况下, 当前的任务优先级高,取消上一个执行的任务
        cancelDeferredCallback(callbackID);
      }
    }
  } else {
    startRequestCallbackTimer(); // 这里不重要 polyfill 相关
  }

  callbackExpirationTime = expirationTime;
  const currentMs = now() - originalStartTimeMs; // 获取到当前 代码加载进来 到目前执行到现在的时间差
  const expirationTimeMs = expirationTimeToMs(expirationTime); // 上一次更新的 expirationTime 转化为 ms 的值
  const timeout = expirationTimeMs - currentMs; // 将来的过期时间 - 执行花费的时间
  callbackID = scheduleDeferredCallback(performAsyncWork, {timeout});  // scheduleDeferredCallback 来自于 React 的 任务调度器 Scheduler
  // callbackID 在下一次进来方法的时候,就可以进行取消了
  // performAsyncWork 是同步任务执行的 更新方法
}

下面我们由 scheduleDeferredCallback 开始切入讲解 React Scheduler 任务调度,我们正式开始

整体完整的流程图

首先展示整体的流程图内容,建议在后面一边看的同时,一边对应流程图进行理解,不然很容易就会懵逼

image.png

scheduleDeferredCallback

// unstable_scheduleCallback 实际上就是 scheduleDeferredCallback 一致的
export {
  unstable_now as now,
  unstable_scheduleCallback as scheduleDeferredCallback,
  unstable_shouldYield as shouldYield,
  unstable_cancelCallback as cancelDeferredCallback,
} from 'scheduler';
// scheduleDeferredCallback
function unstable_scheduleCallback(callback, deprecated_options) {
  var startTime =
    currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime();

  var expirationTime;
  ...
  
  // 我们只需要关注这部分的内容
    switch (currentPriorityLevel) {
      case ImmediatePriority:
        expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT; // -1
        break;
      case UserBlockingPriority:
        expirationTime = startTime + USER_BLOCKING_PRIORITY; // 250
        break;
      case IdlePriority:
        expirationTime = startTime + IDLE_PRIORITY; // Big Int 不可能被过期的任务
        break;
      case NormalPriority:
      default:
        expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT; // 5000
    }
    
  var newNode = {
    callback,
    priorityLevel: currentPriorityLevel,
    expirationTime,
    next: null,
    previous: null,
  };

  // Insert the new callback into the list, ordered first by expiration, then
  // by insertion. So the new callback is inserted any other callback with
  // equal expiration.
  if (firstCallbackNode === null) {
    // 第一个 callback 的 节点
    firstCallbackNode = newNode.next = newNode.previous = newNode;
    ensureHostCallbackIsScheduled();
  } else {
    var next = null;
    var node = firstCallbackNode;
    do {
      // 循环判断 链表里面的 expirationTime 是否大于 当前的 expirationTime
      if (node.expirationTime > expirationTime) {
        // 对传进来的 Node 根据 expirationTime 大小进行排序 优先级最高的
        next = node;
        break;
      }
      node = node.next;
    } while (node !== firstCallbackNode);

    if (next === null) {
      // No callback with a later expiration was found, which means the new
      // callback has the latest expiration in the list.
      next = firstCallbackNode;
    } else if (next === firstCallbackNode) {
      // The new callback has the earliest expiration in the entire list.
      firstCallbackNode = newNode;
      ensureHostCallbackIsScheduled(); // 接到下一步 ensureHostCallbackIsScheduled
    }
    
    // 形成了一个环状的链表
    var previous = next.previous;
    previous.next = next.previous = newNode;
    newNode.next = next;
    newNode.previous = previous;
  }

  return newNode;
}

异步进行root任务调度就是通过这个方法来做的,这里最主要的就是调用了schedulerscheduleDeferredCallback方法(在scheduler包中是scheduleWork

传入的的是回调函数performAsyncWork,以及一个包含timeout超时事件的对象

这里出现了 两种种情况

  • FirstCallbackNodenull 的情况,它的两个指针 nextprevious

image.png

  • FirstCallbackNode 和 新的 CallbackNode 出现, next 指向的实际上是 下一个 需要被执行的 ExpirationTime 比较大的 Node,如果出现的新的 Node 节点 那么就会插入到 FirstCallbackNode 后面 它的 previousnext 将指向最后一个 Node

image.png

这里上面可以理解为调度前的准备

在任务调度前准备,建一个链状的数据结构,注册任务队列(环状链表,头接尾,尾接头)。

ensureHostCallbackIsScheduled

这个方法是在调度前做好准备的

如果已经在调用回调了,就 return,因为本来就会继续调用下去,isExecutingCallbackflushWork的时候会被修改为true

如果isHostCallbackScheduledfalse,也就是还没开始调度,那么设为true,如果已经开始了,就直接取消,因为顺序可能变了。

调用requestHostCallback正式开始调度

function ensureHostCallbackIsScheduled() {
  if (isExecutingCallback) {
    // Don't schedule work yet; wait until the next time we yield. 意思是已经在回调了
    return;
  }
  // 使用列表中最早的过期时间 安排 host 回调。
  var expirationTime = firstCallbackNode.expirationTime;
  if (!isHostCallbackScheduled) {
    isHostCallbackScheduled = true;
  } else {
    // 取消之前已经在队列中的 回调取消掉
    cancelHostCallback();
  }
  
  // 下面提到的就是 requestHostCallback
  requestHostCallback(flushWork, expirationTime); // 下面会提到 听名字可以知道执行 Host Callback
}

requestHostCallback

真正开始调度

开始进入调度,设置调度的内容,用scheduledHostCallbacktimeoutTime这两个全局变量记录回调函数和对应的过期时间

调用requestAnimationFrameWithTimeout,其实就是调用requestAnimationFrame在加上设置了一个100ms的定时器,防止requestAnimationFrame太久不触发。

调用回调animtionTick并设置isAnimationFrameScheduled全局变量为true

requestHostCallback = function(callback, absoluteTimeout) {
  scheduledHostCallback = callback; // firstNode Callback
  timeoutTime = absoluteTimeout; // firstCallBack 对应的 Expiration Time
  // 接下来要被调用的方法
  if (isFlushingHostCallback || absoluteTimeout < 0) {
    // 这里已经超时了
    window.postMessage(messageKey, '*'); // 不需要等待下一帧做,直接 postMessage,进入方法的调用不需要调度了
  } else if (!isAnimationFrameScheduled) {
    // isAnimationFrameScheduled 判断是否已经进入调度的流程中,如果没有那么开始继续进入流程
    // 如果 rAF 尚未安排一个帧,我们需要安排一个帧。
    // TODO:如果此 rAF 由于浏览器限制而无法实现,我们
    // 可能仍希望将 setTimeout 触发器 rIC 作为备份,以确保 我们继续执行工作。
    isAnimationFrameScheduled = true;
    // 防止 requestAnimationFrame 超过时间没有被调用 默认 100ms
    requestAnimationFrameWithTimeout(animationTick); // animationTick 下面的方法中会提到
  }
};

requestAnimationFrameWithTimeout

这个方法的目的是实际上是 实际上是调用了 requestAnimationFrame

告诉浏览器希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

解释

requestAnimationFrameWithTimeout,它是解决网页选项卡如果在未激活状态下 requestAnimationFrame 不会被触发的问题,这样的话,调度器是可以在后台继续做调度的,一方面也能提升用户体验,同时后台执行的时间间隔是以 100ms 为步长,100ms是不会影响用户体验同时也不影响CPU能耗的一个折中时间间隔

需要注意的点

  • 系统控制回调的执行时机恰好在回调注册完成后的下一帧渲染周期的起点的开始执行,控制js计算的到屏幕响应的精确性,避免步调不一致而导致丢帧
  • requestAnimationFrame回调只会在当前页面激活状态下执行,可以大大节省CPU开销
  • 需要注意一点,如果同时在一个高频次交互过程中注册多个requestAnimationFrame回调,这些回调的执行时机都会被注册至下一帧渲染周期的起点上,这样会导致每一帧的渲染压力增加
  • requestAnimationFrame回调参数是回调被调用的时间,也就是当前帧的起始时间
var ANIMATION_FRAME_TIMEOUT = 100; // 100ms
var rAFID;
var rAFTimeoutID;

var requestAnimationFrameWithTimeout = function(callback) {
  // localRequestAnimationFrame 相当于 window.animationFrame API
  rAFID = localRequestAnimationFrame(function(timestamp) {
    // 清楚定时器
    localClearTimeout(rAFTimeoutID);
    callback(timestamp); // Callback 实际是 animationTick 方法 
  });
  
  rAFTimeoutID = localSetTimeout(function() {
    // 超过 100ms 没有被调用将会取消
    localCancelAnimationFrame(rAFID);
    callback(getCurrentTime()); // 防止 requestAnimationFrame 超过时间没有被调用
    // 谁先触发 谁先会被调用
  }, ANIMATION_FRAME_TIMEOUT);
};

animationTick

只要scheduledHostCallback还在就继续调要requestAnimationFrameWithTimeout因为这一帧渲染完了可能队列还没情况,本身也是要进入再次调用的,这边就省去了requestHostCallback在次调用的必要性

接下去一段代码是用来计算相隔的requestAnimationFrame的时差的,这个时差如果连续两次都小于当前的activeFrameTime,说明平台的帧率是很高的,这种情况下会动态得缩小帧时间。

最后更新frameDeadline,然后如果没有触发idleTick则发送消息

var animationTick = function(rafTime) {
  if (scheduledHostCallback !== null) {
    // 这里对应 上面的判断逻辑
    // 在框架安排下一个动画回调
    // 如果调度程序队列在帧结束时不为空,则它将在该回调中继续刷新
    // 如果队列为空 将立即退出
    // 在开始时发布回调 frame 确保它在尽可能早的帧内被触发
    // 是我们等到帧结束才发布回调,我们冒着浏览器跳过一帧,直到该帧才触发回调
    requestAnimationFrameWithTimeout(animationTick);
  } else {
    // 没有任务需要调度了, 直接退出
    isAnimationFrameScheduled = false;
    return;
  }

  // 动态计算帧数
  // 计算 当前的方法 到下一帧 可以执行的时间是多少
  var nextFrameTime = rafTime - frameDeadline + activeFrameTime; //
  
  // 这里 nextFrameTime previousFrameTime 默认都是 33ms
  if (
    nextFrameTime < activeFrameTime &&
    previousFrameTime < activeFrameTime
  ) {
    if (nextFrameTime < 8) {
      // 不支持小于 8ms 的刷新时间 大约 120hz
      nextFrameTime = 8;
    }
    // 判断当前平台的刷新频率 前后几次计算
    // 例如,如果我们 在120hz显示器或90hz VR显示器上运行。
    // 取两者中的最大值,以防其中一个由于以下原因而出现异常
    // 错过了帧截止日期
    activeFrameTime =
      nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
      // activeFrameTime 是一帧完整的时间
  } else {
    previousFrameTime = nextFrameTime;
  }
  frameDeadline = rafTime + activeFrameTime;
  if (!isMessageEventScheduled) {
    isMessageEventScheduled = true;
    window.postMessage(messageKey, '*'); // window.postMessage 理解
  }
};

关于 window.postMessage 的理解

因为requestIdleCallback这个 API 目前还处于草案阶段,所以浏览器实现率还不高,所以在这里 React 直接使用了polyfill的方案。

image.png

这个方案简单来说是通过requestAnimationFrame在浏览器渲染一帧之前做一些处理,然后通过postMessagemacro task(类似 setTimeout)中加入一个回调,在因为接下去会进入浏览器渲染阶段,所以主线程是被 block 住的,等到渲染完了然后回来清空macro task。是一个任务队列的概念

总体上跟requestIdleCallback差不多,等到主线程有空的时候回来调用

调完 window.postMessage 以后在 后面 调用的地方就是 idleTick

animationTick 通过 postMessage 触发 idleTick

// Assumes that we have addEventListener in this environment. Might need
// something better for old IE.
window.addEventListener('message', idleTick, false);

idleTick

这个方法的实际作用是 调度前面定义好的任务,根据当前的时间戳和之前定义好的时间进行比较,然后判断是否过期等,是一个 闭环 的过程,判断然后重复之前的方法

首先判断postMessage是不是自己的,不是直接返回

清空scheduledHostCallbacktimeoutTime

获取当前时间,对比frameDeadline,查看是否已经超时了,如果超时了,判断一下任务callback的过期时间有没有到,如果没有到,则重新对这个callback进行一次调度,然后返回。如果到了,则设置didTimeouttrue

接下去就是调用callback了,这里设置isFlushingHostCallback全局变量为true代表正在执行。并且调用callback也就是flushWork并传入didTimeout

var idleTick = function(event) {
  if (event.source !== window || event.data !== messageKey) {
    return;
  }

  isMessageEventScheduled = false;

  var prevScheduledCallback = scheduledHostCallback;
  var prevTimeoutTime = timeoutTime;
  scheduledHostCallback = null;
  timeoutTime = -1;

  var currentTime = getCurrentTime();

  var didTimeout = false;
  if (frameDeadline - currentTime <= 0) {
    // 已经花的时间超过了 33ms 了 没有时间剩下去更新了 这一帧的时间已经用完了
    if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
      // 判断 Timeout 是否已经过期了  如果Timeout  小于当前时间的话,那么当前任务也已经过期了
      didTimeout = true; // 需要强制更新了
    } else {
      // 没有过期
      if (!isAnimationFrameScheduled) {
        // Schedule another animation callback so we retry later.
        isAnimationFrameScheduled = true;
        requestAnimationFrameWithTimeout(animationTick); // 又重新执行方法 让它可以回到上一步
      }
      // 回到上一步的情况了
      scheduledHostCallback = prevScheduledCallback;
      timeoutTime = prevTimeoutTime;
      return;
    }
  }

  if (prevScheduledCallback !== null) {
    isFlushingHostCallback = true; // 当前正在调用 Callback
    try {
       //  调用 Callback 并且传入 didTimeout 判断是否需要强制输出
      prevScheduledCallback(didTimeout);
    } finally {
      isFlushingHostCallback = false; // 接下来需要调用的是 flushWork 的方法
    }
  }
};

flushWork

  • 先执行最优先的任务
  • 如果存在任务,则进入下一帧,进入下一个调度生命周期

函数功能是 首先 执行掉所有的过期任务,然后 依此执行 callback, 直到帧时间结束为止

先设置isExecutingCallbacktrue,代表正在调用callback

设置deadlineObject.didTimeout,在 React 业务中可以用来判断任务是否超时

如果didTimeout,会一次从firstCallbackNode向后一直执行,直到第一个没过期的任务

如果没有超时,则依此执行第一个callback,直到帧时间结束为止

最后清理变量,如果任务没有执行完,则再次调用ensureHostCallbackIsScheduled进入调度

顺便把Immedia优先级的任务都调用一遍。

var deadlineObject = {
  timeRemaining,
  didTimeout: false,
};


function flushWork(didTimeout) {
  isExecutingCallback = true; // 开始真正调用 Callback 之后 设置成 true
  deadlineObject.didTimeout = didTimeout; // deadline
  try {
    // firstTimeout Node 任务已经过期了
    if (didTimeout) {
      // 执行掉所有的过期任务
      while (firstCallbackNode !== null) {
        var currentTime = getCurrentTime();
        if (firstCallbackNode.expirationTime <= currentTime) {
          // 这里的意思是 执行链表中过期的任务,直到没有过期为止
          // 
          do {
            flushFirstCallback(); // 真正调用 Callback 方法 把所有过期的任务都执行掉
          } while (
            firstCallbackNode !== null &&
            firstCallbackNode.expirationTime <= currentTime
          );
          continue;
        }
        break;
      }
    } else {
      // 保持刷新回调,直到我们在帧中用完时间。
      if (firstCallbackNode !== null) {
        do {
          flushFirstCallback(); // 这里和上面调用 Callback 一致
        } while (
          firstCallbackNode !== null &&
          getFrameDeadline() - getCurrentTime() > 0 // 帧的时间还有空的情况下
        );
      }
    }
  } finally {
    isExecutingCallback = false; // 设置状态变量,说明已经完成了
    if (firstCallbackNode !== null) {
      // 还有工作要做。请求另一个回调 继续回到上面的逻辑
      ensureHostCallbackIsScheduled();
    } else {
      isHostCallbackScheduled = false; // 设置状态变量,说明已经完成了
    }
    // 在退出之前,请 flush 所有已安排的 工作。 这个方法目前没有被调用过 这里就不展开说
    flushImmediateWork();
  }
}

flushFirstCallback

方法做的是什么作用呢?

  • 如果当前队列中只有一个回调,清空队列
  • 调用回调并传入deadline对象,里面有timeRemaining方法通过 frameDeadline - now() 来判断是否帧时间已经到了
  • 如果回调有返回内容,把这个返回加入到回调队列
function flushFirstCallback() {
  var flushedNode = firstCallbackNode;

  // 在调用回调之前,在列表中删除节点。这样,即使回调引发,列表也处于一致状态。
  var next = firstCallbackNode.next;
  if (firstCallbackNode === next) {
    // This is the last callback in the list.
    firstCallbackNode = null;
    next = null;
  } else {
    var lastCallbackNode = firstCallbackNode.previous;
    firstCallbackNode = lastCallbackNode.next = next;
    next.previous = lastCallbackNode;
  }

  // 更新链表的状态
  flushedNode.next = flushedNode.previous = null;

 
  var callback = flushedNode.callback;
  var expirationTime = flushedNode.expirationTime;
  var priorityLevel = flushedNode.priorityLevel;
  var previousPriorityLevel = currentPriorityLevel;
  var previousExpirationTime = currentExpirationTime;
  currentPriorityLevel = priorityLevel;
  currentExpirationTime = expirationTime;
  var continuationCallback;
  try {
    continuationCallback = callback(deadlineObject); // 调用回调
  } finally {
    currentPriorityLevel = previousPriorityLevel;
    currentExpirationTime = previousExpirationTime;
  }

  // 回调可能会返回延续。应安排继续 具有与刚刚完成的回调相同的优先级和过期时间。
  if (typeof continuationCallback === 'function') {
    var continuationNode: CallbackNode = {
      callback: continuationCallback,
      priorityLevel,
      expirationTime,
      next: null,
      previous: null,
    };

    // 如果回调有返回内容,把这个返回加入到回调队列
    ... 后面的内容是细枝末节 这里就不展开了
}

总结

这里其实 React 模拟实现了 requestIdleCallback 这个核心 API,维护时间片的操作。它控制把更多的优先权交给浏览器做动画或者用户输入反馈这些更新,然后等浏览器空闲的时候,去执行 React 异步操作,并且有很多条件和变量去控制,帧时间的判断。

比如说,浏览器刷新频率是更高,那么它将会调整,每一帧可用的时间片大小,最小支持的是 8ms。 还比如说,判断任务是否已经过期了,过期的话需要强制输出执行等等。

方法之间的流程示意图总结

image.png

之前和之后的图片对比

使用调度以后是这样的 image.png

之前是这样的 image.png

全局变量参考

isExecutingCallback

判断是否已经执行了回调方法 在 flushWork 设置成 true,最后设置为 false

isHostCallbackScheduled

是否已经开始调度了,在ensureHostCallbackIsScheduled设置为true,在结束执行callback之后设置为false

scheduledHostCallback

requestHostCallback设置,值一般为flushWork,代表下一个调度要做的事情

isMessageEventScheduled

是否已经发送调用idleTick的消息,在animationTick中设置为true

timeoutTime

表示过期任务的时间,在idleTick中发现第一个任务的时间已经过期的时候设置

isAnimationFrameScheduled

是否已经开始调用requestAnimationFrame

activeFrameTime

给一帧渲染用的时间,默认是 33,也就是 1 秒 30 帧

frameDeadline

记录当前帧的到期时间,他等于currentTime + activeFraeTime,也就是requestAnimationFrame回调传入的时间,加上一帧的时间。

isFlushingHostCallback

是否正在执行callback 函数方法

文章参考

浅谈React Scheduler任务管理