16-原理篇-调度与时间片
课程
1
写给想要进阶的你
已学完
学习时长: 4分28秒
2
基础篇-认识 jsx
已学完
学习时长: 40分51秒
3
基础篇-起源 Component
已学完
学习时长: 32分50秒
4
基础篇-玄学 state
已学完
学习时长: 33分37秒
5
基础篇-深入 props
已学完
学习时长: 43分58秒
6
基础篇-理解 lifeCycle
学习时长: 72分46秒
7
基础篇-多功能 Ref
已学完
学习时长: 71分17秒
8
基础篇-提供者 context
已学完
学习时长: 53分16秒
9
基础篇-模块化 css
学习时长: 35分20秒
10
基础篇-高阶组件
学习时长: 43分29秒
11
优化篇-渲染控制
学习时长: 50分4秒
12
优化篇-渲染调优
学习时长: 35分8秒
13
优化篇-处理海量数据
学习时长: 28分1秒
14
优化篇-细节处理(持续)
学习时长: 32分19秒
15
原理篇-事件原理
学习时长: 30分48秒
16
原理篇-调度与时间片
学习时长: 23分47秒
17
原理篇- 调和与 fiber
学习时长: 35分54秒
18
原理篇-Hooks 原理
已学完
学习时长: 34分34秒
19
生态篇-React-router
学习时长: 50分9秒
20
生态篇-React-redux
学习时长: 50分34秒
21
生态篇-React-mobx
学习时长: 65分3秒
22
实践篇-实现mini-Router
学习时长: 37分19秒
23
实践篇-表单验证上
学习时长: 41分49秒
24
实践篇-表单验证下
学习时长: 46分31秒
25
实践篇-自定义弹窗
学习时长: 35分4秒
26
实践篇-自定义 Hooks 设计(持续)
学习时长: 32分13秒
27
实践篇-自定义 Hooks 实践(持续)
学习时长: 58分47秒
28
总结篇-如何有效阅读源码
学习时长: 7分18秒
29
原理篇-Context原理
学习时长: 28分27秒
30
原理篇-beginWork和render全流程
学习时长: 38分16秒
31
V18特性篇-useMutableSource(已被取缔)
学习时长: 28分37秒
32
V18特性篇-transition
学习时长: 38分15秒
33
原理篇-更新流程:进入调度任务
学习时长: 30分20秒
34
v18特性篇-concurrent 下的 state更新流程
学习时长: 29分27秒
35
v18特性篇-订阅外部数据源
学习时长: 16分53秒
36
原理篇-v18commit全流程
学习时长: 45分40秒
37
v18新特性-Offscreen
学习时长: 1秒
38
实践篇-设计并实现 keepalive 功能
学习时长: 71分13秒
juejin_logo copyCreated with Sketch.

一 前言

接下来的两个章节,我将重点介绍 React 的两大核心模块:调度( Scheduler )和调和( Reconciler )。

通过本章节学习,你将理解 React 异步调度的原理,以及 React 调度流程,从而解决面试中遇到的调度问题。

在正式讲解调度之前,有个问题可能大家都清楚,那就是 GUI 渲染线程和 JS 引擎线程是相互排斥的,比如开发者用 js 写了一个遍历大量数据的循环,在执行 js 时候,会阻塞浏览器的渲染绘制,给用户直观的感受就是卡顿。

请带着这些问题,在本章节中找答案,收获更佳

  • 异步调度原理?
  • React 为什么不用 settimeout ?
  • 说一说React 的时间分片?
  • React 如何模拟 requestIdleCallback?
  • 简述一下调度流程?

二 何为异步调度

为什么采用异步调度?

v15 版本的 React 同样面临着如上的问题,由于对于大型的 React 应用,会存在一次更新,递归遍历大量的虚拟 DOM ,造成占用 js 线程,使得浏览器没有时间去做一些动画效果,伴随项目越来越大,项目会越来越卡。

