【React】那么多解析Scheduler的文章没让你满意的?试试这篇吧!

1,424 阅读24分钟

本文内容(你能收获什么)

  • 为什么需要Scheduler
  • Scheduler原理
  • 具体实现代码解读(基于17.0.2版本)

前置知识(需要了解的内容)

  • 浏览器事件循环
  • React时间分片
  • React Fiber架构
  • 浏览器帧 为了保证文章精简,言简意赅,假设读者了解以上知识点,这里不赘述,需自行百度。

1. 为什么需要Scheduler

起因是React旧架构渲染组件效率问题,当组件过多时,渲染时间过长会阻塞线程(js单线程问题),浏览器无法进行页面的绘制,导致页面看起来会有卡顿现象,影响用户体验。

如何避免线程阻塞?js不存在多线程,在单线程下既要执行React内部的流程,又要让浏览器及时的渲染页面(保证稳定且不是很低的帧频才不会看起来卡顿)。React提出时间分片的概念,将一个漫长的执行过程分割成多个耗时更短的任务。好让浏览器有休息的时间去执行用户事件的处理和页面的绘制等其他任务。

如何分割任务?这就得益于ReactFiber架构,采用链表的结构将完整的任务分成多个任务节点并串联起来,具体实现本文不多讨论。

如何调度任务?也就是如何安排任务的执行顺序?这里需要读者了解一定的事件循环知识。首先排除microtask的方案,我们知道微任务队列是在浏览器执行完宏任务队列之后,页面重绘之前执行,也就是说是在同一帧内执行完毕,这样自然不能解决我们的问题(让浏览器有空去绘制页面)。要保证浏览器有时间去执行其他任务,就要避免单个Task或者说宏任务执行时间过长,上述的时间分片已经解决这个问题,接下来就需要将任务穿插到浏览器的事件循环队列中。这里涉及到浏览器的一些API

  1. setTimeout(callback, delay)
  2. requestAnimationFrame(callback)
  3. requestIdleCallback(callback)
  4. MessageChannel

1. setTimeout

这个可能是我们最常用的创建新Task的API,理想情况下我们可以通过setTimeout将多个任务单元放入事件循环中,每一帧浏览器执行完任务队列,检查是否需要重绘,需要的话重绘页面,然后继续处理接下来的任务队列。

但是,为什么React没有采用setTimeout

首先有一个setTimeout最短执行间隔的问题,这个问题也有其他相关文章讨论。结论既是:setTimeout最短执行间隔为1ms左右,也就是说你执行以下代码:

console.log(0);
setTimeout(() => console.log(1), 0);

看起来我们传的第二个参数delay0,应该是执行完当前同步代码后立即执行回调输出1。但是,浏览器在1ms左右后才会输出1。以下截图为证:

image.png 如图所示:在1处执行的setTimeout,在2处触发Timer Fired执行回调。之间相差1195.3ms - 1194.3ms = 1ms。 或者可以采用以下验证方法:

setTimeout(() => console.log(1), 1);
setTimeout(() => console.log(0), 0);
setTimeout(() => console.log(2), 2);

我们安排三个宏任务,理想情况下以delay参数为准会按以下顺序执行:

// 1. console.log(0)
// 2. console.log(1)
// 3. console.log(2)
// 最终结果:0, 1, 2

但实际运行如图:

image.png

可以看出即使delay0,但实际也是在delay1的代码之后执行。

实际测试过程中,也有可能输出 1, 2, 0。但不会出现 0, 1, 2。所以说setTimeout最短间隔1ms左右。delay参数为0时的具体处理逻辑这里不再深究。

此外,当setTimeout嵌套使用时,其最小间隔会被放大,当嵌套超过4层,第5层的代码执行最小间隔就是4ms左右。执行以下代码:

setTimeout(function level1() {
    setTimeout(function level2() {
        setTimeout(function level3() {
            setTimeout(function level4() {
                setTimeout(function level5() {
                    setTimeout(function level6() {
                        console.log(0);
                    }, 0)
                }, 0)
            }, 0)
        }, 0)
    }, 0)
}, 0)

结果如下图所示:如下图所示:

image.png 我们一共嵌套6层setTimeout,时间轴可以看出来,前4层执行间隔为上述结论中最小间隔1ms左右,图中可能达到2ms,但是第4层到第5层开始,回调执行之间的间隔达到4ms左右。

我们知道显示器一般的刷新率为60fps,如果浏览器按照这个刷新率计算,则一帧的时间为:1000ms / 60 = 16.666ms也就是16ms左右,意味着要达到60帧画面,浏览器需要在16ms内完成当前帧的工作,如果我们使用setTimeout,每个任务之间都至少有1ms的时间被浪费掉,如果回调嵌套4次以上,则至少浪费4ms,四分之一的时间被浪费掉,这个时间成本太高了,效率会被大打折扣。这也是setTimeout不被选择使用的原因。

