React源码解析-Schedule

22,514 阅读4分钟

上一篇,我们介绍了commit阶段,其中一阶段触发了异步flushPassiveEffects执行。在react中,schedule是实现异步调度的核心。

react fiber架构,强调的是异步可中断渲染。为了提升react在大规模DOM场景下的性能,需要在浏览器空闲时执行一些任务,并且期望异步执行不影响DOM正常渲染。

requestIdleCallback是浏览器的一个api,在浏览器空闲时的回调函数。但react最终没有使用他。有两点原因:

    1. requestIdleCallback并非所有浏览器都支持,各浏览器兼容性也不达标
    1. requestIdleCallback的FPS只有20,达不到60FPS

requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work。—— from Releasing Suspense

react自己实现了一套requestIdleCallback,schedule不依赖react,是一个单独的模块,也可以给其他框架使用。下面,我们来一探究竟。

一. scheduleCallback

function scheduleCallback(priorityLevel, callback, options) {
    var currentTime = getCurrentTime(); 
    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;
    }
    
    // ...timeout
    
    // ...newTask
    
    // ...requestHostCallback
}

currentTime是当前精确到毫秒级的时间戳,其实在react17中,options都是null,即开始时间是当前时间戳。

timeout

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;

过期时间 = 开始时间 + 超时时间。 超时时间会根据不同的调度优先级动态分配。

其中,调度优先级有以下枚举值:

var IMMEDIATE_PRIORITY_TIMEOUT = -1; // Eventually times out

var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000; // Never times out

双队列

在调度体系中,存在2个队列,一个是任务队列,一个已超时队列。

var newTask = {
  id: taskIdCounter++, 
  callback: callback,
  priorityLevel: priorityLevel,
  startTime: startTime,
  expirationTime: expirationTime,
  sortIndex: -1
};
if (startTime > currentTime) {
 
  newTask.sortIndex = startTime;
  push(timerQueue, newTask);

 
  if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
    // All tasks are delayed, and this is the task with the earliest delay.
    if (isHostTimeoutScheduled) {
      // Cancel an existing timeout.
      cancelHostTimeout();
    } else {
      isHostTimeoutScheduled = true;
    } // Schedule a timeout.


    requestHostTimeout(handleTimeout, startTime - currentTime);
  }
} else {
  newTask.sortIndex = expirationTime;
  push(taskQueue, newTask);

  {
    markTaskStart(newTask, currentTime);
    newTask.isQueued = true;
  } 

  if (!isHostCallbackScheduled && !isPerformingWork) {
    isHostCallbackScheduled = true;
    requestHostCallback(flushWork);
  }
}   

taskQueue是schedule需要调度的任务,是一个小顶堆数组。这里需要注意的是二叉堆排序,react利用堆排序降低算法复杂度。

  function push(heap, node) {
    var index = heap.length;
    heap.push(node);
    siftUp(heap, node, index);
  }
  function peek(heap) {
    var first = heap[0];
    return first === undefined ? null : first;
  }
  function pop(heap) {
    var first = heap[0];

    if (first !== undefined) {
      var last = heap.pop();

      if (last !== first) {
        heap[0] = last;
        siftDown(heap, last, 0);
      }

      return first;
    } else {
      return null;
    }
  }

  function siftUp(heap, node, i) {
    var index = i;

    while (true) {
      var parentIndex = index - 1 >>> 1;
      var parent = heap[parentIndex];

      if (parent !== undefined && compare(parent, node) > 0) {
        // The parent is larger. Swap positions.
        heap[parentIndex] = node;
        heap[index] = parent;
        index = parentIndex;
      } else {
        // The parent is smaller. Exit.
        return;
      }
    }
  }

  function siftDown(heap, node, i) {
    var index = i;
    var length = heap.length;

    while (index < length) {
      var leftIndex = (index + 1) * 2 - 1;
      var left = heap[leftIndex];
      var rightIndex = leftIndex + 1;
      var right = heap[rightIndex]; // If the left or right node is smaller, swap with the smaller of those.

      if (left !== undefined && compare(left, node) < 0) {
        if (right !== undefined && compare(right, left) < 0) {
          heap[index] = right;
          heap[rightIndex] = node;
          index = rightIndex;
        } else {
          heap[index] = left;
          heap[leftIndex] = node;
          index = leftIndex;
        }
      } else if (right !== undefined && compare(right, node) < 0) {
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        // Neither child is smaller. Exit.
        return;
      }
    }
  }

  function compare(a, b) {
    // Compare sort index first, then task id.
    var diff = a.sortIndex - b.sortIndex;
    return diff !== 0 ? diff : a.id - b.id;
  }

timerQueue是超时的任务队列,但这是一个保留功能,并没有哪个地方调用。

二. requestHostCallback

requestHostCallback = function (callback) {
  scheduledHostCallback = callback;

  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    port.postMessage(null);
  }
};

react会判断window是否存在,以及window上是否有MessageChannel,如果有才进入这段代码定义。否则使用setTimeout。

需要注意的是,react使用MessageChannel宏任务做异步处理task,为什么不使用setTimeout或者微任务promise呢?

