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

前言

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

复制代码

那么Scheduler 需要满足以下功能点

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

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

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

注:理想情况下每一帧都是一次eventloop,但如果因为微任务执行超出16ms(当前帧)甚至超出多帧,那么本次循环将超出一帧,即有可能在第n帧才完成微任务,然后才进行渲染,也就是所说的掉帧。

举个掉帧的例子

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 + yieldIntervaldeadline是在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中,依然有可能会阻塞渲染

参考

www.jianshu.com/p/4a3a09925…

xiaochen1024.com/article_ite…

猜你喜欢

转载自juejin.im/post/7039646834477760548