至于为什么setTimeout这样设计,可以说是历史包袱,这里不再细讲,可以参考文章:juejin.cn/post/684668…

2. requestAnimationFrame

rAF严格来讲并不是添加宏任务Task到任务队列,浏览器事件循环除了执行宏任务队列,微任务队列,回流重绘外,每次重绘页面前会执行rAF安排的回调队列。

那么,React为什么不采用rAF来执行任务?

首先,rAF有一个问题,不能够在同一个任务代码中添加回调到下一帧,也就意味着,在当前代码执行过程中,多次执行rAF添加的回调函数都是在当前帧重绘前执行。(一些文章说可能会导致rAF回调在当前帧执行两次表达的就是这个意思),同样我们看图说话:

requestAnimationFrame(function callback1(){});
requestAnimationFrame(function callback2(){});

image.png

可以看到,我们使用rAF注册两个callback函数,但是两个函数在同一个Task中,浏览器重绘前执行,这样的话就像微任务一样,没办法将多个任务调度到不同帧执行,同样会阻塞浏览器重绘。

那么有没有办法将任务分开调度到不同帧?

方法是有的,在第一个rAF的回调中再次执行rAF,此时注册的回调函数就会在下一帧执行。如下图所示:

requestAnimationFrame(function callback1(){
    requestAnimationFrame(function callback2(){
        requestAnimationFrame(function callback3(){});
    });
});

image.png 我们嵌套三层rAF,这样就实现了将任务调度到不同帧,同时问题也显现出来,rAF的调用时机并不稳定,前两次回调之间间隔只有2ms左右,第二三次回调之间间隔为16ms一帧的时间左右。

此外还有兼容性问题,chromerAF调用时机不稳定,firefox的调用时间间隔则是固定16ms左右。如下图:

image.png 每次回调打印出来的时间之间间隔固定16ms左右。

总结:rAF不是创建宏任务,这一点与setTimeout不同,多次执行添加的回调会在同一个Task执行,这样严格来讲并没有将任务分开调度。要想实现分开调度则需要嵌套执行,调度控制逻辑复杂度大大提升。同样还面临执行间隔过长,每帧只能执行一个任务的问题。

3. requestIdleCallback

rAF不同的是,rIC添加的回调是创建的宏任务Task,会添加到浏览器任务队列,在浏览器空闲时执行。如图:两个rIC回调在两个Task中执行。看起来似乎满足我们的需求。

image.png

但是,rIC在嵌套使用时,两次回调之间会间隔50多mschrome下)

requestIdleCallback(() => {
    console.log(performance.now());
    requestIdleCallback(() => console.log(performance.now()));
});

image.png

此外,rIC存在兼容问题,IEsafari完全不兼容。

image.png

4. MessageChannel

MessageChannel接口允许我们创建一个新的消息通道,并通过它的两个MessagePort进行通信。创建一个MessageChannel时会固定提供两个MessagePort分别为MessageChannel.port1MessageChannel.port2。两个端口可以互相通信,port1.postMessage(msg)时,port2.onmessage回调会处理该消息,同样的,port2发送的消息由port1.onmessage回调进行处理。

最关键的,MessagePort会维护一个消息队列,作为事件循环的事件源之一,也就是会产生Task宏任务。

let msg = new MessageChannel();
msg.port1.onmessage = function handler(){};
msg.port2.postMessage(1);
msg.port2.postMessage(2);
msg.port2.postMessage(3);

image.png

如图所示:后续三个Task即为port2.postMessage()产生的宏任务。

那么,在嵌套使用的情况下表现又如何呢?

let msg = new MessageChannel();
msg.port1.onmessage = function handler({data}){
    if(data < 5) {
        // 在onmessage中再次postMessage
        msg.port2.postMessage(data + 1);
    }
};
msg.port2.postMessage(0);

image.png

可以看到,后边五个Task都是由MessagePort产生的宏任务,第一个处理postMessage(0)的回调,后续都是在onmessage回调中再次触发的Task。可见并不存在上述三种方式存在的延迟或者其他问题,可以按照我们期望的调度执行。

总结

基于以上四种API的讨论,我们得出结论:MessageChannel时最佳的任务调度API。这也是React为什么使用MessageChannel的原因。

2. React.Scheduler源码解析

Scheduler是独立于React的底层任务调度库,主要依赖的浏览器API就是MessageChannel

代码结构为:

