react scheduler之任务调度和时间切片学习笔记

我们日常使用App,浏览网页时,有两类场景会制约快速响应:

  • CPU的瓶颈: 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿

  • IO的瓶颈: 发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。

CPU的瓶颈

主流浏览器刷新频率为60Hz, 即每16.6ms(1000ms / 60Hz)刷新一次。浏览器在一帧中可以用于执行 JS 的时机如下:

一个task(宏任务) – 队列中全部job(微任务) – requestAnimationFrame – 浏览器重排/重绘 – requestIdleCallback

当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了,因此就会造成页面掉帧,造成卡顿。

react如何解决渲染时间太长导致页面卡顿的问题呢?

在浏览器每一帧的时间中,只预留一些时间给JS线程(在源码中预留的初始时间是5ms)
时间一到, 即使任务还没执行完,React也会将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间再继续进行上一帧被中断的工作。
长任务通过被拆分到每一帧不同的task中,这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性。

这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)

所以,解决CPU瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为可中断的异步更新, 异步可中断更新可以理解为:更新在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。

新老react对比

React v15由于不能满足快速响应的理念,以至于被重构升级到v16.

React15

React15架构可以分为两层:
Reconciler(协调器)—— 负责找出变化的组件
Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Reconciler会做如下工作:
  • 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
  • 将虚拟DOM和上次更新时的虚拟DOM对比
  • 通过对比找出本次更新中变化的虚拟DOM
  • 通知Renderer将变化的虚拟DOM渲染到页面上
React15的缺点:

Reconciler和Renderer是交替工作的.
在Reconciler中,mount的组件会调用mountComponent (opens new window),update的组件会调用updateComponent (opens new window)。这两个方法都会递归更新子组件。递归更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。

React16

React16架构可以分为三层:

  • 新增: Scheduler(调度器)—— 调度任务的优先级和时间切片
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Scheduler其实是模拟 requestIdelCallback 但也弥补了缺点:

  1. 一帧的执行时间存在偏差,导致留给工作执行的时间不确定(不稳定);
  2. 浏览器兼容不好,其中 safari 浏览器根本不支持它。

为了能模拟出requestIdleCallback,必须要做到以下两点:

  1. 可以主动让出线程,让浏览器执行其他任务。
  2. 在每帧下只执行一次,然后在下一帧中继续请求时间片。

React 为实现暂停 JS 任务执行,将主线程交还给浏览器,让浏览器有机会执行页面渲染,就需要借助事件循环的「宏任务」。因为宏任务会在下次事件循环中执行,不会阻塞本次页面渲染更新。之所以不选择微任务,是因为「微任务是在本次页面更新前会全部执行」,这一点与同步执行无异,不会让出主线程。而在宏任务中首选便是setTimeout。但是由于setTimeout会有4ms的时差,react放弃使用了setTimeout,改用了MessageChannel。(在不兼容Messagechannel的情况下依然使用setTimeout实现)
利用宏任务异步的机制,以高频(短间隔)5ms 的方式去对任务进行切片执行,每隔 5ms 让出执行权给浏览器看它是否有渲染工作要做,浏览器做完工作或没有工作要做时,根据 EventLoop 的运行机制会再次进入到下一个宏任务中,接着上次的任务继续执行。
注意:时间到,未完成的任务会重新放入到任务池中,重新调度,在下一帧中找到当下最高优的执行,如用户操作等更高优的任务, 不一定会执行前面那个没执行完的那个任务。

事实上Scheduler,划分了多个优先级参数作为任务等级:

Immediate -1 需要立刻执行。
UserBlocking 250ms 超时时间250ms,一般指的是用户交互。
Normal 5000ms 超时时间5s,不需要直观立即变化的任务,比如网络请求。
Low 10000ms 超时时间10s,肯定要执行的任务,但是可以放在最后处理。
Idle 一些没有必要的任务,可能不会执行。

那么React16是如何解决中断更新时DOM渲染不完全的问题呢?
在React16中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记, 只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。即使反复中断,用户也不会看见更新不完全的DOM.

React Fiber

在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。

为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。
每个Fiber节点对应一个React节点,保存了该组件的类型,对应的DOM节点等信息,在 Fiber 架构下,每一个虚拟 DOM 都是一个任务执行单元

