- 发布于
React之Secheduler
- Authors
- Name
- 田中原
Secheduler
目录
简介concurrent
Concurrent Mode简单说就是一种非阻塞UI、可中断式的渲染架构。它可以灵活调度渲染任务以避免js长时间执行导致UI进程无法响应。
v16-v17中的Concurrent Mode
早在v16/v17就引入了fiber架构和实验性的concurrent Mode,开启后**整个应用
会开启并发更新模式
**,但这将带来较大的breaking changes**。
v18中的Concurrent Rendering
因此react18提出了Concurrent Rendering的概念,即没有并发模式,只有并发特性,也就是说并发特性只是个可选项。默认情况下整个应用仍使用同步更新(legacy模式),在使用了并发特性后相关的更新再开启并发更新,不用的话就没有breaking changes。
startTransition
v18里通过startTransition
提供api给用户来手动将某些更新标记为非紧急更新,从而避免浪费时间去渲染不必要的内容。
CPU的瓶颈
当项目变得庞大、组件数量繁多时,就容易遇到CPU的瓶颈。
考虑如下Demo,我们向视图中渲染3000个li
:
主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。
我们知道,JS可以操作DOM,GUI渲染线程
与JS线程
是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。
在每16.6ms时间内,需要完成如下工作:
JS脚本执行 ----- 样式布局 ----- 样式绘制
当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。
在Demo中,由于组件数量繁多(3000个),JS脚本执行时间过长,页面掉帧,造成卡顿。
可以从打印的执行堆栈图看到,JS执行时间为73.65ms,远远多于一帧的时间。
如何解决这个问题呢?
答案是:在浏览器每一帧的时间中,预留一些时间给JS线程,React
利用这部分时间更新组件(可以看到,在源码 (opens new window)中,预留的初始时间是5ms)。
当预留的时间不够用时,React
将线程控制权交还给浏览器使其有时间渲染UI,React
则等待下一帧时间到来继续被中断的工作。
这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为
时间切片
(time slice)
此时我们的长任务被拆分到每一帧不同的task
中,JS脚本
执行时间大体在5ms
左右,这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性。
所以,解决CPU瓶颈
的关键是实现时间切片
,而时间切片
的关键是:将同步的更新变为可中断的异步更新
老架构
React15架构可以分为两层:
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
新架构
React16架构可以分为三层:
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
可以看到,相较于React15,React16中新增了Scheduler(调度器)
Scheduler(调度器)
既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
其实部分浏览器已经实现了这个API,这就是requestIdleCallback (opens new window)。但是由于以下因素,React
放弃使用:
- 浏览器兼容性
- 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的
requestIdleCallback
触发的频率会变得很低
基于以上原因,React
实现了功能更完备的requestIdleCallback
polyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。
Scheduler功能
concurrent带来的变动可以概括为以下两点:
- 时间切片
- 优先级调度
我们学习这个两个功能是如何在Scheduler
中实现的。
我们看个例子,
- 浏览器1s 60次,16.6ms刷新一次,超过这个之后会卡顿。其他操作会没有反应
- 5ms左右的一个个task,
时间切片原理
时间切片
的本质是模拟实现requestIdleCallback (opens new window)。
除去“浏览器重排/重绘”,浏览器一帧中哪些时机可以用于执行JS
。
一般task包含主线程的js脚本,定时器。一般也叫宏任务。
job:执行完task之后会清空队列中的所有Job,常见的微任务有promise
requestAnimationFrame:
唯一能精准控制调用时机的API
是requestAnimationFrame
,他能让我们在“浏览器重排/重绘”之前执行JS
。
这也是为什么我们通常用这个API
实现JS
动画 —— 这是浏览器渲染前的最后时机,所以动画能快速被渲染。
一个task(宏任务) -- 队列中全部job(微任务) -- requestAnimationFrame -- 浏览器重排/重绘 -- requestIdleCallback
requestIdleCallback
是在“浏览器重排/重绘”后如果当前帧还有空余时间时被调用的。
浏览器并没有提供其他API
能够在同样的时机(浏览器重排/重绘后)调用以模拟其实现。
所以,退而求其次,Scheduler
的时间切片
功能是通过task
(宏任务)实现的。
最常见的task
当属setTimeout
了。但是有个task
比setTimeout
执行时机更靠前,那就是MessageChannel (opens new window)。
所以Scheduler
将需要被执行的回调函数作为MessageChannel
的回调执行。如果当前宿主环境不支持MessageChannel
,则使用setTimeout
。
你可以在这里 (opens new window)看到
MessageChannel
的实现。这里 (opens new window)看到setTimeout
的实现
// 源码标记位置 NOTE:tzy-1
// Node.js 和旧的 IE。// 为什么我们更喜欢 setImmediate 有几个原因。//// 与 MessageChannel 不同,它不会阻止 Node.js 进程退出。// (即使这是调度程序的 DOM 分支,你也可以到这里// 混合了 Node.js 15+,它有一个 MessageChannel 和 jsdom。)// https://github.com/facebook/react/issues/20756//// 而且,它运行得更早,这是我们想要的语义。// 如果其他浏览器曾经实现它,最好使用它。// 尽管这两者都不如本地调度。
如何实现的时间切片
在React
的render
阶段,~~开启~~~~Concurrent Mode
~~使用Concurrent Rendering时,每次遍历前,都会通过Scheduler
提供的shouldYield
方法判断是否需要中断遍历,使浏览器有时间渲染:
// NOTE: tzy-2 Concurrent模式 render, shouldYield为true时让出线程
// 循环执行工作直到调度器要求我们让步
// scheduler 提供的 shouldYield,判断是否要终端
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
}
// NOTE: tzy-3 workLoopSync 同步模式执行,无法中止
是否中断的依据,最重要的一点便是每个任务的剩余时间是否用完。
Scheduler包简介
Scheduler 是一个独立的包。目前api尚未稳定,只供react内部调用。
// NOTE: tzy-4 shouldYieldToHost
// NOTE: tzy-5 schedulePerformWorkUntilDeadline 根据环境切换实现方式
// NOTE: tzy-4 shouldYieldToHost逻辑
// 时间切片
// NOTE: tzy-6 navigator.scheduling.isInputPending
在Schdeduler
中,为任务分配的初始剩余时间为5ms
。
你可以从这里 (opens new window)看到初始剩余时间的定义
随着应用运行,会通过fps
动态调整分配给任务的可执行时间。
你可以从这里 (opens new window)看到动态分配任务时间
这也解释了为什么设计理念一节启用Concurrent Mode
后每个任务的执行时间大体都是多于5ms的一小段时间 —— 每个时间切片被设定为5ms,任务本身再执行一小段时间,所以整体时间是多于5ms的时间
那么当shouldYield
为true
,以至于performUnitOfWork
被中断后是如何重新启动的呢?我们会在介绍完"优先级调度"后解答。
优先级调度
首先我们来了解优先级
的来源。需要明确的一点是,Scheduler
是独立于React
的包,所以他的优先级
也是独立于React
的优先级
的。
// NOTE: tzy-7 react的workloop
// NOTE: tzy-8 优先级调度
优先级的意义
Scheduler
对外暴露最重要的方法便是unstable_scheduleCallback (opens new window)。该方法用于以某个优先级
注册回调函数。
什么时候调用scheduleCallback呢?
react的hooks里,以及Fiber渲染的过程中都会调用。是比较核心的函数
搜索 scheduleCallback
scheduleCallback
优先级的意义是什么呢?
startTime: 一般是当前时间,或者options参数里添加的延迟任务
timeout: 对应不同优先级,设置不同的超时时间。
可以看到,如果一个任务的优先级
是ImmediatePriority
,对应IMMEDIATE_PRIORITY_TIMEOUT
为-1
,那么
则该任务的过期时间比当前时间还短,表示他已经过期了,需要立即被执行。
react里需要同步执行的代码本质是一个ImmediatePriority
优先级的回调。
expirationTime: 过期时间就 startTime + timeout
优先级约高,过期时间就越近。优先级约低,过期时间就越远。
不同优先级任务的排序
我们已经知道优先级
意味着任务的过期时间。设想一个大型React
项目,在某一刻,存在很多不同优先级
的任务
,对应不同的过期时间。
同时,又因为任务有过期时间,所以我们可以将这些任务按是否过期了分为:
- 已就绪任务
- 未就绪任务
所以,Scheduler
存在两个队列:
- timerQueue:保存未就绪任务(被延迟的任务)
- taskQueue:保存已就绪任务
// NOTE: tzy-9
每当有新的未就绪的任务被注册,我们将其插入timerQueue
并根据开始时间重新排列timerQueue
中任务的顺序。
当timerQueue
中有任务就绪,即startTime <= currentTime
,我们将其取出并加入taskQueue
。
取出taskQueue
中最早过期的任务并执行他。我们会频繁操作timerQueue
和taskQueue
。然后找出这两个队列中过期时间最小的任务。
为了能在O(1)复杂度找到两个队列中时间最早的那个任务,Scheduler
使用小顶堆 (opens new window)实现了优先级队列
。
你可以在这里 (opens new window)看到
优先级队列
的实现
在堆中,所有节点都大于或小于后面的节点,如果堆中每个节点都小于后面节点,这个堆成为小顶堆。
至此,我们了解了Scheduler
的实现。现在可以回答介绍时间切片
时提到的问题:
那么当shouldYield为true,以至于performUnitOfWork被中断后是如何重新启动的呢?
在“取出taskQueue
中最早过期的任务并执行他”这一步中有如下关键步骤:
const continuationCallback = callback(didUserCallbackTimeout)
currentTime = getCurrentTime()
if (typeof continuationCallback === 'function') {
// continuationCallback是函数
currentTask.callback = continuationCallback
markTaskYield(currentTask, currentTime)
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime)
currentTask.isQueued = false
}
if (currentTask === peek(taskQueue)) {
// 将当前任务清除
pop(taskQueue)
}
}
advanceTimers(currentTime)
当注册的回调函数执行后的返回值continuationCallback
为function
,会将continuationCallback
作为当前任务的回调函数。
如果返回值不是function
,则将当前被执行的任务清除出taskQueue
。
render
阶段被调度的函数为performConcurrentWorkOnRoot
,在该函数末尾有这样一段代码:
if (root.callbackNode === originalCallbackNode) {
// The task node scheduled for this root is the same one that's
// currently executed. Need to return a continuation.
return performConcurrentWorkOnRoot.bind(null, root)
}
可以看到,在满足一定条件时,该函数会将自己作为返回值。
你可以在这里 (opens new window)看到这段代码