这里待说到浏览器的事件循环,宏任务是DOM更新之后执行的,当然也并不是每次的事件循环都伴随着DOM更新。 为了不影响DOM的更新,react将耗时任务放到之后,也就是宏任务执行。 但每帧留给react的时间不长(5毫秒),setTimeout这样的api真正执行的时间也不稳定。

宏任务的处理函数将进入performWorkUntilDeadline。

三. performWorkUntilDeadline

var performWorkUntilDeadline = function () {
    
  if (scheduledHostCallback !== null) {

    var currentTime = getCurrentTime(); 


    deadline = currentTime + yieldInterval;
    var hasTimeRemaining = true;

    try {
      var hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);

      if (!hasMoreWork) {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      } else {

        port.postMessage(null);
      }
    } catch (error) {

      port.postMessage(null);
      throw error;
    }
    } else {
    isMessageLoopRunning = false;
  } 
};

yieldInterval初始值为5毫秒,唯一能改变yieldInterval值的是forceFrameRate函数,但这也只是保留函数,全局并没有任何地方调用。这里每次宏任务执行时,定义了这个宏任务的deadline时间,其实了解requestIdeCallback api的同学会发现,react其实就是在模仿他的实现。ric里面也是可以拿到deadline的,有个注意点是,在浏览器长时间没有任务时,原生requestIdeCallback的deadline能延长到50毫秒。当然,这一切都是变化的。

hasMoreWork意味着,如果taskQueue还有任务,将继续发起宏任务调度。这也是中断再次恢复的关键。

四. flushWork

function flushWork(hasTimeRemaining, initialTime) {
  // ...

    isHostCallbackScheduled = false;


    if (isHostTimeoutScheduled) {
      
      isHostTimeoutScheduled = false;
      cancelHostTimeout();
    }

    isPerformingWork = true;
    var previousPriorityLevel = currentPriorityLevel;

    try {
    
      if (enableProfiling) {
        try {
          return workLoop(hasTimeRemaining, initialTime);
        } catch (error) {
          if (currentTask !== null) {
            var currentTime = getCurrentTime();
            markTaskErrored(currentTask, currentTime);
            currentTask.isQueued = false;
          }

          throw error;
        }
      } else {
        // No catch in prod code path.
        return workLoop(hasTimeRemaining, initialTime);
      }
    } finally {
      currentTask = null;
      currentPriorityLevel = previousPriorityLevel;
      isPerformingWork = false;

      {
        var _currentTime = getCurrentTime();

        markSchedulerSuspended(_currentTime);
      }
    }
  }

isHostTimeoutScheduled是存在超时任务调度,但任务构建阶段,delay是不存在的,17版本未调用delay。即不存在timeout task。不过如果存在超时的时候,在task设计中,已超时5s,那么这样的任务,react认为需要cancel掉。

workLoop

function workLoop(hasTimeRemaining, initialTime) {
  var currentTime = initialTime;
  currentTask = peek(taskQueue);
  
  while (currentTask !== null && !(enableSchedulerDebugging )) {
     if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) {
        break;
      }
      
      var callback = currentTask.callback;
      // ...
      var continuationCallback = callback(didUserCallbackTimeout);
        currentTime = getCurrentTime();

        if (typeof continuationCallback === 'function') {
          currentTask.callback = continuationCallback;
          // ...
        } else {
          // ...

          if (currentTask === peek(taskQueue)) {
            pop(taskQueue);
          }
        }
        // ...
  }
  
  if (currentTask !== null) {
      return true;
    } else {
      var firstTimer = peek(timerQueue);

      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }

      return false;
    }
}

遍历taskQueue队列,如果存在:task未超时,但没有了时间切片或超过宏任务的deadline,那么直接终止循环。待下个事件循环中再执行。

其实,所谓的时间切片,就是在浏览器的每帧渲染16.6ms中(正常浏览器1秒60帧)中,react定义了5ms的时间片。即在每帧的5ms内,浏览器需要执行react相关的js代码,当超过5ms时,react终止一切任务,将执行权交还给浏览器执行DOM渲染或事件响应。 保证浏览器能够1秒刷新60下,而不被js占用导致帧率下降页面卡顿。

如果被中断的task队列,将返回isMoreTask true。将进入下一轮事件循环。

正如以上所述,schedule调度最高优先级是ImmediatePriority,其实他是个同步任务,不参与宏任务队列。直接执行了,比如我们的commit阶段,直接最高优先级插入DOM。

如果taskQueue结束后,依旧存在很久的超时任务,将使用setTimeout来执行,回调的函数将执行handleTimeout函数,最终还是会进入taskQueue的flushWork阶段,在进入workLoop执行。

五. 总结

时间切片原理

消费任务队列的过程中, 可以消费1~n个 task, 甚至清空整个 queue. 但是在每一次具体执行task.callback之前都要进行超时检测, 如果超时可以立即退出循环并等待下一次调用.

可中断渲染原理

在时间切片的基础之上, 如果单个task.callback执行时间就很长, 就需要task.callback自己能够检测是否超时, 所以在 fiber 树构造过程中, 每构造完成一个单元, 都会检测一次超时, 如遇超时就退出fiber树构造循环, 并返回一个新的回调函数(就是此处的continuationCallback)并等待下一次回调继续未完成的fiber树构造。当然最后的effect task(如useEffect)也是如此。