在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。

在内存中构建并直接替换的技术叫做双缓存, React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。

个人理解: 以前只有一块黑板,写满了之后要全部擦掉才开始重新画,存在白屏的问题,现在多了内存这块黑白,直接在第二块写好了替换第一块

JSX和Fiber的关系

在组件mount时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点。
在update时,Reconciler将JSX与Fiber节点保存的数据对比,生成组件对应的Fiber节点,并根据对比结果为Fiber节点打上标记。Fiber比JSX多一些信息比如组件在更新中的优先级

源码解析

MessageChannel有两个属性,

MessageChannel.port1(只读返回channel的port1)
MessageChannel.port2(只读返回channel的port2)
    let scheduledHostCallback = null 
    
    /* 建立一个消息通道 */ 
    var channel = new MessageChannel();
    
    /* 建立一个port发送消息 */ 
    var port = channel.port2; 
    
    channel.port1.onmessage = function(){ 
        /* 执行任务 */ 
        scheduledHostCallback() 
        /* 执行完毕,清空任务 */ 
        scheduledHostCallback = null 
    };
    
    /* 向浏览器请求执行更新任务 */ 
    requestHostCallback = function (callback) { 
        scheduledHostCallback = callback; 
        port.postMessage(null); 
    };
    
    const task = function() {
        console.log('这是浏览器任务')
    }
    
    requestHostCallback(task)

例子中首先实例化了 MessageChannel得到channel。prot1通过onmessage来监听port2传过来的消息。并执行scheduledHostCallback函数。

unstable_scheduleCallback.js
scheduleCallback会将传进来的任务包装成带有优先级调度任务吗,并把他们分别push到对应的timerQueue和taskQueue中:
taskQueue会以任务过期时间为排序标准,有序存放过期任务。
timerQueue会以任务开始时间为排序标准,有序存放未过期的任务

  • unstable_scheduleCallback的整体逻辑就是,先通过任务开始事件+任务延迟时间,计算出任务过期时间。
  • 新建一个调度任务
  • 判断任务是否过期,未过期任务存放入 timerQueue 中,过期任务存放在 taskQueue中。
  • 如果任务未过期会通过requestHostTimeout延迟调用( requestHostTimeout的本质就是调用 setTimeout使任务延迟 ),如果任务过期了便会调用 requestHostCallback。
function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 计算出过期时间
  var expirationTime = startTime + timeout;
  // 创建一个调度任务
  var newTask = {
    id: taskIdCounter++,
    callback: callback, 
    priorityLevel: priorityLevel,
    startTime: startTime,
    expirationTime: expirationTime,
    sortIndex: -1
  };
    
  // 如果任务开始时间大于当前时间,说明任务没有过期
  if (startTime > currentTime) {
    // This is a delayed task.
    // 以startTime作为排序标准
    newTask.sortIndex = startTime;
    // 将任务放入到timerQueue队列中
    push(timerQueue, newTask);
    
    requestHostTimeout(handleTimeout, startTime - currentTime); 
  } else {
    // 以expirationTime作为排序标准
    newTask.sortIndex = expirationTime;
    // 将过期任务放入到taskQueue中
    push(taskQueue, newTask);
    // wait until the next time we yield.
    
    requestHostCallback(flushWork);
  }

  return newTask;
}

requestHostCallback.js
在源码中,通过 MessageChannel 创建一个消息通道,当用户执行 requestHostCallback 调度 callback 时,便会通过 postMessage 发起一个宏任务进入 performWorkUntilDeadline 方法。

