React Scheduler 源码详解(1)

下一篇

React Scheduler 源码详解(2)vue

一、引言

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

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

虽说标题是React Scheduler,但本文的内容跟react是不相关的,由于任务调度器其实跟react是没有关系的,它只是描述怎么在合适的时机去执行一些任务,也就是说你即便没有react基础也能够进行本文的阅读,若是你是框架做者,也能够借鉴这个scheduler的实现,在本身的框架里来进行任务调度。git

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

二、基础知识

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

一、window.performance.now

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

二、window.requestAnimationFrame

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

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

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

三、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内部正是利用了这一点来在一帧渲染结束后的剩余时间来执行任务的

四、 链表

先默认你们对链表有个基本的认识。没有的话本身去补一下知识。

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

  • 双向链表是指每一个节点有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对任务的调度就会比较容易了。

三、正文

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

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

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

一、任务优先级

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天半,任务才有可能过时。

二、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

一、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

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