如何解决以上的问题呢,首先对比一下 vue 框架,vue 有这 template 模版收集依赖的过程,轻松构建响应式,使得在一次更新中,vue 能够迅速响应,找到需要更新的范围,然后以组件粒度更新组件,渲染视图。但是在 React 中,一次更新 React 无法知道此次更新的波及范围,所以 React 选择从根节点开始 diff ,查找不同,更新这些不同。

React 似乎无法打破从 root 开始‘找不同’的命运,但是还是要解决浏览器卡顿问题,那怎么办,解铃还须系铃人,既然更新过程阻塞了浏览器的绘制,那么把 React 的更新,交给浏览器自己控制不就可以了吗,如果浏览器有绘制任务那么执行绘制任务,在空闲时间执行更新任务,就能解决卡顿问题了。与 vue 更快的响应,更精确的更新范围,React 选择更好的用户体验。而今天即将讲的调度( Scheduler )就是具体的实现方式。

时间分片

React 如何让浏览器控制 React 更新呢,首先浏览器每次执行一次事件循环(一帧)都会做如下事情:处理事件,执行 js ,调用 requestAnimation ,布局 Layout ,绘制 Paint ,在一帧执行后,如果没有其他事件,那么浏览器会进入休息时间,那么有的一些不是特别紧急 React 更新,就可以执行了。

那么首先就是如何知道浏览器有空闲时间?

requestIdleCallback 是谷歌浏览器提供的一个 API, 在浏览器有空余的时间,浏览器就会调用 requestIdleCallback 的回调。首先看一下 requestIdleCallback的基本用法:

requestIdleCallback(callback,{ timeout })
  • callback 回调,浏览器空余时间执行回调函数。
  • timeout 超时时间。如果浏览器长时间没有空闲,那么回调就不会执行,为了解决这个问题,可以通过 requestIdleCallback 的第二个参数指定一个超时时间。

React 为了防止 requestIdleCallback 中的任务由于浏览器没有空闲时间而卡死,所以设置了 5 个优先级。

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

React 的异步更新任务就是通过类似 requestIdleCallback 去向浏览器做一帧一帧请求,等到浏览器有空余时间,去执行 React 的异步更新任务,这样保证页面的流畅。

4.jpg

模拟requestIdleCallback

但是 requestIdleCallback 目前只有谷歌浏览器支持 ,为了兼容每个浏览器,React需要自己实现一个 requestIdleCallback ,那么就要具备两个条件:

  • 1 实现的这个 requestIdleCallback ,可以主动让出主线程,让浏览器去渲染视图。
  • 2 一次事件循环只执行一次,因为执行一个以后,还会请求下一次的时间片。

能够满足上述条件的,就只有 宏任务,宏任务是在下次事件循环中执行,不会阻塞浏览器更新。而且浏览器一次只会执行一个宏任务。首先看一下两种满足情况的宏任务。

setTimeout(fn, 0)

setTimeout(fn, 0) 可以满足创建宏任务,让出主线程,为什么 React 没选择用它实现 Scheduler 呢?原因是递归执行 setTimeout(fn, 0) 时,最后间隔时间会变成 4 毫秒左右,而不是最初的 1 毫秒。所以 React 优先选择的并不是 setTimeout 实现方案。

接下来模拟一下 setTimeout 4毫秒延时的真实场景:

let time = 0 
let nowTime = +new Date()
let timer
const poll = function(){
    timer = setTimeout(()=>{
        const lastTime = nowTime
        nowTime = +new Date()
        console.log( '递归setTimeout(fn,0)产生时间差:' , nowTime -lastTime )
        poll()
    },0)
    time++
    if(time === 20) clearTimeout(timer)
}
poll()

效果:

5.jpg

MessageChannel