| forks
├╌ SchedulerFeatureFlags.www.js
├╌ SchedulerHostConfig.default.js             // 参考default配置内容
├╌ SchedulerHostConfig.mock.js
| Scheduler.js                                  // 入口逻辑
| SchedulerFeatureFlags.js                      // 全局标识变量
| SchedulerHostConfig.js                        // 参考 forks -> SchedulerFeatureFlags.default
| SchedulerMinHeap.js                           // 最小堆实现
| SchedulerPostTask.js                          // 另外单独打包
| SchedulerPriorities.js                        // 优先级变量
| SchedulerProfiling.js                         // 日志
| Tracing.js                                    // 测试或开发工具用
| TracingSubscriptions.js                       // 测试或开发工具用

本文主要讨论Scheduler的调度逻辑,不考虑日志和测试代码,也就是只讨论SchedulerHostConfig.default.jsScheduler.jsSchedulerMinHeap.js,另外的优先级变量或者全局标识变量文件只是变量声明,不包含逻辑代码。

1. 数据结构:SchedulerMinHeap

Scheduler中会根据任务优先级进行排序,始终保持执行最高优先级任务。这里就涉及到任务队列的数据结构问题,Scheduler并没有直接采用数组模拟队列的形式,而是通过数组实现了一个最小堆。

比如以下数组[10, 16, 22, 22, 32, 27]表示为堆:

image.png

image.png 先看下代码整体框架:

// 可以看出,其实是用数组实现的堆
type Heap = Array<Node>;
type Node = {|
  id: number,
  sortIndex: number,
|};

export function push(heap: Heap, node: Node): void {}

export function peek(heap: Heap): Node | null {}

export function pop(heap: Heap): Node | null {}

function siftUp(heap, node, i) {}

function siftDown(heap, node, i) {}

function compare(a, b) {}

对外只提供peekpoppush三个API,内部函数有siftUpsiftDowncompare

compare

我们从最简单的compare开始:

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

正如代码注释所说,首先比较任务的sortIndex字段,然后比较ididScheduler.js中是自增的,也就是优先按照sortIndex从小到大的顺序,其次按照任务生成顺序id执行任务。

siftUp

siftUp的逻辑是比较节点和父节点的优先级,如果当前节点优先级更高(sortIndex更小),则交换当前节点和父节点的位置,直至父节点比当前节点优先或者已经到达堆顶。以此保证堆为最小堆。

// 传入参数为:堆数组,当前节点,当前节点在堆数组中的索引。
function siftUp(heap, node, i) {
  let index = i;
  while (true) {
    // 计算出父元素
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    if (parent !== undefined && compare(parent, node) > 0) {
      // 如果父元素优先级低于子节点,交换位置,并重置index,进入下一次循环
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // 父节点不存在(说明当前节点已经是堆顶节点)或者父节点优先级更高时退出
      return;
    }
  }
}

可以将每个节点sortIndex想象为密度,密度越小的应该在越上边,siftUp就是将插入的节点上浮到合适的位置。

siftDown

siftDownsiftUp逻辑相反,是将密度大的节点向下沉,不同之处为:向上的过程只需要沿着父节点路径,和父节点比较即可,下沉时则有左子节点和右子节点两条路径,此时需要比较左右子节点的值哪个更小,与最小的子节点进行交换位置。

function siftDown(heap, node, i) {
  let index = i;
  const length = heap.length;
  while (index < length) {
    // 计算左右节点
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];
    
    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 {
      // 当前节点已经小于两个子节点,不需要交换,直接退出
      return;
    }
  }
}

push

push即添加任务节点,新添加的任务节点会在二叉树堆底,需要上浮siftUp操作将其放在合适的位置。

export function push(heap: Heap, node: Node): void {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}

例如有当前堆:[10, 16, 22, 22, 32, 27]

image.png

在以上堆中插入元素9的过程如下:

  1. 将元素9放在末尾。 image.png

  2. siftUp操作,9小于父节点22,交换位置。 image.png

  3. 9小于父节点10,继续交换位置。 image.png

  4. 父节点不存在,退出。

pop

pop将当前堆顶的任务节点取出,并重新整理堆顺序,重新整理的过程是将堆底也就是数组末尾元素放入堆顶,然后下沉siftDown到适当位置。

export function pop(heap: Heap): Node | null {
  // 取出堆顶元素
  const first = heap[0];
  if (first !== undefined) {
    // 取出末尾元素放到堆顶并下沉
    const last = heap.pop();
    if (last !== first) {
      heap[0] = last;
      siftDown(heap, last, 0);
    }
    return first;
  } else {
    return null;
  }
}

对上图所示堆进行pop操作过程如下:

  1. 取出顶部元素9image.png

  2. 将末尾元素22放置在堆顶。

