「✍ React Scheduler」为什么用 MessageChannel 来做调度?

2,313 阅读6分钟

前言

在React 16+ 的架构中,React团队没有直接选择requestIdleCallback api来做任务调度(Scheduler),原因大抵是该api的兼容性以及fps的限制(1秒中最多调用20次,即20fps),而选择了MessageChannel来polyfill。

为什么要做调度

我们可以通过一个简单的例子来模拟diff发生时的状况。

假设我们有一个很复杂的应用,有10000甚至更多的Virtual DOM节点,我们用循环+耗时任务来模拟这些节点的diff过程。

/**
 * 耗时的递归
 **/
function fibonacci(n) {
  if (n === 0) return 0;
  else if (n === 1) return 1;
  return fibonacci(n - 1) + fibonacci(n - 2);
}
​
/**
 * 耗时任务
 **/
const timeConsumingTask = (n) => {
  const res = fibonacci(n);
  return res;
};

然后我们需要模拟一下浏览器的行为,这里就将遍历过的节点数插入html中作为浏览器任务。

let count = 0;
const addCount = () => {
   count += 1;
   console.log("count=", count);
}
const $root = document.getElementById("root");

先来看没有任何调度的做法

function render(times) {
  while (count < times) {
    // 计算 25 的斐波那契数列大概耗时 1ms,所以这里选择 25
    timeConsumingTask(25);
    addCount();
    $root.innerText = "当前计算个数:" + count;
  }
}
render(5000)

效果:

render1.gif

再看看调度情况, 给到浏览器的时间不足1ms,这是非常糟糕的

image.png

可以看到html是在节点全部遍历完成后才渲染的,这当然是我们不愿意看到的

手动调度

上述的阻塞情况很明显是因为没有做任何调度,那么在介绍fiber的实现之前,我们先来试试手动调度

function render(times) {
  setTimeout(() => {
    let currentCount = 1;
    while (count < times && currentCount < 10) {
      
      currentCount++;    
      timeConsumingTask(25);
​
      addCount();
      $root.innerText = "当前计算个数:" + count;
    }
    render(times);
  }, 15);
}

首先我们在循环中加了一个条件,限制每次render只执行10次耗时任务(diff),并且在每次render之前都留出15ms来给浏览器。

效果:

render2.gif

这下就流畅多了,看看调度情况

image.png

显然是留给了浏览器15ms的时间。

但问题又来了,可以看到在上图中浏览器做渲染的时间可能只有0.1ms时间,但我们却留给了它15ms,即有绝大部分的时间浏览器是不需要的。

总结: 这种手动调度的方式本质上是耗时任务(diff)做主导,不需要的时间才还给浏览器

Scheduler

在上述手动调度的基础上,我们需要再优化一下,将主导的角色还给浏览器,浏览器有空闲的时间再做diff

问题在于我们如何知道浏览器空闲了呢?其实已经有了现成的api

requestIdleCallback

详细的使用方式见文档,简单来说就是告诉浏览器,在你空闲的时候执行我的指定任务(diff)

function renderWithFiber(times) {
  const run = (deadline) => {
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && count < times) {
      fibonacciWithTime(25); 
      addCount()
      $root.innerText = "当前计算个数:" + count;
    }
    if (count < times) {
      requestIdleCallback(run);
    }
  };
  requestIdleCallback(run);
}

看看调度情况

image.png

可以看到浏览器的空闲时间大大缩短了。

当然requestIdleCallback有自身的设计缺陷

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

也就是说 requestIdleCallback 的 FPS 只有 20 (一般 FPS 为 60 时对用户来说是感觉流程的, 即一帧时间为 16.7 ms)。在加上api兼容性的考虑,React 团队自己 polyfill了一个,但理念上都是一样的。

React的调度过程

React更新时和Scheduler的交互流程如下:

  1. React 组件状态更新,向 Scheduler 中存入一个任务,该任务为 React 更新算法。
  2. Scheduler 调度该任务,执行 React 更新算法。
  3. React 在调和阶段(reconciliation)更新一个 Fiber 之后,会询问 Scheduler 是否需要暂停。如果不需要暂停,则重复步骤 3,继续更新下一个 Fiber。
  4. 如果 Scheduler 表示需要暂停,则 React 将返回一个函数,该函数用于告诉 Scheduler 任务还没有完成。Scheduler 将在未来某时刻调度该任务。

在这些步骤中,我们着重关注第3点,也就是需要判断Scheduler是否需要暂停

执行React任务的时机

知道了大概的调度过程,首先了解一下React任务是放在什么时机执行的

先来复习一下浏览器的eventloop

image.png

用代码来

/**
 *  事件循环
 */
while(true) {

  // 拿出宏任务执行
  const queue = getNextQueue()
  const task = queue.pop()
  excute(task)

  // 有微任务的话执行
  while(microtaskQueue.hasTasks()){
    doMicrotask()
  }

  if(isRepaintTime()) {

    // 处理RAF(requestAnimationFrame)
    animationTasks = animationQueue.copyTasks();
    for(task in animationTasks) {
      doAnimationTask(task);
    }

    // 渲染下一帧
    repaint();
  }
}

