React scheduler模块part2 - scheduler包

331 阅读10分钟

我正在参加「掘金·启航计划」

大家好,我是三重堂堂主(公众号:咪仔和汤圆,欢迎关注~)

TIP:这一篇可能源码较多,也比较难理解~

上一篇(这里~)说了React scheduler中实现时间分片靠的是Event Loop中的Task,通过messageChannelsetImmediate或者setTimeout来实现。这里再解释下为什么是宏任务,而不是同步任务或者是微任务。首先微任务的执行机制是执行完当前微任务队列再进行下一个Event Loop,所以不能用微任务;如果用同步任务,当前帧时间不够需要打断的时候,scheduler需要让当前的调度停止并安排没执行完的任务继续执行,伪代码:

while(!shouldYield){
  if(需要中断){
      shouldYield = ture
      doNext()
  }  
}

doNext就是安排下一次Event Loop的函数,如果是同步任务,当前Event Loop就会马上开始继续调度,达不到时间分片的作用。

React中的scheduler包

React中为了抽象出“通过优先级和时间分片调度任务”这种能力,将scheduler单独发布了一个包,而不受框架影响,是一个纯的JS包。

本篇主要就是讲scheduler的流程和源码:scheduler源码地址~

流程

第一步,在scheduler最开始的时候,调用scheduler包中的任务分发函数scheduleCallback,以一定的优先级发送一个任务(也叫task,后续同理),其中参数options会有delay属性。

第二步,在发送任务后,根据任务的优先级以及是否delay,放入不同的队列中,然后才开始调度任务到任务执行这一过程。

第三步,放入队列后,进行调度任务,调度任务需要根据当前的执行情况来筛选出当前需要处理的task;因为有延时任务(有delay属性的就是延时任务)和立即执行任务之分,所以这里处理不同的task有不同的流程。

第四步,到了任务执行阶段,拿到最高优先级的task进行执行,以及处理中断或者执行完成的情况。

大致流程如下:

image.png

因此我们主要分四个部分来讲scheduler的:

  1. 第一部分是存储任务以及重排任务(这里重排任务指的是处理任务到正确的队列上)相关;
  2. 第二部分是处理延时任务阶段;
  3. 第三部分是处理立即执行任务阶段;
  4. 第四部分是执行任务到下一次的整体循环。

存储和重排任务

通过schedulerCallback来派发任务,其中options如果有delay,则是一个延时任务,没有则是一个立即执行任务。scheduer中用TimerQueue来存储延时任务,用TaskQueue来存储立即执行任务,并且都是采用“小顶堆”的存储方式,也就是每次push或者pop后都会重新排列任务,将优先级最高的任务放在第一个。

TaskQueue中的任务是以过期时间为优先级,过期时间最近的任务优先级最高TimerQueue是以开始时间为优先级,开始时间最近的任务优先级最高,请牢牢这里记住一点。

TaskQueue中存放的是需要立即执行的task,所以在TaskQueue中的任务肯定都会比TimerQueue中的优先。

派发任务的时候会通过任务优先级以及当前时间、是否delay来生成一个task

  // 生成一个调用的时刻
  var startTime;
  
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }

  // 根据优先级计算超时时间
  var timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }
  // 计算当前这个调用的过期时刻 = 开始时刻 + 过期时间
  var expirationTime = startTime + timeout;

  // 根据调用 生成一个task  任务对象封装
  var newTask = {
    id: taskIdCounter++, // 一个全局的计数id
    callback, // 回调
    priorityLevel, // 优先级
    startTime, //开始时刻
    expirationTime, // 过期时刻
    sortIndex: -1, // 排序优先级
  };

生成这个任务后,则需要将其放入正确的queue。如果是延迟任务,将tasksortIndex赋值为startTime,放入TimerQueue中;如果是立即执行任务,将tasksortIndex赋值为expirationTime,放入TaskQueue中。

至此,存储和重排任务的上半段就完成了,之所以说是上半段是因为在执行任务后阶段,执行完一个task后,都需要重新重排两个queue,此为下半段。

// scheduleCallback

// 如果开始时刻在当前时刻之后  则是延迟的任务
  if (startTime > currentTime) {
    // This is a delayed task.
    // timerQueue中的sortIndex为开始时间
    // 将任务的sortIndex改为startTime并且放入延迟队列
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    // ...      
  } else {
    // 如果任务已经需要开始了 则把sortIndex设置为过期时间并放入需要立即执行的队列
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    // ...
  }

image.png

处理延时任务

假设我们在存储和重排的时候放入的是一个延时任务,放入TimerQueue后我们需要进行延时任务的处理,大致有以下几种情况:

  1. TaskQueue中没有任务,TimerQueue中放入的当前taskTimerQueue优先级最高的task
  2. TaskQueue中没有任务,TimerQueue中有其他的task比当前的task优先级更高
  3. TaskQueue中有需要执行的任务