image.png

  1. siftDown操作,将当前堆顶元素22和左子节点16,右子节点10进行比较,并于最小的子节点(右子节点10)进行交换。

image.png

  1. 当前节点22大于左子节点27,所以不需要下一步操作,退出。

peek

peek即获取堆顶元素,也就是当前最优先的任务,并不对堆进行操作。

export function peek(heap: Heap): Node | null {
  const first = heap[0];
  return first === undefined ? null : first;
}

总结

Scheduler中通过数组实现最小堆,来保证每次从任务堆中取出(pop)的任务总是优先级最高的。优先级则是根据sortIndex属性来标明,sortIndex越小则说明任务优先级越高。

2. SchedulerHostConfig

正如文件名HostConfig,这里会根据宿主情况做一些兼容配置。(我们这里只关注在浏览器端情况,其他情况代码也并不复杂,可自行阅读)

代码主要结构:

export let requestHostCallback;
export let cancelHostCallback;
export let requestHostTimeout;
export let cancelHostTimeout;
export let shouldYieldToHost;
export let requestPaint;
export let getCurrentTime;
export let forceFrameRate;

// step1
if (hasPerformanceNow) {
  // 配置变量值
} else {
  // 配置变量值
}

// step2
if (
  typeof window === 'undefined' ||
  typeof MessageChannel !== 'function'
) {
  // 配置变量值
} else {
  // 配置变量值
}

先看下对外export,主要是提供操作接口,下边两个if-else分支是根据宿主情况做兼容配置操作。整个文件就是配置export的这几个API函数。

第一个分支step1是配置getCurrentTime,第二个分支step2是判定宿主是否提供MessageChannel接口来配置其他函数。

1. getCurrentTime

先看下getCurrentTime的配置,代码在第一个分支step1处。

const hasPerformanceNow =
  typeof performance === 'object' && typeof performance.now === 'function';

if (hasPerformanceNow) {
  const localPerformance = performance;
  getCurrentTime = () => localPerformance.now();
} else {
  const localDate = Date;
  const initialTime = localDate.now();
  getCurrentTime = () => localDate.now() - initialTime;
}

逻辑是根据是否存在performance.now来配置不同的函数。 简单理解的话就是等同于performance.now或者是Date.now() - initialTime,如下:

getCurrentTime = performance.now || (() => Date.now() - initialTime);

减去initialTime得出的即是程序运行时间

2. requestHostCallback

第二个分支step2我们只考虑else(也就是浏览器提供MessageChannel)的情况(if分支中的情况更简单,可以自行阅读)。

  // 保存原生API,避免被其他库覆盖掉
  const setTimeout = window.setTimeout;
  const clearTimeout = window.clearTimeout;

  if (typeof console !== 'undefined') {
    // 如果浏览器不支持rAF,则console警告(目前已经不依赖rAF,只是为了保留未来可能提供选择)
  }

  let isMessageLoopRunning = false;
  let scheduledHostCallback = null;
  let taskTimeoutID = -1;
  let yieldInterval = 5;
  let deadline = 0;
  const maxYieldInterval = 300;
  let needsPaint = false;

  if (
    enableIsInputPending &&
    navigator !== undefined &&
    navigator.scheduling !== undefined &&
    navigator.scheduling.isInputPending !== undefined
  ) {
    // step3
    const scheduling = navigator.scheduling;
    shouldYieldToHost = function() {/*...*/};
    requestPaint = function() {/*...*/};
  } else {
    // step4
    shouldYieldToHost = function() {/*...*/};
    requestPaint = function() {};
  }

  forceFrameRate = function(fps) {/*...*/};

  const performWorkUntilDeadline = () => {/*...*/};

  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;

  requestHostCallback = function(callback) {/*...*/};
  cancelHostCallback = function() {/*...*/};
  requestHostTimeout = function(callback, ms) {/*...*/};
  cancelHostTimeout = function() {/*...*/};

以上代码除去了函数体和无关紧要的代码,只看整体结构。我们可以看到在这里创建了一个MessageChannel,然后用port2作为发送端口,port1作为接收端口,每当接收到消息则执行performWorkUntilDeadline,结合以上内容我们知道,每次调度新的宏任务时是执行performWorkUntilDeadline

那么performWorkUntilDeadline中执行了什么操作?

  const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // 标识变量,目前没有用到
      deadline = currentTime + yieldInterval;
      const hasTimeRemaining = true;
      try {
        // 执行回调函数,回调函数返回结果决定是否还有任务
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        if (!hasMoreWork) {
          // 没有多余任务,删除回调,置标记为false
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // 仍有任务时再次触发新的Task处理
          port.postMessage(null);
        }
      } catch (error) {
        port.postMessage(null);
        throw error;
      }
    } else {
      isMessageLoopRunning = false;
    }
    // 表示变量,目前没有用到
    needsPaint = false;
  };