为了让视图流畅地运行,可以按照人类能感知到最低限度每秒 60 帧的频率划分时间片,这样每个时间片就是 16ms 。也就是这 16 毫秒要完成如上 js 执行,浏览器绘制等操作,而上述 setTimeout 带来的浪费就足足有 4ms,react 团队应该是注意到这 4ms 有点过于铺张浪费,所以才采用了一个新的方式去实现,那就是 MessageChannel

MessageChannel 接口允许开发者创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。

  • MessageChannel.port1 只读返回 channel 的 port1 。
  • MessageChannel.port2 只读返回 channel 的 port2 。 下面来模拟一下 MessageChannel 如何触发异步宏任务的。
  let scheduledHostCallback = null 
  /* 建立一个消息通道 */
  var channel = new MessageChannel();
  /* 建立一个port发送消息 */
  var port = channel.port2;

  channel.port1.onmessage = function(){
      /* 执行任务 */
      scheduledHostCallback() 
      /* 执行完毕,清空任务 */
      scheduledHostCallback = null
  };
  /* 向浏览器请求执行更新任务 */
  requestHostCallback = function (callback) {
    scheduledHostCallback = callback;
    if (!isMessageLoopRunning) {
      isMessageLoopRunning = true;
      port.postMessage(null);
    }
  };
  • 在一次更新中,React 会调用 requestHostCallback ,把更新任务赋值给 scheduledHostCallback ,然后 port2 向 port1 发起 postMessage 消息通知。
  • port1 会通过 onmessage ,接受来自 port2 消息,然后执行更新任务 scheduledHostCallback ,然后置空 scheduledHostCallback ,借此达到异步执行目的。

三 异步调度原理

上面说到了时间片的感念和 Scheduler 实现原理。接下来,来看一下调度任务具体的实现细节。React 发生一次更新,会统一走 ensureRootIsScheduled(调度应用)。

  • 对于正常更新会走 performSyncWorkOnRoot 逻辑,最后会走 workLoopSync
  • 对于低优先级的异步更新会走 performConcurrentWorkOnRoot 逻辑,最后会走 workLoopConcurrent

如下看一下workLoopSync,workLoopConcurrent。

react-reconciler/src/ReactFiberWorkLoop.js

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

在一次更新调度过程中,workLoop 会更新执行每一个待更新的 fiber 。他们的区别就是异步模式会调用一个 shouldYield() ,如果当前浏览器没有空余时间, shouldYield 会中止循环,直到浏览器有空闲时间后再继续遍历,从而达到终止渲染的目的。这样就解决了一次性遍历大量的 fiber ,导致浏览器没有时间执行一些渲染任务,导致了页面卡顿。

scheduleCallback

无论是上述正常更新任务 workLoopSync 还是低优先级的任务 workLoopConcurrent ,都是由调度器 scheduleCallback 统一调度的,那么两者在进入调度器时候有什么区别呢?

对于正常更新任务,最后会变成类似如下结构:

scheduleCallback(Immediate,workLoopSync)

对于异步任务:

/* 计算超时等级,就是如上那五个等级 */
var priorityLevel = inferPriorityFromExpirationTime(currentTime, expirationTime);
scheduleCallback(priorityLevel,workLoopConcurrent)

低优先级异步任务的处理,比同步多了一个超时等级的概念。会计算上述那五种超时等级。

scheduleCallback 到底做了些什么呢?

scheduler/src/Scheduler.js

function scheduleCallback(){
   /* 计算过期时间:超时时间  = 开始时间(现在时间) + 任务超时的时间(上述设置那五个等级)     */
   const expirationTime = startTime + timeout;
   /* 创建一个新任务 */
   const newTask = { ... }
  if (startTime > currentTime) {
      /* 通过开始时间排序 */
      newTask.sortIndex = startTime;
      /* 把任务放在timerQueue中 */
      push(timerQueue, newTask);
      /*  执行setTimeout , */
      requestHostTimeout(handleTimeout, startTime - currentTime);
  }else{
    /* 通过 expirationTime 排序  */
    newTask.sortIndex = expirationTime;  
    /* 把任务放入taskQueue */
    push(taskQueue, newTask);
    /*没有处于调度中的任务, 然后向浏览器请求一帧,浏览器空闲执行 flushWork */
     if (!isHostCallbackScheduled && !isPerformingWork) {
        isHostCallbackScheduled = true;
         requestHostCallback(flushWork)
     }
    
  }
  
} 

