React Scheduler 源码详解(1)

1、引言

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

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

虽然说标题是React Scheduler,但本文的内容跟react是不相关的,因为任务调度器其实跟react是没有关系的,它只是描述怎么在合适的时机去执行一些任务,也就是说你即使没有react基础也可以进行本文的阅读,如果你是框架作者,也可以借鉴这个scheduler的实现,在自己的框架里来进行任务调度。

  • 本文讲解的是react v16.7.0版本的源码,请注意时效性。
  • 源码路径 Scheduler.js

2、基础知识

接下来先来了解一下阅读本文需要知道的一些基础知识。

1、window.performance.now

这个是浏览器内置的时钟,从页面加载开始计时,返回到当前的总时间,单位ms。意味着你在打开页面第10分钟在控制台调用这个方法,返回的数字大概是 600000(误)。

2、window.requestAnimationFrame

  • 这个方法应该很常见了,它让我们可以在下一帧开始时调用指定的函数。它的执行是是跟随系统的刷新频率的。requestAnimationFrame 方法接收一个参数,即要执行的回调函数。这个回调函数会默认地传入一个参数,即从打开页面到回调函数被触发时的时间长度,单位为毫秒。

  • 可以理解为系统在调用回调前立马执行了一下performance.now()传给了回调当参数。这样我们就可以在执行回调的时候知道当前的执行时间了。

     requestAnimationFrame(function F(t) {
           console.log(t, '===='); //会不断打印执行回调的时间,如果刷新频率为60Hz,则相邻的t间隔时间大约为1000/60 = 16.7ms
           requestAnimationFrame(F)
       })
    复制代码
  • requestAnimationFrame有个特点,就是当页面处理未激活的状态下,requestAnimationFrame会停止执行;当页面后面再转为激活时,requestAnimationFrame又会接着上次的地方继续执行。

3、window.MessageChannel

这个接口允许我们创建一个新的消息通道,并通过它的两个MessagePort(port1,port2) 属性发送数据。 示例代码如下

    var channel = new MessageChannel();
    var port1 = channel.port1;
    var port2 = channel.port2;
    port1.onmessage = function(event){
        console.log(event.data)  // someData
    }
    port2.postMessage('someData')
复制代码

这里有一点需要注意,onmessage的回调函数的调用时机是在一帧的paint完成之后。据观察vuenextTick也是用MessageChannel来做fallback的(优先用setImmediate)。
react scheduler内部正是利用了这一点来在一帧渲染结束后的剩余时间来执行任务的

4、 链表

先默认大家对链表有个基本的认识。没有的话自己去补一下知识。

这里要介绍的是双向循环链表

  • 双向链表是指每个节点有previousnext两个属性来分别指向前后两个节点。
  • 循环的意思是,最后一个节点的next指向第一个节点,而第一个节点的previous指向最后一个节点,形成一个环形的人体蜈蚣
  • 我们还需要用一个变量firstNode来存储第一个节点。
  • 下面以一个具体例子来讲一下双向循环链表的插入和删除操作,假设有一群人需要按照年龄进行排队,小孩站前边,大人站后边。在一个过程内会不断有人过来,我们需要把他插到正确的位置。删除的话只考虑每次把排头的人给去掉。
    //person的类型定义
    interface Person {
        name : string  //姓名
        age : number  //年龄,依赖这个属性排序
        next : Person  //紧跟在后面的人,默认是null
        previous : Person //前面相邻的那个人,默认是null
    }
    var firstNode = null; //一开始链表里没有节点
    
    //插入的逻辑
    function insertByAge(newPerson:Person){
        if(firstNode = null){
        
        //如果 firstNode为空,说明newPerson是第一个人,  
        //把它赋值给firstNode,并把next和previous属性指向自身,自成一个环。
          firstNode = newPerson.next = newPerson.previous = newPerson;
          
        } else { //队伍里有人了,新来的人要找准自己的位置
        
             var next = null; //记录newPerson插入到哪个人前边
             var person = firstNode; // person 在下边的循环中会从第一个人开始往后找
             
             do {
                  if (person.age > newPerson.age) {
                  //如果person的年龄比新来的人大,说明新来的人找到位置了,他恰好要排在person的前边,结束
                    next = person;
                    break;
                  }
                  //继续找后面的人
                  node = node.next;
            } while (node !== firstNode); //这里的while是为了防止无限循环,毕竟是环形的结构
            
            if(next === null){ //找了一圈发现 没有person的age比newPerson大,说明newPerson应该放到队伍的最后,也就是说newPerson的后面应该是firstNode。
                next = firstNode;
            }else if(next === firstNode){ //找第一个的时候就找到next了,说明newPerson要放到firstNode前面,这时候firstNode就要更新为newPerson
                firstNode = newPerson
            }
            
            //下面是newPerson的插入操作,给next及previous两个人的前后链接都关联到newPerson
            var previous = next.previous;
            previous.next = next.previous = newPerson; 
            newPerson.next = next;
            newPerson.previous = previous;
        }
        //插入成功
    }
    
    //删除第一个节点
    function deleteFirstPerson(){
        if(firstNode === null) return; //队伍里没有人,返回
        
        var next = firstNode.next; //第二个人
        if(firstNode === next) {
            //这时候只有一个人
            firstNode = null;
            next = null;
        } else {
            var lastPerson = firstNode.previous; //找到最后一个人
            firstNode = lastPerson.next = next; //更新新的第一人
            next.previout = lastPerson; //并在新的第一人和最后一人之间建立连接
        }
        
    }
    