if (typeof window === 'undefined' || typeof MessageChannel !== 'function') {
  // 非浏览器环境,或不支持 MessageChannel,会使用 setTimeout 宏任务来实现
} else {
  // 保存 api 引用,防止 polyfill 覆盖它们
  const setTimeout = window.setTimeout;
  const clearTimeout = window.clearTimeout;

  getCurrentTime = () => performance.now(); // 页面加载后开始计算

  let isMessageLoopRunning = false; // 标记 MessageChannel 正在运行
  let scheduledHostCallback = null; // 要执行的处理函数
  let taskTimeoutID = -1; // 用作终止 setTimeout 延迟任务

  // 定义每一帧工作时间,默认时间为 5ms,React 会根据浏览器主机环境进行重新计算。
  let yieldInterval = 5;
  let deadline = 0; // 过期时间,让出主线程

  // 让出主线程
  shouldYieldToHost = function () {
    return getCurrentTime() >= deadline;
  };

  // (可选方法)默认空闲执行时间是5ms,用户可通过该方法来根据不同用户主机的设备刷新率(FPS)来计算预留时间
  forceFrameRate = function (fps) {
    if (fps < 0 || fps > 125) {
      return;
    }
    if (fps > 0) {
      yieldInterval = Math.floor(1000 / fps);
    } else {
      yieldInterval = 5;
    }
  };

  // 开启高频短间隔 5ms 执行工作
  const performWorkUntilDeadline = () => {
    ...
  };

  // 定义宏任务,建立通信
  const channel = new MessageChannel();
  const port = channel.port2; // 用于发布任务
  channel.port1.onmessage = performWorkUntilDeadline; // 处理任务

  requestHostCallback = function (callback) {
    scheduledHostCallback = callback; // 保存任务
    if (!isMessageLoopRunning) {
      isMessageLoopRunning = true;
      port.postMessage(null); // 发起宏任务
    }
  };

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

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

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

调用 requestHostCallback本质上就是发起了一次 MessageChannel 的调用

在 performWorkUntilDeadline 方法中,基于 yieldInterval 计算得到一个执行过期时间 deadline,也就是「高频短间隔 5ms」。
当用户在 callback 中通过调用 shouldYieldToHost() 发现执行时间过期且还存在未完成的任务,可在 callback 函数中返回 true,performWorkUntilDeadline 会先将主线程交给浏览器,再开启一个宏任务等待执行。

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime(); // 拿到当前时间
    // 根据 yieldInterval(5ms)计算剩余时间(任务执行截止时间)。这种方式意味着 port.postMessage 开始后总有剩余时间
    deadline = currentTime + yieldInterval;
    // 标识还有时间,类似 requestIdleCallback deadline.didTimeout
    const hasTimeRemaining = true;
    try {
      const hasMoreWork = scheduledHostCallback(
        hasTimeRemaining,
        currentTime,
      );
      // 执行完成,没有新任务,初始化工作环境
      if (!hasMoreWork) {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      } else {
        // 如果任务截止时间过期(根据 shouldYieldToHost()),还有需要处理的工作,再发起一个异步宏任务
        port.postMessage(null);
      }
    } catch (error) {
      port.postMessage(null);
      throw error;
    }
  } else {
    isMessageLoopRunning = false;
  }
};

scheduledHostCallback最终会调用 workLoop,
workLoopConcurrent就是在执行最小任务单元,也就是我们的fiber节点
在 workLoopConcurrent会通过 shouldYield 来判断当前浏览器是否还有空余时间。

function workLoop(){
  var currentTime = initialTime;
  advanceTimers(currentTime);
  /* 获取任务列表中的第一个 */
  currentTask = peek();
  while (currentTask !== null){
      /* 真正的更新函数 callback */
      var callback = currentTask.callback;
      
      if(callback !== null ){
         /* 执行更新 */
         // 执行真正的回调函数后返回了一个函数,这个函数其实也是performConcurrentWorkOnRoot
         var continuationCallback = callback(didUserCallbackTimeout);
         // 将返回的函数赋值给任务的callback
         currentTask.callback = continuationCallback;
        /* 先看一下 timeQueue 中有没有 过期任务。 */
        advanceTimers(currentTime);
      }
      /* 再一次获取任务,循环执行 */ 
      currentTask = peek(taskQueue);
  }
}

在 workLoop 中会先检测一遍是否有任务过期,然后取出最先过期的任务执行。到目前为止Scheduler 的整个调度流程就结束了。

参考文档:

https://react.iamkasong.com/process/reconciler.html#%E9%80%92-%E9%98%B6%E6%AE%B5

https://www.geeksforgeeks.org/what-is-react-fiber/
https://juejin.cn/post/7146004454653820935
https://juejin.cn/post/7159768909392904222

  • 14
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值