对于调度本身,有几个概念必须掌握。

  • taskQueue,里面存的都是过期的任务,依据任务的过期时间( expirationTime ) 排序,需要在调度的 workLoop 中循环执行完这些任务。
  • timerQueue 里面存的都是没有过期的任务,依据任务的开始时间( startTime )排序,在调度 workLoop 中 会用advanceTimers检查任务是否过期,如果过期了,放入 taskQueue 队列。

scheduleCallback 流程如下。

  • 创建一个新的任务 newTask。
  • 通过任务的开始时间( startTime ) 和 当前时间( currentTime ) 比较:当 startTime > currentTime, 说明未过期, 存到 timerQueue,当 startTime <= currentTime, 说明已过期, 存到 taskQueue。
  • 如果任务过期,并且没有调度中的任务,那么调度 requestHostCallback。本质上调度的是 flushWork。
  • 如果任务没有过期,用 requestHostTimeout 延时执行 handleTimeout。

requestHostTimeout

上述当一个任务,没有超时,那么 React 把它放入 timerQueue中了,但是它什么时候执行呢 ?这个时候 Schedule 用 requestHostTimeout 让一个未过期的任务能够到达恰好过期的状态, 那么需要延迟 startTime - currentTime 毫秒就可以了。requestHostTimeout 就是通过 setTimeout 来进行延时指定时间的。

scheduler/src/Scheduler.js

requestHostTimeout = function (cb, ms) {
_timeoutID = setTimeout(cb, ms);
};

cancelHostTimeout = function () {
clearTimeout(_timeoutID);
};
  • requestHostTimeout 延时执行 handleTimeout,cancelHostTimeout 用于清除当前的延时器。

handleTimeout

延时指定时间后,调用的 handleTimeout 函数, handleTimeout 会把任务重新放在 requestHostCallback 调度。

scheduler/src/Scheduler.js

function handleTimeout(){
  isHostTimeoutScheduled = false;
  /* 将 timeQueue 中过期的任务,放在 taskQueue 中 。 */
  advanceTimers(currentTime);
  /* 如果没有处于调度中 */
  if(!isHostCallbackScheduled){
      /* 判断有没有过期的任务, */
      if (peek(taskQueue) !== null) {   
      isHostCallbackScheduled = true;
      /* 开启调度任务 */
      requestHostCallback(flushWork);
    }
  }
}
  • 通过 advanceTimers 将 timeQueue 中过期的任务转移到 taskQueue 中。
  • 然后调用 requestHostCallback 调度过期的任务。

advanceTimers

scheduler/src/Scheduler.js advanceTimers

function advanceTimers(){
   var timer = peek(timerQueue);
   while (timer !== null) {
      if(timer.callback === null){
        pop(timerQueue);
      }else if(timer.startTime <= currentTime){ /* 如果任务已经过期,那么将 timerQueue 中的过期任务,放入taskQueue */
         pop(timerQueue);
         timer.sortIndex = timer.expirationTime;
         push(taskQueue, timer);
      }
   }
}
  • 如果任务已经过期,那么将 timerQueue 中的过期任务,放入 taskQueue。

flushWork和workloop

综上所述要明白两件事:

  • 第一件是 React 的更新任务最后都是放在 taskQueue 中的。
  • 第二件是 requestHostCallback ,放入 MessageChannel 中的回调函数是flushWork。

flushWork

scheduler/src/Scheduler.js flushWork