复制代码

由于react16内大量利用了链表来记录数据,尤其react scheduler内对任务的操作使用了双向循环链表结构。所以理解了上述的代码,对于理解react对任务的调度就会比较容易了。

3、正文

注:为了梳理整体的运行流程,下面的示例代码有可能会在源码基础上有少量删减

0、 几个方法,下文不再赘述

```
    getCurrentTime = function() {
        return performance.now();
        //如果不支持performance,利用 Date.now()做fallback
    }
```
复制代码

1、任务优先级

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
   var maxSigned31BitInt = 1073741823;

   // 立马过期 ==> ImmediatePriority
   var IMMEDIATE_PRIORITY_TIMEOUT = -1;
   // 250ms以后过期
   var USER_BLOCKING_PRIORITY = 250;
   //
   var NORMAL_PRIORITY_TIMEOUT = 5000;
   //
   var LOW_PRIORITY_TIMEOUT = 10000;
   // 永不过期
   var IDLE_PRIORITY = maxSigned31BitInt;
复制代码

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

上面的maxSigned31BitInt,通过注释可以知道这是32位系统V8引擎里最大的整数。react用它来做IdlePriority的过期时间。

据粗略计算这个时间大概是12.427天。也就是说极端情况下你的网页tab如果能一直开着到12天半,任务才有可能过期。

2、function scheduleCallback()

  • 代码里的方法叫做unstable_scheduleCallback,意思是当前还是不稳定的,这里就以scheduleCallback作名字。
  • 这个方法的作用就是把任务以过期时间作为优先级进行排序,过程类似上文双向循环链表的操作过程。

下面上代码

   function scheduleCallback(callback, options? : {timeout:number} ) {
       //to be coutinued
   }
复制代码

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

  //这是一个全局变量,代表当前任务的优先级,默认为普通
  var currentPriorityLevel = NormalPriority
  
  function scheduleCallback(callback, options? : {timeout:number} ) {
      var startTime = getCurrentTime()
      if (
          typeof options === 'object' &&
          options !== null &&
          typeof options.timeout === 'number'
        ){
          //如果传了options, 就用入参的过期时间
          expirationTime = startTime + 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, //上一个节点
        };
      //to be coutinued
  }
复制代码

