ReactScheduler梳理
- Authors
- Name
- 小明&小艺
是 react 的内部包,md 里声明的是用于浏览器环境协同调度的,也有计划把它推广出去。
react15 的弊端之一是 Reconciler 和 Renderer 是同步递归进行的,Reconciler 负责虚拟 DOM 的维护,Renderer 负责视图渲染,递归无法保存中间状态或者中断执行,且浏览器里 js 执行和视图渲染是互斥的,于是当视图渲染遇到瓶颈,无法在一帧时间完成时,页面就出现了卡顿。虽然通过批量更新做优化,减少 Reconciler 的计算频率,但治标不治本,还是有可能出现 Renderer 过于繁忙的情况。而且批量更新无法识别异步任务里的,为确保结果正确,异步里的也只能按照顺序执行,不做合并更新了。
为了解决这个问题,react16 引入了 Fiber 支持异步可中断更新。通过把整个虚拟 DOM 树微观化,变成链表,按照子元素->兄弟元素->父元素的逻辑遍历节点,利用浏览器的空闲时间计算 Diff。一旦浏览器有需求,把没计算完的任务放在一旁,把主进程控制权还给浏览器,让浏览器可以有足够的时间渲染 UI(不可中断)。这种架构虽然没有减少运算量,但是巧妙地利用空闲实现计算,解决了卡顿的问题。
到这里可能就会疑问了,怎么中断任务、什么时间执行、怎么划分优先级,而这个正是 Scheduler 所负责的工作,时间切片和优先级调度。
实现原理
要解决卡顿,就要先了解浏览器的渲染过程。浏览器的每一帧的操作,https://github.com/hushicai/hushicai.github.io/issues/5。
- 接受输入事件
- 执行事件回调
- 开始一帧
- 执行 RAF (RequestAnimationFrame)
- 页面布局,样式计算
- 绘制渲染
- 执行 RIC (RequestIdelCallback)
可以看到,当所有事情都做完了之后,会调用一个 requestIdleCallback 函数,在这个函数里我们可以拿到浏览器当前一祯的剩余时间。借助这个 API ,我们就可以让浏览器仅在空闲时期的时候执行脚本。时间切片的本质,也就是模拟实现 requestIdleCallback 这个函数。
由于兼容性和刷新帧率(requestIdleCallback 工作帧率低,只有 20FPS)的问题,React 并没有直接使用 requestIdleCallback , 而是用 requestAnimationFrame 和 MessageChannel 进行 polyfill,原理是一样的。
在 Chrome 87 版本,React 团队和 Chrome 团队合作,在浏览器上加入了一个新的 API isInputPending。这也是第一个将中断这个操作系统概念用于网页开发的 API。
合理使用 isInputPending 方法,我们可以在页面渲染的时候及时响应用户输入,并且,当有长耗时的 JS 任务要执行时,可以通过 isInputPending 来中断 JS 的执行,将控制权交还给浏览器来执行用户响应。
Scheduler 对外暴露了一个方法 unstable_runWithPriority,这个方法可以用来获取优先级
switch (priorityLevel) {
case ImmediatePriority:
case UserBlockingPriority:
case NormalPriority:
case LowPriority:
case IdlePriority:
break;
default:
priorityLevel = NormalPriority;
}
可以看到有 5 种优先级,比如,我们知道 commit 阶段是同步执行的。可以看到,commit 阶段的起点 commitRoot 方法的优先级为 ImmediateSchedulerPriority。 ImmediateSchedulerPriority 即 ImmediatePriority 的别名,为最高优先级,会立即执行。可是优先级只是一个名称,react 如何判断优先级的高低呢,这里我觉得和操作系统里面的一些概念还是挺相似的 给不同任务给上过期时间,谁快过期了就先执行谁
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
var expirationTime = startTime + timeout;
// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;
可以看到 IMMEDIATE_PRIORITY_TIMEOUT = -1
,说明比当前时间还早,已经过期,必须快执行,初此之外,react 新增了两个队列:已就绪任务 ,未就绪任务 所以,Scheduler 存在两个队列:timerQueue:保存未就绪可以延迟执行的任务,taskQueue:保存已就绪要立即执行的任务。
每当有新的未就绪的任务被注册,我们将其插入 timerQueue 并根据开始时间重新排列 timerQueue 中任务的顺序。当 timerQueue 中有任务就绪,即 startTime <= currentTime
,我们将其取出并加入 taskQueue。取出 taskQueue 中最早过期的任务并执行他。部分任务有 delay 参数,表示可以延迟的,进入 timerQueue。
React 怎么找到优先级最高的任务呢?前面已经说到 timerQueue 和 taskQueue 是队列,那显然就是一个优先队列,优先队列一般可以使用堆排序,那怎么评估优先级呢?Scheduler 里使用过期时间和 id 做比较
function compare(a, b) {
// Compare sort index first, then task id.
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
sortIndex = 当前时间戳 + 延迟时间 + 不同优先级的过期时间
优先比较过期时间(sortIndex),相同的,则比较 id(由 1 递增)
Schedule 使用的是小根堆,用一维数组存储树结构,父子节点通过下标维护,核心是实现取堆顶、上旋和下旋方法。建堆时,调用上旋方法。删除元素时把最后一个放到堆顶,然后调用下旋方法。