function flushWork(){
  if (isHostTimeoutScheduled) { /* 如果有延时任务,那么先暂定延时任务*/
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }
  try{
     /* 执行 workLoop 里面会真正调度我们的事件  */
     workLoop(hasTimeRemaining, initialTime)
  }
}
  • flushWork 如果有延时任务执行的话,那么会先暂停延时任务,然后调用 workLoop ,去真正执行超时的更新任务。

workLoop

这个 workLoop 是调度中的 workLoop,不要把它和调和中的 workLoop 弄混淆了。

function workLoop(){
  var currentTime = initialTime;
  advanceTimers(currentTime);
  /* 获取任务列表中的第一个 */
  currentTask = peek();
  while (currentTask !== null){
      /* 真正的更新函数 callback */
      var callback = currentTask.callback;
      if(callback !== null ){
         /* 执行更新 */
         callback()
        /* 先看一下 timeQueue 中有没有 过期任务。 */
        advanceTimers(currentTime);
      }
      /* 再一次获取任务,循环执行 */ 
      currentTask = peek(taskQueue);
  }
}
  • workLoop 会依次更新过期任务队列中的任务。到此为止,完成整个调度过程。

shouldYield 中止 workloop

在 fiber 的异步更新任务 workLoopConcurrent 中,每一个 fiber 的 workloop 都会调用 shouldYield 判断是否有超时更新的任务,如果有,那么停止 workLoop。

scheduler/src/Scheduler.js unstable_shouldYield

function unstable_shouldYield() {
  var currentTime = exports.unstable_now();
  advanceTimers(currentTime);
  /* 获取第一个任务 */
  var firstTask = peek(taskQueue);
  return firstTask !== currentTask && currentTask !== null && firstTask !== null && firstTask.callback !== null && firstTask.startTime <= currentTime && firstTask.expirationTime < currentTask.expirationTime || shouldYieldToHost();
}
  • 如果存在第一个任务,并且已经超时了,那么 shouldYield 会返回 true,那么会中止 fiber 的 workloop。

调度流程图

整个调度流程,用一个流程图表示:

2.jpg

调和 + 异步调度 流程总图

异步调度过程,如下图所示:

3.jpeg

四 总结

本章节学习了 React 调度原理和流程,下一节,将学习 React Reconciler 调和流程。