上面的代码根据入参或者当前的优先级来确定当前callback的过期时间,并生成一个真正的任务节点。接下来就要把这个节点按照expirationTime排序插入到任务的链表里边去。

   // 代表任务链表的第一个节点
   var firstCallbackNode = null;
   
   function scheduleCallback(callback, options? : {timeout:number} ) {
       ...
       var newNode = {
           callback, //任务的具体内容
           priorityLevel: currentPriorityLevel, //任务优先级
           expirationTime, //任务的过期时间
           next: null, //下一个节点
           previous: null, //上一个节点
       };
       // 下面是按照 expirationTime 把 newNode 加入到任务队列里。参考基础知识里的person排队的例子
       
       if (firstCallbackNode === null) {
           firstCallbackNode = newNode.next = newNode.previous = newNode;
           ensureHostCallbackIsScheduled(); //这个方法先忽略,后面讲
       } else {
           var next = null;
           var node = firstCallbackNode;
           do {
             if (node.expirationTime > expirationTime) {
               next = node;
               break;
             }
             node = node.next;
           } while (node !== firstCallbackNode);

       if (next === null) {
         next = firstCallbackNode;
       } else if (next === firstCallbackNode) {
         firstCallbackNode = newNode;
         ensureHostCallbackIsScheduled(); //这个方法先忽略,后面讲
       }
   
       var previous = next.previous;
       previous.next = next.previous = newNode;
       newNode.next = next;
       newNode.previous = previous;
     }
   
     return newNode;
       
   }
复制代码
  • 上面的逻辑除了ensureHostCallbackIsScheduled就是前面讲的双向循环链表的插入逻辑。
  • 到这里一个新进来的任务如何确定过期时间以及如何插入现有的任务队列就讲完了。
  • 到这里就会不禁产生一个疑问,我们把任务按照过期时间排好顺序了,那么何时去执行任务呢?
  • 答案是有两种情况,1是当添加第一个任务节点的时候开始启动任务执行,2是当新添加的任务取代之前的节点成为新的第一个节点的时候。因为1意味着任务从无到有,应该 立刻启动。2意味着来了新的优先级最高的任务,应该停止掉之前要执行的任务,重新从新的任务开始执行。
  • 上面两种情况就对应ensureHostCallbackIsScheduled方法执行的两个分支。所以我们现在应该知道,ensureHostCallbackIsScheduled是用来在合适的时机去启动任务执行的。
  • 到底什么是合适的时机?可以这么描述,在每一帧绘制完成之后的空闲时间。这样就能保证浏览器绘制每一帧的频率能跟上系统的刷新频率,不会掉帧。

接下来就需要实现这么一个功能,如何在合适的时机去执行一个function。

3 requestIdleCallback pollyfill

现在请暂时忘掉上面那段任务队列相关的事情,来思考如何在浏览器每一帧绘制完的空闲时间来做一些事情。

答案可以是requestIdleCallback,但由于某些原因,react团队放弃了这个api,转而利用requestAnimationFrameMessageChannel pollyfill了一个requestIdleCallback

1、function requestAnimationFrameWithTimeout()

首先介绍一个超强的函数,代码如下

    var requestAnimationFrameWithTimeout = function(callback) {
      rAFID = requestAnimationFrame(function(timestamp) {
        clearTimeout(rAFTimeoutID);
        callback(timestamp);
      });
      rAFTimeoutID = setTimeout(function() {
        cancelAnimationFrame(rAFID);
        callback(getCurrentTime());
      }, 100);
    }
复制代码

这段代码什么意思呢?

  • 当我们调用requestAnimationFrameWithTimeout并传入一个callback的时候,会启动一个requestAnimationFrame和一个setTimeout,两者都会去执行callback。但由于requestAnimationFrame执行优先级相对较高,它内部会调用clearTimeout取消下面定时器的操作。所以在页面active情况下的表现跟requestAnimationFrame是一致的。

  • 到这里大家应该明白了,一开始的基础知识里说了,requestAnimationFrame在页面切换到未激活的时候是不工作的,这时requestAnimationFrameWithTimeout就相当于启动了一个100ms的定时器,接管任务的执行工作。这个执行频率不高也不低,既能不影响cpu能耗,又能保证任务能有一定效率的执行。

  • 下面我们暂时先认为requestAnimationFrameWithTimeout 等价于 requestAnimationFrame

(不知不觉篇幅已经这么长了,今天先写到这里吧,下次有机会再更)

猜你喜欢

转载自juejin.im/post/5c32c0c86fb9a049b7808665