Cosen95/blog

reactScheduler

Cosen95 opened this issue · 0 comments

自从react 16出来以后,react fiber相关的文章层出不穷,但大多都是讲解fiber的数据结构,以及组件树的diff是如何由递归改为循环遍历的。对于time slicing的描述一般都说利用了requestIdleCallback这个api来做调度,但对于任务如何调度却很难找到详细的描述。

因此,本篇文章就是来干这个事情的,从源码角度来一步步阐述React Scheduler是怎么实现任务调度的。

scheduler

React16.5 之后把scheduler单独发一个包了,就叫scheduler,对应源码在packages/scheduler/src/Scheduler.js

scheduleCallbackWithExpirationTime

在上一节requestWork的最后:

// TODO: Get rid of Sync and use current time?
if (expirationTime === Sync) {
  // 同步的调用 js 代码
  performSyncWork();
} else {
  // 异步调度 独立的 react 模块包,利用浏览器有空闲的时候进行执行,设置 deadline 在此之前执行
  scheduleCallbackWithExpirationTime(root, expirationTime);
}

异步调度调用的是scheduleCallbackWithExpirationTime方法:

function scheduleCallbackWithExpirationTime(
  root: FiberRoot,
  expirationTime: ExpirationTime
) {
  if (callbackExpirationTime !== NoWork) {
    // A callback is already scheduled. Check its expiration time (timeout).
    if (expirationTime < callbackExpirationTime) {
      // Existing callback has sufficient timeout. Exit.
      return;
    } else {
      if (callbackID !== null) {
        // Existing callback has insufficient timeout. Cancel and schedule a
        // new one.
        cancelDeferredCallback(callbackID);
      }
    }
    // The request callback timer is already running. Don't start a new one.
  } else {
    startRequestCallbackTimer();
  }

  callbackExpirationTime = expirationTime;
  const currentMs = now() - originalStartTimeMs;
  const expirationTimeMs = expirationTimeToMs(expirationTime);
  const timeout = expirationTimeMs - currentMs;
  callbackID = scheduleDeferredCallback(performAsyncWork, { timeout });
}