留言
Ctrl + Enter
全部评论(88)
叁十四城的头像
删除
文章有些地方写的timeQueue,有些地方写的timerQueue
点赞
回复
柳杉的头像
删除
微信公众号 @ 柳杉前端
今天再理解了一遍,更清晰一些[奸笑](liush.top)
点赞
回复
挽星河的头像
删除
前端
有点看不懂,看完下一章再看一遍
1
回复
柳杉的头像
删除
微信公众号 @ 柳杉前端
打卡懵逼中,还得再看一遍
点赞
回复
marh的头像
删除
请问currentTime是怎么来的,感觉startTime不可能大于currentTime
点赞
1
删除
可以看下源码 currentTime = getCurrentTime()就是当前时间,startTime = currentTime + delay; 关键是这个 delay 是从 options 中获取的,也就是推迟时间
2
回复
BrownWhite的头像
删除
调和 + 异步调度 流程总图看不到,加载不出来
点赞
回复
蓝思绪的头像
删除
web前端开发工程师
大佬的流程图挺清晰,请教下用什么画的[疑问]
点赞
1
删除
processon
点赞
回复
violetyc的头像
删除
web前端开发
看了三四遍[捂脸]有点明白了
点赞
回复
sunxt108321的头像
删除
希望把lane讲清除
点赞
回复
脚本小子996的头像
删除
我有一个疑问,作者为啥这么牛逼,这也太细了吧,怎么研究的
2
1
删除
同问
1
回复
很帅很愁人的头像
删除
全村工程师 @ 下王庄村委员会
逐渐看不懂系列
1
回复
levi_m的头像
删除
前端
二刷,从后往前看懂了[捂脸]
点赞
回复
遇风的头像
删除
前端 @ 老板不让说 空姐
从头开始看,越看越看不懂,不过最后看到流程总图,好像懂了一些[皱眉]
1
回复
WebChang的头像
删除
真·react进阶,我果然看不懂[捂脸]
点赞
1
删除
我觉得大部分人都看不懂,并不是大部分人的问题,而是作者就没讲清楚,或者说一些关键细节有遗漏,所以导致整个逻辑看起来不连贯,所以看不懂。建议还是自己扒源码研究研究。
3
回复
尤可脱的头像
删除
Web前端开发工程师
说实话,我看不懂;我还要再多学习;后面多看几遍;多领悟;
点赞
1
删除
感谢啦~
点赞
回复
爱吃猪头爱吃肉的头像
删除
我觉得schduler最有意思是实现中断、恢复以及判断这个任务有没有执行完,单独看了下scheduler0.20.2的版本,我说下我的理解,workLoop是在performWorkUntilDeadline函数里执行,这个函数调用是在这一帧有剩余时间时执行,workLoop的执行会返回一个boolean用于判断是全部执行完毕还是被中断了,true表示当前帧执行完了,但是过期任务的过期时间没有到,那么下一帧执行继续执行这个任务,为false表示过期任务队列全部执行完毕了。核心是为true时实现中断和恢复。
恢复:是在performWorkUntilDeadline拿到workLoop返回值为true继续调用port.postMessage(null) 让他下一帧时继续执行过期任务队列的第一个 也就是上一帧没执行完的任务
中断:是在workLoop中,这个任务还没执行完了,但是没时间了。就跳出当前循环,并且判断一下currentTask是不是有值,有值表示被中断返回true
不对的地方各位大佬帮忙指正一下
展开
4
2
删除
说的很详细了[玫瑰],有一点需要说明的是在 concurrent 模式下,是通过 shouldyeild 来判断有无高优先级的任务,在 v17 及以前的版本,任务都是相同的优先级的,但是在 v18 版本,会有不同优先级的更新任务。
2
回复
删除
总结的很好[赞],算是对本章节的补充,这部分本文没有细谈,期待作者更新 Scheduler 部分[玫瑰]
点赞
回复
Alisa不想说话的头像
删除
关于渲染线程、js引擎线程有点困惑,昨天刚看了一篇讲述浏览器的工作原理的文章,里面主要是讲了浏览器的各个进程及进程中的线程,还有进程之间是如何通信的,比如浏览器进程、渲染进程及他们的线程及通信等,其中渲染进程又有Main thread、worker thread 、Compositor thread、Raster thread ,并没有提到渲染线程、js引擎线程,这些名词是不是为了区分主线程的工作才起的?我是个菜鸟[哭笑]
点赞
1
删除
[玫瑰][玫瑰][玫瑰][玫瑰]
点赞
回复
NeoYu的头像
删除
前端开发工程师 @ 🦐 皮
16 毫秒是在MessageChanel内的,4 毫秒是在 setTimeout回调执行之前的,是浪费的
1
1
删除
嗯嗯 16 毫秒是浏览器的一帧。
1
回复
NeoYu的头像
删除
前端开发工程师 @ 🦐 皮
赞₍₍Ϡ(੭•̀ω•́)੭✧⃛
点赞
1
删除
感谢
点赞
回复
谭低调的头像
删除
前端
这里的中断是指在执行下一个任务单元的时候通过 shouldYield 判断进行中断,一个任务单元在执行过程中是无法中断的是吗?
点赞
2
删除
我觉得shouldYield应该是判断这个这一帧有没有结束,结束了就需要暂停,没结束就不用暂停[思考]
点赞
回复
删除
fiber是最小的任务执行单元
点赞
回复

查看全部 88 条回复