前言
这篇文章是我自己理解 React Scheduler 的思路整理,再用 AI 做了润色。全文尽量用大白话讲清楚 Scheduler 是啥,但肯定有不严谨甚至错误的地方,欢迎指正!
最小堆
什么是最小堆
要了解 Scheduler 之前肯定要先知道什么是最小堆。
具体点说:
- 最小堆通常用完全二叉树来表示(你可以理解成一层一层从左到右排满的树)。
- 堆顶(也就是最上面那个数)一定是所有数里最小的。
- 但它不是完全排好序的!比如第二小的数不一定在第二层左边,它可能在右边,也可能在更深的地方。只要满足“爸爸 ≤ 孩子”这个规则就行。
举个例子:
1
/ \
2 3
/ \
5 4
这个就是一个最小堆:
- 1 是最小的,在最顶上;
- 1 的两个孩子是 2 和 3,都 ≥ 1;
- 2 的两个孩子是 5 和 4,也都 ≥ 2;
- 所有“爸爸”都不比“孩子”大,符合规则!
那最小堆有啥用呢?
- 快速拿到最小值:永远在堆顶,O(1) 时间就能拿到。
- 动态维护最小值:比如你不断加新数字,或者删掉最小的,堆都能快速调整(O(log n) 时间)。
- 常用于优先队列、堆排序、Dijkstra 算法等。
最小堆的数据维护
最小堆可以通过父子节点的索引计算公式,把整棵完全二叉树“扁平化”地存储在一个数组里,而不需要使用指针或树形结构。
🌰 举个例子
假设我们有最小堆(树形):
1
/ \
2 3
/ \
5 4
按照从上到下、从左到右一层层读下来,放进数组就是:
const heap = [1, 2, 3, 5, 4];
假设数组从 索引 0 开始,那么:
某个节点在数组中的位置是 i
- 它的 左孩子 就在
2 * i + 1 - 它的 右孩子 就在
2 * i + 2 - 它的 爸爸(父节点) 就在
Math.floor((i - 1) / 2)
现在我们来验证一下“父子关系”对不对:
-
heap[0] = 1(根)- 左孩子:
2*0+1 = 1→heap[1] = 2✅ - 右孩子:
2*0+2 = 2→heap[2] = 3✅
- 左孩子:
-
heap[1] = 2- 左孩子:
2*1+1 = 3→heap[3] = 5✅ - 右孩子:
2*1+2 = 4→heap[4] = 4✅
- 左孩子:
-
heap[3] = 5(叶子节点)- 左孩子索引是
2*3+1 = 7,但数组长度才 5,说明它没孩子了 ✅
- 左孩子索引是
再看爸爸是谁:
heap[4] = 4,它的爸爸索引是Math.floor((4-1)/2) = Math.floor(3/2) = 1→heap[1] = 2✅
完美对应!
❓ 为啥能这么干?
因为最小堆必须是“完全二叉树”——意思就是:
- 除了最后一层,上面都填满;
- 最后一层也必须从左往右紧挨着放,不能跳着空位。
这种“严丝合缝”的结构,正好可以按顺序塞进数组,不会浪费位置,也不会搞混谁是谁的孩子。
🛠 实际好处
- 不用写复杂的树节点(比如 left/right 指针)
- 内存连续,访问快
- 插入/删除时,只要用索引算父子,往上“冒泡”或往下“下沉”调整就行
比如你往堆里加个新数,就先塞到数组末尾,然后不断跟“爸爸”比,如果比爸爸小,就交换,直到满足“爸爸 ≤ 孩子”——这个过程叫 上浮(heapify up)。
删最小值(堆顶)时,把最后一个数挪到顶部,然后不断跟两个孩子中更小的那个比,如果比孩子大,就交换——这叫 下沉(heapify down)。
全程只用数组和索引计算,超高效!
时间切片
为什么需要时间切片?(核心思想)
JavaScript 是单线程的,意味着在主线程上,一次只能做一件事。如果一个任务(比如一个复杂的计算、渲染一个上万条数据的列表)执行时间过长(比如超过 50ms),它就会“霸占”主线程。
后果:
-
页面卡顿: 浏览器无法及时响应用户的点击、输入等交互。
-
渲染延迟: 页面的动画、滚动等视觉效果会掉帧,看起来不流畅。
-
阻塞其他任务: 其他重要的任务(如用户输入事件、定时器)只能排队等待,导致整体体验下降。
时间切片的解决方案:
时间切片的核心思想是 “合作式调度”。我们主动将一个大的、可能阻塞主线程的 Task,拆分成许多个小的 Work。然后,我们设定一个时间预算(即“时间片”),在每个时间片内执行一个或多个 Work。一旦时间片用完,即使任务还没完成,我们也主动中断,把主线程的控制权交还给浏览器,让它去处理更高优先级的工作(如渲染、用户交互)。等浏览器空闲下来,我们再从中断的地方继续执行。
一句话总结:把大任务拆小,分批执行,超时就停,保证主线程随时可响应。
核心概念详解
用例子举例理解:假设我们有一个输入框和下面一个包含 20,000 个项目的列表。当用户在输入框里输入文字时,列表需要实时过滤。
Task (任务)
它是被调度的对象。调度器(Scheduler)管理着一个任务队列,决定哪个 Task 应该在何时被执行。那么在这个例子中,task 有可能就是:
- 用户输入框的每一个字符的行为;
- 列表过滤;
核心属性:
id: 任务的唯一标识。callback: 任务具体要执行的函数(见下文)。priority: 任务的优先级(如:立即执行、高、中、低、空闲时执行)。expirationTime: 任务的过期时间,如果超过这个时间还没执行,可能会被提升优先级。startTime: 任务的开始时间。
如果一次时间切片中完不成,Task 的执行状态可以被保存,并在下次被调度时重启(虽然说是重启,但是这是一个带有状态记录的重启,例如发现已经执行了一半,且这一半没什么变化的话,那么下次就从一半的位置继续执行,而如果有变化了,那么就开始重新执行)。
Callback (回调函数)
是 Task 中具体要执行的逻辑代码,通常是一个函数。没有 Callback,Task 就只是一个空壳,不知道要做什么。
在这个例子中:
- 输入字符的 Task 的 Callback 工作就是渲染
<input />组件; - 列表过滤 Task 的 Callback 工作就是遍历列表项,根据输入的字符过滤出符合条件的项;最后渲染过滤后的列表项;
Callback 函数在被调用时,通常会接收一个
didTimeout参数和一个deadline对象,让函数内部可以判断自己是否还有时间继续执行。
Work (工作单元)
它是调度器实际执行的最小单位。我们不是一次性执行完整个 Callback,而是在一个时间片内,执行 Callback 中的一小部分,这一小部分就是一个 Work。
在这个例子中:
- 输入字符的 Callback 里的 Work 可能就是计算新的 value,准备更新 DOM;
- 列表过滤 Task 的 Callback 里的 Work 可能就是遍历列表项中,检查每个 item 是否包含输入的字符;
通常通过
while循环结合deadline.timeRemaining()来实现。循环体里的每一次迭代,都可以看作是一个 Work。
那么当用户在输入字符时可能会发生的事情如下:
- 用户输入 "a"。一个高优先级 Task 诞生了!这个任务计算很快,一次时间切片就能完成,接着处理相对低优先级的列表过滤 Task。此时用户立马输入 "b"。
- 调度器检查当前正在执行的任务。它发现正在执行的是一个低优先级的列表过滤 Task。中断发生! 调度器会暂停(或说“杀死”)这个低优先级的 Task。它已经完成的部分 Work(比如已经过滤了 5000 个项目)会被丢弃,因为过滤条件已经从 “a” 变成了 “ab”,之前的结果已经无效了。
- 调度器立即切换到执行新的高优先级 Task,更新输入框显示 “ab”。用户再次感受到输入框的即时响应。
- 当输入框更新完成后,调度器会重新安排一个新的低优先级 Task,这次的任务是过滤包含 “ab” 的项目。
- 这个新的 Task 开始从头执行它的 Work,遍历整个列表…
所以如果你想用一句话来概括就是:React 的调度器创建了一个带优先级的 Task,这个 Task 包含一个 Callback 函数。当 Task 被执行时,Callback 会在一个时间片内循环执行尽可能多的 Work(处理 Fiber 节点)。如果时间用完或有更高优先级的 Task 进来,当前 Task 就会被中断,并在之后(通常是)重启。
最小堆和时间切片的结合
将时间切片与最小堆结合,是为了解决一个更高级的问题:当有大量任务需要调度时,如何高效、公平地选择下一个要执行的任务?
简单来说:
- 时间切片 解决了 “如何执行一个任务而不阻塞主线程” 的问题。
- 最小堆 解决了 “在众多任务中,应该优先选择哪一个来执行” 的问题。
下面我们详细拆解它们是如何天衣无缝地结合在一起的。
为什么需要最小堆?
想象一下,你有一个任务列表,里面有成千上万个任务。每个任务都有一个“优先级”或者“过期时间”。
- 任务 A: 优先级极高(比如用户点击触发的),但执行时间很短。
- 任务 B: 优先级低(比如后台数据分析),但执行时间很长。
- 任务 C: 优先级中等,但马上就要过期了。
如果你的任务调度器只是一个简单的数组,每次要找下一个任务时,你都需要遍历整个数组,找到优先级最高或最紧急的那个。当任务数量巨大时,这个查找过程本身就会消耗性能,甚至可能造成卡顿。
最小堆就是解决这个“查找”问题的利器。 它是一种特殊的优先级队列,可以让你在 O(1) 的时间内(极快)获取到所有任务中“最小”的那个元素(在我们的场景里,就是优先级最高或最早过期的任务),而插入和删除操作也只需要 O(log n) 的时间,非常高效。
结合后的完整工作流程
注意之前讲 Task 的时候提到过两个属性:
priority和expirationTime。
让我们用一个完整的例子来走一遍流程:
场景: 我们有一个调度器,它内部使用一个最小堆来管理任务。
第 1 步:任务创建与入队
- 一个用户点击事件产生了一个高优先级任务
Task_A,它的priority是1。 - 一个后台数据同步任务
Task_B被创建,它的priority是10。 - 一个需要在
timestamp 1000之前完成的定时任务Task_C被创建,它的expirationTime是1000。
我们将这些任务插入到最小堆中。最小堆会自动根据它们的排序键(这里我们假设用 priority)进行排列。堆顶永远是 priority 最小的任务。
最小堆内部状态 (以priority为键):
(Task_A, p:1)
/ \
(Task_C, p:5) (Task_B, p:10)
(注意:Task_C 的 priority 我假设为 5,用于示例)
第 2 步:调度器启动
调度器的主循环开始(通常使用 requestIdleCallback 或 MessageChannel 来实现)。
第 3 步:获取下一个任务
- 调度器问最小堆:“给我下一个最紧急的任务。”
- 最小堆不需要遍历,直接返回堆顶元素:
Task_A。这个操作是 O(1),极快。 - 调度器将
Task_A从堆中“取出”(pop),这个操作是 O(log n)。
第 4 步:执行时间切片
- 调度器开始执行
Task_A的Callback。 - 它启动一个计时器,时间片为
5ms。 Task_A的Callback开始执行。假设它是一个复杂的计算,需要20ms才能完成。
第 5 步:时间片用尽,让出控制权
5ms后,计时器响起。调度器强制中断Task_A的执行。- 此时,
Task_A还没完成。调度器需要决定如何处理它。 - 关键决策:由于
Task_A还没完成,我们需要把它重新放回最小堆,以便后续继续执行。它的priority通常保持不变。
第 6 步:循环继续
Task_A被重新插入堆中。堆再次自动调整,Task_A因为priority最低,很可能又回到了堆顶。- 调度器回到第 3 步,再次从堆顶获取任务。它又拿到了
Task_A。 - 调度器继续执行
Task_A的Callback5ms... - 这个过程重复 4 次后,
Task_A终于执行完毕。
第 7 步:任务完成
当一个任务的 Callback 在一个时间片内执行完毕,调度器就不会把它再放回堆中。它被彻底丢弃。
如果在执行过程中来了新任务怎么办?
假设在第 4 步执行 Task_A 的第一个时间片时,一个 priority 为 0 的紧急任务 Task_D 进来了。它会被立即插入堆中,并成为新的堆顶。当 Task_A 的时间片用尽并被重新插入堆后,调度器下一次获取的将是 Task_D,而不是 Task_A。这就保证了高优先级任务总能被优先处理。
总结
所以,当你看到 React 的 Scheduler(调度器)源码时,你会发现它内部就实现了一个最小堆,用来管理各种不同优先级的更新任务(比如用户输入、数据请求、动画等),然后通过时间切片的机制,在浏览器空闲时去执行这些任务。这正是 React 18 并发特性的基石。
如何调度
理论我们都已经知道了,那么就需要了解调度的实现。
一、什么是原生的 requestIdleCallback (rIC)?
这是浏览器提供的一个 API,它的初衷非常好:
“开发者,你给我一个回调函数。我(浏览器)会在主线程空闲的时候,也就是处理完渲染、用户输入等高优先级任务后,调用你的函数。这样你就可以在不影响用户体验的情况下,做一些不那么重要的事情。”
它还提供了一个 deadline 对象,告诉你还剩多少空闲时间 (timeRemaining()),这简直就是为时间切片量身定做的!
理想很丰满: React 最初也想直接用它来调度低优先级的更新。
二、为什么 React 不直接用原生的 requestIdleCallback?
现实很骨感,原生的 rIC 存在几个致命缺陷,导致它无法支撑 React 的并发模式:
-
触发频率太低,甚至不触发
- 如果浏览器一直很忙(比如有复杂的动画、大量计算),
rIC的回调可能永远不会被执行。这意味着 React 的更新可能会被无限期推迟,导致界面看起来“卡死了”。 - React 需要一个更可靠的机制,确保任务最终一定会被执行,而不是“有空再说”。
- 如果浏览器一直很忙(比如有复杂的动画、大量计算),
-
执行时机不可控
rIC通常在一帧的末尾被调用。但 React 的并发调度需要更精细的控制,比如在一帧的中间就插入一个高优先级任务,或者在一个空闲周期内执行多个小任务。rIC的粒度太粗了。
-
兼容性问题
- Safari 对
rIC的实现有 Bug,并且触发频率极低,基本不可用。React 必须保证在所有主流浏览器上表现一致。
- Safari 对
-
缺乏优先级控制
rIC只是一个“有空就做”的机制,它本身没有优先级概念。而 React 的调度器需要处理非常复杂的优先级(比如用户输入 > 动画 > 数据获取 > 页面懒加载),rIC无法满足这种需求。
三、React 的解决方案:自定义调度器
由于原生的 rIC 靠不住,React 团队做了一个大胆的决定:我们自己造一个!
这个自定义的调度器(在 scheduler 包中)就是时间切片和最小堆的完美结合体,它解决了 rIC 的所有问题。
它是如何工作的?
-
触发机制:不用
rIC,改用MessageChannel- React 使用
MessageChannel或postMessageAPI 来调度任务。这两个 API 会将一个任务作为宏任务 推送到任务队列的末尾。 - 这就模拟了一个“空闲”状态:当前同步代码执行完毕,浏览器处理完微任务和渲染后,就会来执行这个宏任务。这比
rIC可靠得多,因为它保证会在下一个事件循环中被触发。
- React 使用
-
时间切片的实现
- 当
MessageChannel的回调被触发时,React 的调度器就开始工作。 - 它从最小堆中取出优先级最高的任务。
- 开始执行这个任务的
Callback(也就是Work)。 - 在执行过程中,它会不断检查时间,看是否超过了预设的时间片(比如 5ms)。
- 如果时间片用完,它就主动中断,然后再次通过
MessageChannel把自己(剩下的工作)安排到下一个宏任务中,从而让出主线程。
- 当
-
最小堆的作用
- 在每次
MessageChannel回调被触发,准备开始工作时,调度器都会去最小堆里查找当前应该执行哪个任务。这保证了高优先级的任务总是被优先处理。
- 在每次
这里知道 React 使用 MessageChannel 或 postMessage API 来调度任务就行了,除非你想深入理解,那么推荐直接去看源码。
综合场景举例
现在你已经了解了什么是最小堆、Task、Callback、Work、MessageChannel,并且知道他们之间协作的原理,那么接下来我们来看看一个综合的场景,加深理解。
场景设置
这个页面包含:
- 一个侧边栏,上面有多个筛选器(如日期范围、用户类型等)。
- 主内容区,显示了三个复杂的图表:一个柱状图、一个折线图和一个饼图。每个图表的数据都需要经过大量计算才能渲染出来。
用户操作
用户在侧边栏点击了“显示上个月数据”的按钮。
React 内部发生了什么?
当用户点击按钮,React 需要更新整个页面。我们来看看 React 的调度器(那个“自制的 requestIdleCallback”)是如何工作的。
第一步:任务的创建与排序 (最小堆登场)
点击按钮触发了状态更新,React 知道需要重新渲染。但它不会一股脑地把所有事情都做了。它会创建一系列的 Task,并根据优先级将它们放入一个最小堆中。
-
高优先级任务 (优先级数值小,在堆顶):
Task A: 更新按钮的视觉状态(比如显示一个加载中的小图标),给用户即时反馈。Task B: 更新图表上方的标题,从“本月数据”变为“上月数据”。
-
普通优先级任务 (优先级数值大,在堆底):
Task C: 重新计算并渲染柱状图。Task D: 重新计算并渲染折线图。Task E: 重新计算并渲染饼图。
最小堆的作用:React 调度器从堆里取任务时,总能以最快的速度(O(log n))拿到优先级最高的那个。所以它会先执行 Task A,然后是 Task B,之后才会轮到 C, D, E。这保证了用户能立刻看到页面的关键部分发生了变化。
第二步:任务的执行与中断 (时间切片登场)
现在,React 开始执行任务。Task A 和 Task B 很小,瞬间就完成了。
接下来,轮到了 Task C(重新计算并渲染柱状图)。这是一个大任务,可能需要 100 毫秒才能完成。如果直接执行,页面会卡死 100 毫秒!
这时,时间切片机制启动了:
- React 调度器不会一次性执行完
Task C。它会把这个大任务拆分成很多个小Work(比如,计算第一行数据是一个 Work,计算第二行数据是另一个 Work)。 - 调度器开始执行第一个
Work,同时启动一个 5 毫秒的计时器(这就是时间片)。 - 5 毫秒后,计时器响起!调度器立即中断当前
Work的执行,即使Task C还没完成。 - 它把控制权交还给浏览器。浏览器现在可以去处理其他事情了,比如响应鼠标移动、播放 CSS 动画等。页面依然流畅。
- 在下一帧的空闲时间,React 的调度器(通过
MessageChannel)会再次被唤醒。 - 它回到最小堆,发现最高优先级的任务仍然是未完成的
Task C。 - 于是,它从上次中断的地方继续执行
Task C的下一个Work,同样只执行 5 毫秒。 - 这个“执行 5ms -> 中断 -> 让出控制权 -> 下一帧继续”的循环,就是时间切片。直到
Task C完全完成,调度器才会从堆里取出下一个任务Task D,用同样的方式去处理它。
第三步:React 的调度器 (自制 requestIdleCallback) 统一指挥
整个过程的总指挥,就是 React 的调度器。它就是那个“自制的 requestIdleCallback”。
- 它用最小堆来管理所有待办事项,并决定 “下一个该做什么?”(优先级调度)。
- 它用时间切片来控制每一个任务的执行方式,确保 “怎么做才不会卡死?”(不阻塞主线程)。
- 它用
MessageChannel等技术来获得一个可靠的执行时机,而不是像浏览器原生的requestIdleCallback那样“看心情”被调用。
提问 1:阻塞问题
前面提到:
- 假设 Task C 是渲染一个柱状图,在执行 5ms 后被中断,此时柱状图尚未渲染完成。
- 浏览器接着去处理其他任务,比如播放一个持续的 CSS 加载动画(比如旋转的小图标)。
问题来了:如果这个 CSS 动画一直在运行(比如每帧都需要重绘),那浏览器是否还有“空闲时间”留给 React 继续执行 Task C?换句话说,持续的动画是否会阻塞 React 的后续调度?
答案是:不会的! 这背后有一个关键的浏览器优化机制:主线程 和 合成器线程 的分离。
合成器线程会流畅地播放加载小图标的 CSS 动画(比如旋转)。这个过程完全不占用主厨的时间。
提问 2:与宏任务之间的关系
React 的调度器将一个渲染 Task 的 callback 函数放入一个宏任务中执行。在这个宏任务内部,React 通过一个 workLoop 循环来执行具体的 Work 单元,并用 shouldYield() 主动检查时间,确保在一个时间片(约 5ms)内结束。如果 Task 未完成,React 会放弃当前宏任务,让出主线程,并在下一个宏任务中“重启”该任务,利用 Fiber 树的优化机制高效地跳过已完成的部分,继续推进工作。