首选说简单的第3种情况,也就是我们放入延时任务后,TaskQueue中是有需要执行的task的情况。这种情况我们就不需要去关心TimerQueue中是否有任务,因为在当前时间下都是延迟的,肯定不会比TaskQueue中的优先级高,因此这种情况处理延时任务的流程就结束了。TaskQueue中的任务怎么执行,什么时候执行,不是“处理延时任务阶段”的工作。

然后我们说第1种情况,TaskQueue中没有任务,当前taskTimerQueue是最高优先级。这个时候没有任务需要执行,并且需要等待当前task到执行时间,因此很自然就会去调用一个setTimeout来触发这个任务,触发时机就是task到了startTime,因此setTimeout的间隔时间是startTime-currentTime


// 处理下延迟任务:处理已经取消的或者已经到开始时间的任务
// 将符合资格的延迟任务调度到taskQueue
function advanceTimers(currentTime) {
  // Check for tasks that are no longer delayed and add them to the queue.
  // 延迟队列中的第一个 timer
  let timer = peek(timerQueue);
  
  while (timer !== null) {

    if (timer.callback === null) {
      // Timer was cancelled.
      // 如果当前任务被取消了  直接pop
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // Timer fired. Transfer to the task queue.
      // 如果任务的开始时间已经过了 将当前任务移入到task
      pop(timerQueue);
      // 还记得taskqueue的sortIndex是expirationTime吗?
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
    } else {
      // 如果timerQueue中当前第一个task都还没有开始时间,则结束处理timerQueue
      // Remaining timers are pending.
      return;
    }
    // 处理完以后读取下一个timer继续执行操作
    timer = peek(timerQueue);
  }
}

重排完两个queue以后,则需要到处理立即任务阶段,或者轮询自身,即轮询处理延迟任务阶段,直到有立即执行任务的时候,马上进入处理立即执行任务阶段(优先处理立即执行任务阶段)。requestHostCallback就代表到了处理立即执行阶段,标志是isHostCallbackScheduled=true;而requestHostTimeout就是代表到了处理延时任务阶段,标志是isHostTimeoutScheduled=true

// timeout到点时 触发函数
// 参数是触发那一刻的时间