以上代码可以看出回调函数中实际执行的是scheduledHostCallback,根据函数执行结果判定是否仍有未完成任务,或者说当前任务是否未完成。这里需要注意的是,每次的回调函数只有一个,即scheduledHostCallback。具体执行内容需要用户自行在回调函数中处理,这和我们使用setTimeout(fn, timeout)每次指定不同的回调函数的思路不太相同。

标识变量isMessageLoopRunning表示是否正在执行任务,或者说是否处在任务循环状态中。

那么scheduledHostCallback由哪个接口指定?

答案是:requestHostCallback

requestHostCallback = function(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    port.postMessage(null);
  }
};

代码逻辑很简单,指定scheduledHostCallback函数,如果当前没有处在任务循环中则立即执行(通过postMessage触发上文说的performWorkUntilDeadline)。如果当前已经处在任务循环中,自然会根据执行结果判定是否仍有任务需要执行。

3. cancelHostCallback

  cancelHostCallback = function() {
    scheduledHostCallback = null;
  };

cancelHostCallback很简单,将scheduledHostCallback置空即可,在performWorkUntilDeadline函数中有判断scheduledHostCallback是否为空,空的话则退出。

4. requestHostTimeout

  requestHostTimeout = function(callback, ms) {
    taskTimeoutID = setTimeout(() => {
      callback(getCurrentTime());
    }, ms);
  };

setTimeout在以上部分引用了window.setTimeout,也就是对原生的引用封装,不同之处在于:这里使用taskTimeoutID全局变量记录,而不是将id返回,所以同时只会保留一个taskTimeoutId,如果多次执行requestHostTimeout则会覆盖taskTimeoutId,没有办法clear掉之前的。和原生的使用有所区别。(不太懂这样设计的目的)

此外,执行回调callback会传入当前时间作为参数。

5. cancelHostTimeout

  cancelHostTimeout = function() {
    clearTimeout(taskTimeoutID);
    taskTimeoutID = -1;
  };

这里直接调用系统原生API清除定时器。

6. forceFrameRate

  forceFrameRate = function(fps) {
    if (fps < 0 || fps > 125) {
      // Using console['error'] to evade Babel and ESLint
      console['error'](
        'forceFrameRate takes a positive int between 0 and 125, ' +
          'forcing frame rates higher than 125 fps is not supported',
      );
      return;
    }
    if (fps > 0) {
      yieldInterval = Math.floor(1000 / fps);
    } else {
      // reset the framerate
      yieldInterval = 5;
    }
  };

代码逻辑很简单,设置fps,然后根据fps计算出yieldInterval,也就是每帧的时间,正如上文讲到的,每次执行任务不能超出yieldInterval,否则就会降低帧率出现卡顿现象。当然这里设置fps需要考虑浏览器绘制页面耗时,这里计算的yieldInterval是不包含绘制页面时间的。比如你要保证60fps,每帧时间是16.666ms那么这里设置的yieldInterval一定要小于16.666,预留出页面渲染的时间,也就是参数fps要大于60

7. shouldYieldToHost

这个是最主要的API之一,我们看到目前的调度机制是安排一个回调函数,在回调函数中自行处理任务,那么如何判定执行到什么时间呢?这个并没有由Scheduler调度器自身来判定,需要用户自行判定然后分割任务。

在上文step2的代码中有if-else分支,根据是否使用isInputPending来配置不同的shouldYieldToHostrequestPaintenableIsInputPending默认为false也就是不启用该功能)。

我们先看下else分支(step4)中:

shouldYieldToHost = function() {
  return getCurrentTime() >= deadline;
};

很简单,如果当前时间超过deadline就表示需要将返还给宿主执行其他任务。

deadline在哪里设置的呢?

上文performWorkUntilDeadline函数中,在执行实际的回调函数scheduledHostCallback前会先设置deadline

deadline = currentTime + yieldInterval;

也就是当前时间加上每帧时间(yieldIntervla默认为5,可以通过forceFrameRate更改)

简单说就是任务执行超过yieldInterval的时间shouldYieldToHost就会返回true,也就意味着你需要暂停当前任务,将控制权返回给宿主。

调度器在任务开始执行前设置deadline,用户在任务执行过程中通过调用shouldYieldToHost来判定是否需要暂停任务

再看下if分支(step3)中(这里的略复杂)