虽说每轮Tick的开始都是宏任务,但在实际执行中,首次执行同步代码会作为一次宏任务,因此后续的顺序可以看作: 执行微任务队列 => 执行RAF回调(若要执行渲染)=>渲染(若要执行渲染) => 下一个任务

Scheduler需要满足以下功能点

  1. 暂停 JS 执行,将主线程还给浏览器,让浏览器有机会更新页面
  2. 在未来某个时刻继续调度任务,执行上次还没有完成的任务

也就是说我们需要一个宏任务,因为宏任务在渲染后的下一帧,不会阻塞本次循环

理想情况下每一帧都是一次loop,但如果因为某些原因,如某微任务执行太久,时间超出当前帧(16ms)甚至超出多帧,那么本次循环将在该微任务执行完才结束,然后才进行渲染,也就是所说的掉帧。

举个掉帧的例子

setTimeout(()=>{
    console.log('第1次宏任务')
    requestAnimationFrame(()=>{ console.log('RAF执行') });
    const dom = document.getElementById('box')
    let n = 0
    while(n < 200){
        dom.style.left = n + 'px'
        n = n + 1
    }
    setTimeout(()=>{
        console.log('第2次宏任务')
    },0)
    p.then(()=>{
        let r = timeConsumingTask(40)
        console.log('第1次微任务', r)
    })
},2000)

打印顺序:
第1次宏任务
第1次微任务 102334155
RAF执行
第2次宏任务

执行顺序:
1. 2000ms后触发第1次宏任务,移动dom(还没渲染),将第2次宏任务和第1次微任务塞入队列
2. 执行微任务列表,这里模拟了一个耗时任务,大概花了10s
3. 过了10s后,微任务执行完毕,执行渲染,因此我们发现过了10s这个dom才完成移动
5. RAF的回调此时才执行,因为它一定是在渲染前才执行
6. 渲染重绘
7. 新的一轮,执行第2次宏任务

如何暂停React任务

源码中shouldYield就是用来判断在有限的时间片中React任务有没有完成,需不需要挂起。在源码中每个时间片时5ms,这个值会根据设备的fps调整。

判断是否应该暂停

function workLoopConcurrent() { 
    while (workInProgress !== null && !shouldYield()) { 
        performUnitOfWork(workInProgress); 
    } 
}

根据fps计算时间片

function forceFrameRate(fps) {
  if (fps < 0 || fps > 125) {
    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 {
    yieldInterval = 5;//时间片默认5ms
  }
}

shouldYield

在函数中有一段,所以可以知道,如果当前时间大于任务开始的时间+yieldInterval,就打断了任务的进行。

function shouldYield

//deadline = currentTime + yieldInterval,deadline是在performWorkUntilDeadline函数中计算出来的
if (currentTime >= deadline) {
  //...
	return true
}

MessageChannel

postMessage作用就是将一个任务塞到宏任务队列中

相关源码比较长篇大论

window.addEventListener('message', idleTick, false); // 接受 react 任务队列

idleTick

-   接受判断 react 任务

-   判断当前帧是否把时间用完了,帧时间用完了任务又过期了 didTimout 标志过期

-   没用完继续或调用动画,保存任务等它过期再调用

-   最后判断 callback 不为空,调用过期的 react 任务。

-   这个方法保证了动画最大限度的执行,react 更新任务只有到时间才会执行

const idleTick = function(event) {
  ...
}

然后在requestHostCallbackanimationTick 中调用postMessage

为什么不用setTimeout

上面说到我们需要一个宏任务,那么为什么不使用setTimeout呢,原因是setTimeout在递归调用下,塞入队列的最低延时会变为4ms,一帧一共就16ms,上面说到时间片默认也就5ms,浪费的这3~4ms是不可容忍的。

为什么不用requestAnimationFrame

从流程上看,RAF的执行时机是在渲染前,但其实浏览器并没有规定应该何时渲染页面,因此RAF是不稳定的。

  1. 有可能过了几次loop才调用一次RAF,React Task就会被搁置太久
  2. 将React Task放到RAF中,依然有可能会阻塞渲染

为什么不用requestIdleCallback

从流程上看

image.png requestIdleCallback是在浏览器重绘重排之后,如果还有空闲就可以执行的时机,所以为了不影响重绘重排,可以在浏览器在requestIdleCallback中执行耗性能的计算,但是由于requestIdleCallback存在兼容和触发时机不稳定的问题,scheduler中采用MessageChannel来实现requestIdleCallback,当前环境不支持MessageChannel就采用setTimeout。

参考

zhuanlan.zhihu.com/p/297971861

www.jianshu.com/p/4a3a09925…

xiaochen1024.com/article_ite…