function handleTimeout(currentTime) {
  // 将是否有timeout被调度设置为false
  // 因为到这已经被触发了 timeout已经没有被占用
  isHostTimeoutScheduled = false;
  
  // 在当前时间点下处理延迟任务队里
  advanceTimers(currentTime);
  
  // 如果当前没有回调正在进行
  if (!isHostCallbackScheduled) {
  
    // taskQueue中有任务
    if (peek(taskQueue) !== null) {
    
      // 执行taskQueue中的任务
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
      
    } else {
    
      // 如果没有需要立即执行的任务
      const firstTimer = peek(timerQueue);
      
      // 读取延迟任务 继续下一个轮询
      if (firstTimer !== null) {
      
      // 这里没有加isHostTimeoutScheduled = true
      // 是因为程序以isHostCallbackScheduled优先
      // 当isHostCallbackScheduled为true时可以不管isHostTimeoutScheduled
      // 个人认为不是很严谨
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

image.png

处理立即执行任务阶段

到处理立即执行任务阶段就需要结合时间分片了,当scheduler拿到立即执行任务,则需要通过时间分片机制(messageChannel、setImmediate、setTimeout)来发起任务需要执行的信号,这一段就是requestHostCallback干的事情:


// 调度任务的callback
// 只在处理需要执行队列的时候调用 taskQueue阶段
function requestHostCallback(callback) {

  scheduledHostCallback = callback;
  
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    
    // 在deadline之前调度已经确认要执行任务
    schedulePerformWorkUntilDeadline();
  }
}

// 根据环境判断时间分片使用的机制
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // There's a few reasons for why we prefer setImmediate.
  //
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // (Even though this is a DOM fork of the Scheduler, you could get here
  // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
  // https://github.com/facebook/react/issues/20756
  //
  // But also, it runs earlier which is the semantic we want.
  // If other browsers ever implement it, it's better to use it.
  // Although both of these would be inferior to native scheduling.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

performWorkUntilDeadline起到比较关键的作用,他调用flushWork执行task,当flushWork被阻断后,他负责发起新的一轮schedulePerformWorkUntilDeadline调用,即新的一轮时间分片:


// 在deadline之前执行任务
const performWorkUntilDeadline = () => {

  //scheduledHostCallback 就是 flushWork
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    
    // Keep track of the start time so we can measure how long the main thread
    // has been blocked.
    // 设置当前的任务执行时间
    startTime = currentTime;
    
    // 是否有剩余时间 发起调用默认有时间
    const hasTimeRemaining = true;

    // If a scheduler task throws, exit the current browser task so the
    // error can be observed.
    //
    // Intentionally not using a try-catch, since that makes some debugging
    // techniques harder. Instead, if `scheduledHostCallback` errors, then
    // `hasMoreWork` will remain true, and we'll continue the work loop.
    let hasMoreWork = true;
    try {
    
      // flushWork(是否有剩余时间, 当前时间)
      // scheduledHostCallback 就是flushWork
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      
    } finally {
    
      // hasMorkWork就会workLoop的返回:task中是否还有任务
      if (hasMoreWork) {
        // If there's more work, schedule the next message event at the end
        // of the preceding one.
        
        // 如果有更多的任务需要执行,继续发送
        schedulePerformWorkUntilDeadline();
        
      } else {
      
        // 如果没有任务需要继续执行 则释放相关资源
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
  // Yielding to the browser will give it a chance to paint, so we can
  // reset this.
  needsPaint = false;
};

执行任务到下一次循环

requestHostCallback发出执行任务这个信号,像一个指挥官(像你领导),而真正执行任务的函数是flushWork,是真正干活的(像你一样搬砖)。flushWork需要做事情:结束isHostCallbackScheduled,表明处理立即执行任务阶段已经结束,到了执行任务阶段;结束isHostTimeoutScheduled,因为每一次的workLoop执行完任务后都需要重新重排两个queue中的任务,所以处理延时任务阶段也已经结束;然后打一个标志位isPerformingWork=true,表明已经进入了执行任务阶段,然后通过workLoop执行任务:


// taskQueue中的任务被调度的时候的处理函数
function flushWork(hasTimeRemaining, initialTime) {
  if (enableProfiling) {
    markSchedulerUnsuspended(initialTime);
  }

  // We'll need a host callback the next time work is scheduled.
  // 结束从taskQueue到flushWork阶段
  isHostCallbackScheduled = false;
  
  // 释放timeout 
  // 因为flushWork阶段是去执行wookLoop 而workLoop是一次完整的循环会调用advanceTimer
  if (isHostTimeoutScheduled) {
    // We scheduled a timeout but it's no longer needed. Cancel it.
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }

  // 执行任务阶段开始
  isPerformingWork = true;
  
  // 保存上一次的优先级
  const previousPriorityLevel = currentPriorityLevel;
  try {
    if (enableProfiling) {
      try {
        return workLoop(hasTimeRemaining, initialTime);
      } catch (error) {
        if (currentTask !== null) {
          const currentTime = getCurrentTime();
          markTaskErrored(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        throw error;
      }
    } else {
      // No catch in prod code path.
      return workLoop(hasTimeRemaining, initialTime);
    }
  } finally {
  
    // 执行完以后将当前task置为空 并恢复优先级
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
    
    if (enableProfiling) {
      const currentTime = getCurrentTime();
      markSchedulerSuspended(currentTime);
    }
  }
}

workLoop,就很简单了,轮询当前的TaskQueue,执行当前任务,执行完以后,又重新重排两个queue的任务,然后继续执行。

有两种情况会退出循环:一种是被阻断,这个时候会返回是否还有任务需要执行(hasMoreWork=true),performWorkUntilDeadline拿到返回结果生成下一个时间分片调用;第二种是如果在当前帧里面workLooptask执行完了,就去生成一个处理延迟任务的阶段,来衔接到我们在最开始说的重排任务的下半段。

// 一轮循环
// 总是拿到需要最高优先级执行的任务执行,不断轮询,直到被打断或者taskQueue中没有了任务
// 最高优先级指的是先整理timerQueue  timerQueue是startTime最开始的在前面
// 可能存在一个任务startTime很早 expirationTime也很早,这个任务在timerQueue中是最高优先级 移入到taskQueue中也是最高优先级
function workLoop(hasTimeRemaining, initialTime) {

  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  
  while (
  
    // 当前执行任务不为空 (后面条件不用管 是调试用的)
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      // 如果当前任务还未过期 但是schduler需要阻断(阻断条件:已经没有多余时间 或者shouldYield 跳出本次循环
      // 如果当前时间已经超过了过期时间 就算没有空闲时间也会执行这次任务
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break;
    }
    
    // 如果当前时间已经超过了过期时间 或者 不需阻断
    // 继续执行
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      
      // 是否已经超过过期时间
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      
      if (enableProfiling) {
        markTaskRun(currentTask, currentTime);
      }
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      //
      if (typeof continuationCallback === 'function') {
      
        // 如果执行以后还是一个函数 就不弹出当前task 继续下一个轮询执行这个返回的函数
        currentTask.callback = continuationCallback;
        
        if (enableProfiling) {
          markTaskYield(currentTask, currentTime);
        }
      } else {
        if (enableProfiling) {
          markTaskCompleted(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      
      // 执行完当前task,整理下timerQueue
      advanceTimers(currentTime);
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
  
  // Return whether there's additional work
  if (currentTask !== null) { // 被阻断了后 如果还有任务没执行完 return true
    return true;
  } else {
  
    // 被阻断后 或者是 taskQueue轮询完后 ,去整理timerQueue 返回false
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

总结

至此,从调用开始到执行完成以及重新进入轮询的整个过程都理清了。

含有注释的源码,里面包含小顶堆、优先级等相关的代码: github-awefeng-scheduler

最后,记得关注下公众号~

提供一张完整流程图:

整体流程.png