shouldYieldToHost = function() {
  const currentTime = getCurrentTime();
  if (currentTime >= deadline) {
    if (needsPaint || scheduling.isInputPending()) {
      return true;
    }
    return currentTime >= maxYieldInterval;
  } else {
    return false;
  }
};

这里会根据标识符needsPaint和变量scheduling.isInputPending()来计算。如果当前时间没超出deadline则直接返回false。超出deadline的情况下,如果需要重绘或者有输入处理,则返回true,否则返回false直到当前时间超过maxYieldInterval界限值(默认300

scheduling.isInputPending()是由浏览器提供的APIneedsPaint则是Scheduler提供的API设置。

8. requestPaint

step4requestPaint被设置为空函数,因为此时的shouldYieldToHost并不依赖needsPaint

requestPaint = function() {};

step3requestPaint直接设置needsPainttrue

requestPaint = function() {
  needsPaint = true;
};

总结

SchedulerHostConfig主要是根据宿主情况配置几项API函数,全局搜索代码我们发现,React中并没有使用到forceFrameRate,也没有更改enableIsInputPending,也就是说shouldYieldToHost总是会根据yieldInterval(默认为5)来计算。requestPaint也会被设置为空函数。所以之后可以不用考虑这些情况。

3. Scheduler

Scheduler作为入口文件,是对优先级,堆操作,HostConfig的进一步封装。我们先看下该文件对外提供的操作:

export {
  ImmediatePriority as unstable_ImmediatePriority,
  UserBlockingPriority as unstable_UserBlockingPriority,
  NormalPriority as unstable_NormalPriority,
  IdlePriority as unstable_IdlePriority,
  LowPriority as unstable_LowPriority,
  unstable_runWithPriority,
  unstable_next, // react库未使用
  unstable_scheduleCallback,
  unstable_cancelCallback,
  unstable_wrapCallback, // react库未使用
  unstable_getCurrentPriorityLevel,
  shouldYieldToHost as unstable_shouldYield,
  unstable_requestPaint,
  unstable_continueExecution, // react库未使用
  unstable_pauseExecution, // react库未使用
  unstable_getFirstCallbackNode, // react库未使用
  getCurrentTime as unstable_now,
  forceFrameRate as unstable_forceFrameRate, // react库未使用
};

react库未使用是本人在React源码中全局搜索查看没有使用到的函数,这里不做探讨。不考虑react库未使用的函数,其中前五项是导出的优先级变量,后三项是HostConfig中的接口直接对外导出(其中unstable_requestPaintReact中并没有使用到,可以忽略)。所以主要内容就是其中四个API函数:

  1. unstable_runWithPriority
  2. unstable_scheduleCallback
  3. unstable_cancelCallback
  4. unstable_getCurrentPriorityLevel

1. unstable_runWithPriority

先看下比较简单的unstable_runWithPriority

function unstable_runWithPriority(priorityLevel, eventHandler) {
  switch (priorityLevel) {
    case ImmediatePriority:
    case UserBlockingPriority:
    case NormalPriority:
    case LowPriority:
    case IdlePriority:
      break;
    default:
      priorityLevel = NormalPriority;
  }

  // 记录当前优先级
  var previousPriorityLevel = currentPriorityLevel;
  currentPriorityLevel = priorityLevel;

  try {
    return eventHandler();
  } finally {
    currentPriorityLevel = previousPriorityLevel;
  }
}

逻辑很简单,首先记录当前的优先级currentPriorityLevel,然后更改currentPriorityLevel,执行函数,然后重新恢复设置为之前的优先级。

这里的currentPriorityLevel作为全局变量保存当前任务优先级,在任务执行过程中可以通过unstable_getCurrentPriorityLevel获取到。

2. unstable_getCurrentPriorityLevel

function unstable_getCurrentPriorityLevel() {
  return currentPriorityLevel;
}

这两个函数搭配使用,首先runWithPriority,然后在run的过程中,也就是在eventHandler用户可以根据getCurrentPriorityLevel获取到当前优先级,做进一步处理。

3. unstable_scheduleCallback

最主要的一个API就是unstable_scheduleCallback。在分析源码之前,先来看一下Scheduler中任务Task的数据结构:

var newTask = {
  id: taskIdCounter++,
  callback,
  priorityLevel,
  startTime,
  expirationTime,
  sortIndex: -1,
};

其中idsortIndex在上文讲解最小堆时有提到过,是对任务进行排序的字段。callback即为任务回调函数。startTime是任务开始时间。expirationTime是任务过期时间。priorityLevel是任务的优先级,这里的优先级和在最小堆中的优先级顺序不是一个概念,Scheduler中将任务分为了6个优先级,不同优先级的timeout不同,从而计算出的任务过期时间expirationTime不同。这里的sortIndex只是初始值,后续会根据startTimeexpirationTime重新计算。

任务分为延时任务和同步任务,同步任务的startTime就是currentTime,延时任务的startTimecurrentTime + delaydelay可以在options中设置。延时任务和同步任务放在两个不同的队列(timerQueuetaskQueue),每次循环会根据是否到达延时任务的startTime来决定是否将延时任务转为同步任务,放在同步任务队列中。

接下来我们开始分析代码:


function unstable_scheduleCallback(priorityLevel, callback, options) {
  // step1
  // 计算一些变量值

  // step2
  // 创建任务
  var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  
  // step3
  // 计算优先级,添加到对应队列(延时任务队列或同步任务队列)
  
  return newTask;
}

先将代码分为3个步骤,看下step1

  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;
  }

  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;