这里最主要的就是调用了schedulerscheduleDeferredCallback方法(在scheduler包中是scheduleWork

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

scheduleDeferredCallback定义在packages/react-dom/src/client/ReactDOMHostConfig.js

export {
  unstable_now as now,
  unstable_scheduleCallback as scheduleDeferredCallback,
  unstable_shouldYield as shouldYield,
  unstable_cancelCallback as cancelDeferredCallback,
} from "scheduler";

这里用的是scheduler包中的unstable_scheduleCallback方法。

分析这个方法前,先来看下几个定义:

任务优先级

react内对任务定义的优先级分为 5 种,数字越小优先级越高:

// 最高优先级
var ImmediatePriority = 1;
// 用户阻塞型优先级
var UserBlockingPriority = 2;
// 普通优先级
var NormalPriority = 3;
// 低优先级
var LowPriority = 4;
// 空闲优先级
var IdlePriority = 5;

这 5 种优先级依次对应 5 个过期时间:

// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823;

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out

var USER_BLOCKING_PRIORITY = 250;

var NORMAL_PRIORITY_TIMEOUT = 5000;

var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out

var IDLE_PRIORITY = maxSigned31BitInt;

每个任务在添加到链表里的时候,都会通过performance.now() + timeout来得出这个任务的过期时间,随着时间的推移,当前时间会越来越接近这个过期时间,所以过期时间越小的代表优先级越高。如果过期时间已经比当前时间小了,说明这个任务已经过期了还没执行,需要立马去执行。

上面的maxSigned31BitInt,通过注释可以知道这是 32 位系统 V8 引擎里最大的整数。react用它来做IdlePriority的过期时间。
据粗略计算这个时间大概是 12.427 天。也就是说极端情况下你的网页tab如果能一直开着到 12 天半,任务才有可能过期。

getCurrentTime

获取当前时间,如果不支持performance,利用localDate.now()fallback

var getCurrentTime;

if (hasNativePerformanceNow) {
  var Performance = performance;
  getCurrentTime = function () {
    return Performance.now();
  };
} else {
  getCurrentTime = function () {
    return localDate.now();
  };
}

unstable_scheduleCallback

回到上面的unstable_scheduleCallback方法:

function unstable_scheduleCallback(callback, deprecated_options) {
  var startTime =
    currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime();

  var expirationTime;
  if (
    typeof deprecated_options === "object" &&
    deprecated_options !== null &&
    typeof deprecated_options.timeout === "number"
  ) {
    // FIXME: Remove this branch once we lift expiration times out of React.
    // 如果传了options, 就用入参的过期时间
    expirationTime = startTime + deprecated_options.timeout;
  } else {
    // 判断当前的优先级
    switch (currentPriorityLevel) {
      case ImmediatePriority:
        expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
        break;
      case UserBlockingPriority:
        expirationTime = startTime + USER_BLOCKING_PRIORITY;
        break;
      case IdlePriority:
        expirationTime = startTime + IDLE_PRIORITY;
        break;
      case LowPriority:
        expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
        break;
      case NormalPriority:
      default:
        expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
    }
  }
  // 上面确定了当前任务的截止时间,下面创建一个任务节点
  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.
  // 下面是按照 expirationTime 把 newNode 加入到任务队列里
  if (firstCallbackNode === null) {
    // This is the first callback in the list.
    firstCallbackNode = newNode.next = newNode.previous = newNode;
    ensureHostCallbackIsScheduled();
  } else {
    var next = null;
    var node = firstCallbackNode;
    do {
      if (node.expirationTime > expirationTime) {
        // The new callback expires before this one.
        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();
    }

    var previous = next.previous;
    previous.next = next.previous = newNode;
    newNode.next = next;
    newNode.previous = previous;
  }

  return newNode;
}

这个方法的作用就是把任务以过期时间作为优先级进行排序,过程类似双向循环链表的操作过程。

这个方法有两个入参,第一个是要执行的callback,暂时可以理解为一个任务。第二个参数是可选的,可以传入一个超时时间来标识这个任务过多久超时。如果不传的话就会根据上述的任务优先级确定过期时间。

同时会生成一个真正的任务节点。接下来就要把这个节点按照expirationTime排序插入到任务的链表里边去。

到这里一个新进来的任务如何确定过期时间以及如何插入现有的任务队列就讲完了。

此时不禁产生一个疑问,我们把任务按照过期时间排好顺序了,那么何时去执行任务呢?

答案是有两种情况:
1、当添加第一个任务节点的时候开始启动任务执行
2、当新添加的任务取代之前的节点成为新的第一个节点的时候

因为 1 意味着任务从无到有,应该立刻启动,2 意味着来了新的优先级最高的任务,应该停止掉之前要执行的任务,重新从新的任务开始执行。

上面两种情况就对应ensureHostCallbackIsScheduled方法执行的两个分支。所以我们现在应该知道,ensureHostCallbackIsScheduled是用来在合适的时机去启动任务执行的。

到底什么是合适的时机?可以这么描述,在每一帧绘制完成之后的空闲时间。这样就能保证浏览器绘制每一帧的频率能跟上系统的刷新频率,不会掉帧。

ensureHostCallbackIsScheduled

然后来看ensureHostCallbackIsScheduled这个函数,这个也很简单,首先判断是否任务已经开始循环安排了,如果是,则退出,如果没有,则重置条件,重新开始去请求循环安排任务。

function ensureHostCallbackIsScheduled() {
  // 有一个callback正在进行
  if (isExecutingCallback) {
    // Don't schedule work yet; wait until the next time we yield.
    return;
  }
  // firstCallbackNode的过期时间是最早的
  // Schedule the host callback using the earliest expiration in the list.
  var expirationTime = firstCallbackNode.expirationTime;
  if (!isHostCallbackScheduled) {
    isHostCallbackScheduled = true;
  } else {
    // Cancel the existing host callback.
    // 取消其它存在的host callback
    cancelHostCallback();
  }
  // 开始安排任务队列
  requestHostCallback(flushWork, expirationTime);
}

可以看到这里最后走到了requestHostCallback(flushWork, expirationTime); 这里的flushworkschedule的一个刷新任务队列函数,等会再看。先看下requestHostCallback

requestHostCallback

这里requestHostCallback根据传入的callback和过期时间确定下一步执行那些操作,如果当前正在执行任务,或者是过期时间小于 0,则通过port.postMessage发送信息,来立即执行任务更新。

这里的port.postMessage是:

var channel = new MessageChannel();
var port = channel.port2;

这里可以理解为一个通道,就是当在scheduler中如果想要立即执行任务链表的更新,就可以通过port.postMessage来发送一个信息,通过channel.port1.onmessage来接收信息,并且立即开始执行任务链表的更新,类似一个发布订阅,当想更新链表的时候,只需要发送个信息就可以了。

scheduler里边就是通过MessageChannel来完成通知和执行任务链表更新操作的。

requestHostCallback 里边如果没有到到期时间且还还没有开始通过isAnimationFrameScheduled来订阅浏览器的空闲时间,则通过requestAnimationFrameWithTimeout(animationTick)去订阅。

requestHostCallback = function (callback, absoluteTimeout) {
  // callback就是flushWork
  // absoluteTimeout是传入的过期时间
  scheduledHostCallback = callback;
  // timeoutTime就是callback链表的头部的expirationTime
  timeoutTime = absoluteTimeout;
  // isFlushingHostCallback这个判断是一个Eagerly操作,如果有新的任务进来,
  // 尽量让其直接执行,防止浏览器在下一帧才执行这个callback
  // 这个判断其实不是很好理解,建议熟悉模块之后再回来看,并不影响scheduler核心逻辑
  if (isFlushingHostCallback || absoluteTimeout < 0) {
    // absoluteTimeout < 0说明任务超时了,立刻执行,不要等下一帧
    // Don't wait for the next frame. Continue working ASAP, in a new event.
    port.postMessage(undefined);
  } else if (!isAnimationFrameScheduled) {
    // If rAF didn't already schedule one, we need to schedule a frame.
    // TODO: If this rAF doesn't materialize because the browser throttles, we
    // might want to still have setTimeout trigger rIC as a backup to ensure
    // that we keep performing work.
    isAnimationFrameScheduled = true;
    requestAnimationFrameWithTimeout(animationTick);
  }
};

requestAnimationFrameWithTimeout

这里主要是使用requestAnimationFrame,但是会有requestAnimationFrame不起作用的情况下,使用setTimeout

var requestAnimationFrameWithTimeout = function (callback) {
  // callback就是animationTick方法
  // schedule rAF and also a setTimeout
  // localRequestAnimationFrame相当于window.requestAnimationFrame
  // 1. 调用requestAnimationFrame
  rAFID = localRequestAnimationFrame(function (timestamp) {
    // cancel the setTimeout
    localClearTimeout(rAFTimeoutID);
    callback(timestamp);
  });
  // 2. 调用setTimeout,时间为ANIMATION_FRAME_TIMEOUT(100),超时则取消rAF,改为直接调用
  rAFTimeoutID = localSetTimeout(function () {
    // cancel the requestAnimationFrame
    localCancelAnimationFrame(rAFID);
    callback(getCurrentTime());
  }, ANIMATION_FRAME_TIMEOUT);
};

代码也很简单,这里传入的callbackanimationTick,去看下animationTick的代码。

animationTick

var animationTick = function (rafTime) {
  // scheduledHostCallback也就是callback
  if (scheduledHostCallback !== null) {
    // Eagerly schedule the next animation callback at the beginning of the
    // frame. If the scheduler queue is not empty at the end of the frame, it
    // will continue flushing inside that callback. If the queue *is* empty,
    // then it will exit immediately. Posting the callback at the start of the
    // frame ensures it's fired within the earliest possible frame. If we
    // waited until the end of the frame to post the callback, we risk the
    // browser skipping a frame and not firing the callback until the frame
    // after that.
    // 这里是连续递归调用,直到scheduledHostCallback === null
    // scheduledHostCallback会在messageChannel的port1的回调中设为null
    // 因为requestAnimationFrameWithTimeout会加入event loop,所以这里不是普通递归,而是每一帧执行一次
    // 注意当下一帧执行了animationTick时,之前的animationTick已经计算出了nextFrameTime

    requestAnimationFrameWithTimeout(animationTick);
  } else {
    // No pending work. Exit.
    isAnimationFrameScheduled = false;
    return;
  }
  // 保持浏览器能保持每秒30帧,那么每帧就是33毫秒
  // activeFrameTime在模块顶部定义,初始值为33
  // previousFrameTime的初始值也是33
  // nextFrameTime就是此方法到下一帧之前可以执行多少时间
  // 如果第一次执行,nextFrameTime肯定是很大的,因为frameDeadline为0
  // rafTime是当前时间戳

  var nextFrameTime = rafTime - frameDeadline + activeFrameTime;
  if (nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) {
    if (nextFrameTime < 8) {
      // Defensive coding. We don't support higher frame rates than 120hz.
      // If the calculated frame time gets lower than 8, it is probably a bug.
      nextFrameTime = 8;
    }
    // If one frame goes long, then the next one can be short to catch up.
    // If two frames are short in a row, then that's an indication that we
    // actually have a higher frame rate than what we're currently optimizing.
    // We adjust our heuristic dynamically accordingly. For example, if we're
    // running on 120hz display or 90hz VR display.
    // Take the max of the two in case one of them was an anomaly due to
    // missed frame deadlines.

    // 这里试探性的设置了activeFrame,因为在某些平台下,每秒的帧数可能更大,例如vr游戏这种情况
    // 设置activeFrameTime为previousFrameTime和nextFrameTime中的较大者
    activeFrameTime =
      nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
  } else {
    previousFrameTime = nextFrameTime;
  }
  frameDeadline = rafTime + activeFrameTime;
  // isMessageEventScheduled的值也是在port1的回调中设置为false
  // isMessageEventScheduled的意义就是每一帧的animationTick是否被执行完
  // animationTick -> port.postMessage(设置isMessageEventScheduled为false) -> animationTick
  // 防止port.postMessage被重复调用(应该是在requestAnimationFrameWithTimeout超时的时候会出现的情况
  // 因为postMessage也是依赖event loop,可能会有竞争关系

  if (!isMessageEventScheduled) {
    isMessageEventScheduled = true;
    // port就是port1
    // postMessage是event loop下一个tick使用,所以就是frameDeadline中,其实留了空闲时间给浏览器执行动画渲染
    // 举个例子: 假设当前浏览器为30帧,则每帧33ms,frameDeadline为currentTime + 33,当调用了port.postMessage,当前tick的js线程就变为空了
    // 这时候就会留给浏览器部分时间做动画渲染,所以实现了requestIdleCallback的功能
    // port.postMessage是留给空出js线程的关键
    port.postMessage(undefined);
  }
};

中间部分nextFrameTime的判断是React检查帧数的计算,我们先忽略,关注整体。

animationTick一开始直接判断scheduledHostCallback是否为null,否则就继续通过requestAnimationFrameWithTimeout调用animationTick自身,这是一个逐帧执行的递归。意思就是这个递归在浏览器渲染下一帧的时候,才会再次调用animationTick

也就是在animationTick调用requestAnimationFrameWithTimeout(animationTick)之后,后面的代码依然有时间可以执行。因为递归会在下一帧由浏览器调用。而在animationTick最后的代码调用了port.postMessage,这是一个浏览器提供的APIMessageChannel,主要用于注册的两端port之间相互通讯,有兴趣的读者可以自己查查。

MessageChannel的通讯每次调用都是异步的,类似于EventListener``,也就是,当调用port.postMessage时告诉浏览器当前EventLoop的任务执行完了,浏览器可以检查一下现在有没有别的任务进来(例如动画或者用户操作),然后插入到下一个EventLoop中。(当然在EventLoop的任务队列中,animationTick剩余的代码优先级会比动画及用户操作更高,因为排序排在前面。但是其实后面的代码也会有根据帧时间是否足够,执行让出线程的操作)

递归的流程如下图:

接下来判断了isMessageEventScheduled的布尔值,这是防止port.postMessage被重复调用。

channel.port1.onmessage(idleTick)

animationTick中调用port.postMessage(undefined)之后,我们实际上进入了channel.port1的回调函数:

// We use the postMessage trick to defer idle work until after the repaint.
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = function (event) {
  // 设置为false,防止animationTick的竞争关系
  isMessageEventScheduled = false;

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

  var currentTime = getCurrentTime();

  var didTimeout = false;
  // 说明超过了activeFrameTime的时间(默认值33
  // 说明这一帧没有空闲时间,然后检查任务是否过期,过期的话就设置didTimeout,用于后面强制执行
  if (frameDeadline - currentTime <= 0) {
    // 查看任务是否过期,过期则强行更新
    // There's no time left in this idle period. Check if the callback has
    // a timeout and whether it's been exceeded.
    if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
      // Exceeded the timeout. Invoke the callback even though there's no
      // time left.
      // 这种过期的情况有可能已经掉帧了
      didTimeout = true;
    } else {
      // 没有超时则等待下一帧再执行
      // No timeout.
      // isAnimationFrameScheduled这个变量就是判断是否在逐帧执行animationTick
      // 开始设置animationTick时设置为true,animationTick结束时设置为false
      if (!isAnimationFrameScheduled) {
        // Schedule another animation callback so we retry later.
        isAnimationFrameScheduled = true;
        requestAnimationFrameWithTimeout(animationTick);
      }
      // Exit without invoking the callback.
      // 因为上一个任务没有执行完,设置回原来的值,等animationTick继续处理scheduledHostCallback
      scheduledHostCallback = prevScheduledCallback;
      timeoutTime = prevTimeoutTime;
      return;
    }
  }

  if (prevScheduledCallback !== null) {
    isFlushingHostCallback = true;
    try {
      prevScheduledCallback(didTimeout);
    } finally {
      isFlushingHostCallback = false;
    }
  }
};

此处代码用了React中常用的命名方式prevXXXX,一般是在某个流程之中,先保留之前的值,在执行完某个操作之后,再还原某个值,提供给别的代码告诉自己正在处理的阶段。例如:

var prevScheduledCallback = scheduledHostCallback;
scheduledHostCallback = null;
// ...
// ...
// 还原
scheduledHostCallback = prevScheduledCallback;

整个回调函数其实比较简单,只有几个分支:

flushWork

下面就是flushWork了:

function flushWork(didTimeout) {
  // Exit right away if we're currently paused
  // didTimeout是指任务是否超时
  if (enableSchedulerDebugging && isSchedulerPaused) {
    return;
  }

  isExecutingCallback = true;
  const previousDidTimeout = currentDidTimeout;
  currentDidTimeout = didTimeout;
  try {
    if (didTimeout) {
      // Flush all the expired callbacks without yielding.
      while (
        firstCallbackNode !== null &&
        !(enableSchedulerDebugging && isSchedulerPaused)
      ) {
        // TODO Wrap i nfeature flag
        // Read the current time. Flush all the callbacks that expire at or
        // earlier than that time. Then read the current time again and repeat.
        // This optimizes for as few performance.now calls as possible.
        var currentTime = getCurrentTime();
        if (firstCallbackNode.expirationTime <= currentTime) {
          // 这个循环的意思是,遍历callbackNode链表,直到第一个没有过期的callback
          // 所以主要意义就是将所有过期的callback立刻执行完
          do {
            // 这个函数有将callbackNode剥离链表并执行的功能, firstCallbackNode在调用之后会修改成为新值
            // 这里遍历直到第一个没有过期的callback
            flushFirstCallback();
          } while (
            firstCallbackNode !== null &&
            firstCallbackNode.expirationTime <= currentTime &&
            !(enableSchedulerDebugging && isSchedulerPaused)
          );
          continue;
        }
        break;
      }
    } else {
      // Keep flushing callbacks until we run out of time in the frame.
      if (firstCallbackNode !== null) {
        do {
          if (enableSchedulerDebugging && isSchedulerPaused) {
            break;
          }
          flushFirstCallback();
          // shouldYieldToHost就是比较frameDeadline和currentTime,就是当前帧还有时间的话,就一直执行
        } while (firstCallbackNode !== null && !shouldYieldToHost());
      }
    }
  } finally {
    isExecutingCallback = false;
    currentDidTimeout = previousDidTimeout;
    if (firstCallbackNode !== null) {
      // There's still work remaining. Request another callback.
      // callback链表还没全部执行完,继续
      // ensureHostCallbackIsScheduled也是会启动下一帧,所以不是连续调用
      // 同时,isHostCallbackScheduled决定了ensureHostCallbackIsScheduled的行为,
      // 在此分支中isHostCallbackScheduled === true, 所以ensureHostCallbackIsScheduled会执行一个cancelHostCallback函数
      // cancelHostCallback设置scheduledHostCallback为null,可以令上一个animationTick停止
      ensureHostCallbackIsScheduled();
    } else {
      // isHostCallbackScheduled这个变量只会在ensureHostCallbackIsScheduled中被设置为true
      // 这个变量的意义可能是代表,是否所有任务都被flush了?,因为只有firstCallbackNode === null的情况下才会设为false

      isHostCallbackScheduled = false;
    }
    // Before exiting, flush all the immediate work that was scheduled.
    flushImmediateWork();
  }
}