step1就是根据参数来计算任务的startTime和任务的expirationTime

startTime取决于任务是否为延时任务,是的话即为当前时间加上延时,否则就等同于当前时间。

expirationTime等于startTime加上timeouttimeout由优先级(6个优先级)决定,优先级越高,timeout取值越小,expirationTime也就越短。

step2是创建任务对象。

接下来看step3

  if (startTime > currentTime) {
    // 延时任务
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      if (isHostTimeoutScheduled) {
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // 同步任务
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    // 记录日志,本文不做讨论
    if (enableProfiling) {
      // ...
    }
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }

这里根据startTime > currentTime结果做了不同处理,根据step1我们知道,只有延时任务才会满足条件,所以if中是处理延时任务,else中处理同步任务。

延时任务处理。首先sortIndex = startTime,也就是在任务队列中的优先级取决于startTime,开始时间越近越优先。然后放入延时任务队列。如果当前同步任务队列为空,并且新任务是延时任务队列中最优先的,则安排一个定时器回调,这里避免多次调用会先取消。isHostTimeoutScheduled来记录是否已经安排且未处理定时器回调。当delay时间后开始执行handleTimeout函数处理延时任务。

同步任务处理。同步任务在任务队列中的优先级取决于expiration过期时间,过期时间越近则越优先。放入队列后如果没有安排任务并且没有处在任务执行阶段,则安排任务。isHostCallbackScheduled表示是否存在未开始执行的任务,isPerformingWork表示是否处在任务执行阶段。上文讨论过Scheduler只支持一个回调函数,这里就是flushWork

1. handleTimeout

接下来我们先看下延时任务的处理handleTimeout

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  // 这里将已经到时间的延时任务转为同步任务加入队列,下文讲解
  advanceTimers(currentTime);

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) {
      // 这里已经将延时任务放入同步任务队列,如果存在任务则处理
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    } else {
      // 可能延时任务的回调被取消掉,这里没有任务,则安排回调处理下一项延时任务
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

handleTimeout其实是将到时间的延时任务转为同步任务加入taskQueue,然后再通过requestHostCallback执行任务。advanceTimers就是将延时任务转为同步任务,我们看一下具体逻辑:

2. advanceTimers

function advanceTimers(currentTime) {
  // 拿到堆顶元素,也就是开始时间最近的延时任务
  let timer = peek(timerQueue);
  while (timer !== null) {
    // 循环处理
    if (timer.callback === null) {
      // 延时任务没有回调,是被取消掉的,这里只是移除掉,不需要加入taskQueue
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // 到达startTime,需要将任务从timerQueue转移到taskQueue,同时更新sortIndex(之前是等于startTime,现在放入taskQueue需要根据expirationTime排序)
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
      // 记录,这里不做深究
      if (enableProfiling) {
        markTaskStart(timer, currentTime);
        timer.isQueued = true;
      }
    } else {
      // 如果当前任务没有到时间,则跳出循环(堆是按照startTime排序,当前任务没到时间,之后的任务也不会到时间)
      return;
    }
    timer = peek(timerQueue);
  }
}

advanceTimers就是从堆顶遍历堆,只要任务执行时间已到,就将任务转移到taskQueue。一般情况下handleTimeout执行时,堆顶的延时任务一定会到时间(在unstable_scheduleCallback添加任务时timeout是等于任务的delay的),也就是taskQueue一般情况下不会为空,但是如果延时任务的callback被取消掉,则会进入else分支,此时如果还有延时任务则会再次requestHostTimeout

整体代码中isHostTimeoutScheduled并没有严格和requestHostTimeout保持一致,比如上文说的else分支中重新requestHostTimeout,但是isHostTimeoutScheduled已经置为false且没有重新置true,(在workloop中也存在这一情况)此时重新unstable_scheduleCallback一个优先级较高的延时任务,会再次调用requestHostTimeout同时产生两个timeout回调。回调中只是将过期任务转移到taskQueue,即使多次执行也不会重复或者遗漏,并且isHostCallbackScheduled是严格保持一致的,不会多次调用requestHostCallback,所以不会出现多个任务回调。所以这里严格来讲不会影响代码运行,只是可能影响理解,个人觉得可以和isHostCallbackScheduled一样保持完全一致或者不需要这个标识符也没关系。

3. flushWork

最后的任务处理逻辑都集中在了flushWork(以下代码删除了profiling相关代码):

function flushWork(hasTimeRemaining, initialTime) {
  isHostCallbackScheduled = false;
  if (isHostTimeoutScheduled) {
    // 在任务队列处理后如果存在延时任务会重新发起requestHostTimeout,所以这里可以取消掉
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }

  // 标识变量,表示正处于任务执行过程中
  isPerformingWork = true;
  // 记录优先级
  const previousPriorityLevel = currentPriorityLevel;
  try {
    // 参数由底层传入,hasTimeRemaining其实恒等于true
    return workLoop(hasTimeRemaining, initialTime);
  } finally {
    currentTask = null;
    // 恢复优先级
    currentPriorityLevel = previousPriorityLevel;
    // 重置标识变量
    isPerformingWork = false;
  }
}

其实flushWork是对workLoop的一层分装,做一些变量修改和重置工作。

4. workLoop

function workLoop(hasTimeRemaining, initialTime) {
  // 初始化时间,转移异步任务
  let currentTime = initialTime;
  advanceTimers(currentTime);
  // 开始循环处理任务
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // 如果任务未过期并且当前帧没有多余时间处理则跳出循环返回true
      break;
    }
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      // 执行回调
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      // 回调可以返回一个函数来作为当前任务的下一环节回调
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
      } else {
        // 处理完毕,从堆中移出当前任务
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      advanceTimers(currentTime);
    } else {
      pop(taskQueue);
    }
    // 处理下一项任务
    currentTask = peek(taskQueue);
  }
  
  if (currentTask !== null) {
    // 如果仍有任务,返回true
    return true;
  } else {
    // 任务处理完则处理延时任务,返回false
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      isHostTimeoutScheduled = true;
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

workLoop就是循环处理taskQueue中的任务,每次处理前会执行advanceTimers(上文讲过)将达到startTime的延时任务转移到taskQueue中。循环终止条件通过shouldYieldToHost判定,并且需要任务未过期。如果任务优先级(6个优先级)比较高,expirationTime就会比较小,很快会过期,过期的任务会直接执行不考虑shouldYieldToHost结果。循环终止后如果仍有任务未处理则返回true(这里返回的结果是上文讲的SchedulerHostConfigperformWorkUntilDeadline函数中是否继续postMessage的判定条件)也就是说,会再次postMessage安排新的宏任务Task(因为当前帧已经没有时间,浏览器自然会将该任务安排到下一帧)

4. unstable_cancelCallback

function unstable_cancelCallback(task) {
  // 记录
  if (enableProfiling) {
    if (task.isQueued) {
      const currentTime = getCurrentTime();
      markTaskCanceled(task, currentTime);
      task.isQueued = false;
    }
  }

  task.callback = null;
}

因为任务队列实际采用的是堆结构,不是数组,对外只提供poppush来进行更新操作,所以不能直接移出任务,只能将任务callback置为null,在处理任务时会对callback进行判定是否为null,如果null则不进一步处理,所以该函数实际就是取消任务。

总结

SchedulerSchedulerHostConfig中提供出来的API做进一步的封装处理,对外提供出接口添加删除任务,内部维护任务队列(堆的形式),并封装了处理函数循环执行任务。任务支持优先级,支持同步任务和异步任务等,自动根据任务队列中任务的执行情况调度任务到不同的Task,穿插到不同帧执行。

全文总结

ReactFiber架构将组件树的渲染改为链表树(Fiber节点构成树的形式,Fiber节点中又保留对下一个节点的引用),而不是递归渲染(依靠JS自身的函数调用栈)。链表的形式可以支持随时中断跳出渲染过程,或从当前节点继续渲染等,从而可以将渲染过程分割开来,并借助Scheduler将任务队列调度到不同帧,不会造成对浏览器页面渲染的阻塞,也就不会出现掉帧卡顿现象。

Scheduler内部实现则是通过最小堆的形式维护两个任务队列timerQueuetaskQueue支持同步任务和异步任务,并支持任务的优先级,内部自动实现对任务的排序,处理,并会根据执行情况将任务安排到下一帧。

Scheduler底层为了实现宏任务的调用,采用了浏览器提供的MessageChannel接口。这里在第一部分讨论了四种接口的优劣对比。