通俗易懂讲 React 原理-第一集:Scheduler 任务调度器

223 阅读20分钟

前言

这篇文章是我自己理解 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 = 1heap[1] = 2
    • 右孩子:2*0+2 = 2heap[2] = 3
  • heap[1] = 2

    • 左孩子:2*1+1 = 3heap[3] = 5
    • 右孩子:2*1+2 = 4heap[4] = 4
  • heap[3] = 5(叶子节点)

    • 左孩子索引是 2*3+1 = 7,但数组长度才 5,说明它没孩子了 ✅

再看爸爸是谁:

  • heap[4] = 4,它的爸爸索引是 Math.floor((4-1)/2) = Math.floor(3/2) = 1heap[1] = 2

完美对应!


❓ 为啥能这么干?

因为最小堆必须是“完全二叉树”——意思就是:

  • 除了最后一层,上面都填满;
  • 最后一层也必须从左往右紧挨着放,不能跳着空位。

这种“严丝合缝”的结构,正好可以按顺序塞进数组,不会浪费位置,也不会搞混谁是谁的孩子。


🛠 实际好处

  • 不用写复杂的树节点(比如 left/right 指针)
  • 内存连续,访问快
  • 插入/删除时,只要用索引算父子,往上“冒泡”或往下“下沉”调整就行

比如你往堆里加个新数,就先塞到数组末尾,然后不断跟“爸爸”比,如果比爸爸小,就交换,直到满足“爸爸 ≤ 孩子”——这个过程叫 上浮(heapify up)

删最小值(堆顶)时,把最后一个数挪到顶部,然后不断跟两个孩子中更小的那个比,如果比孩子大,就交换——这叫 下沉(heapify down)

全程只用数组和索引计算,超高效!

时间切片

为什么需要时间切片?(核心思想)

JavaScript 是单线程的,意味着在主线程上,一次只能做一件事。如果一个任务(比如一个复杂的计算、渲染一个上万条数据的列表)执行时间过长(比如超过 50ms),它就会“霸占”主线程。

后果:

  • 页面卡顿: 浏览器无法及时响应用户的点击、输入等交互。

  • 渲染延迟: 页面的动画、滚动等视觉效果会掉帧,看起来不流畅。

  • 阻塞其他任务: 其他重要的任务(如用户输入事件、定时器)只能排队等待,导致整体体验下降。

时间切片的解决方案:

时间切片的核心思想是 “合作式调度”。我们主动将一个大的、可能阻塞主线程的 Task,拆分成许多个小的 Work。然后,我们设定一个时间预算(即“时间片”),在每个时间片内执行一个或多个 Work。一旦时间片用完,即使任务还没完成,我们也主动中断,把主线程的控制权交还给浏览器,让它去处理更高优先级的工作(如渲染、用户交互)。等浏览器空闲下来,我们再从中断的地方继续执行

一句话总结:把大任务拆小,分批执行,超时就停,保证主线程随时可响应。

核心概念详解

用例子举例理解:假设我们有一个输入框和下面一个包含 20,000 个项目的列表。当用户在输入框里输入文字时,列表需要实时过滤。

Task (任务)

它是被调度的对象。调度器(Scheduler)管理着一个任务队列,决定哪个 Task 应该在何时被执行。那么在这个例子中,task 有可能就是:

  1. 用户输入框的每一个字符的行为;
  2. 列表过滤;

核心属性:

  • 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 的时候提到过两个属性:priorityexpirationTime

让我们用一个完整的例子来走一遍流程:

场景: 我们有一个调度器,它内部使用一个最小堆来管理任务。

第 1 步:任务创建与入队

  1. 一个用户点击事件产生了一个高优先级任务 Task_A,它的 priority1
  2. 一个后台数据同步任务 Task_B 被创建,它的 priority10
  3. 一个需要在 timestamp 1000 之前完成的定时任务 Task_C 被创建,它的 expirationTime1000

我们将这些任务插入到最小堆中。最小堆会自动根据它们的排序键(这里我们假设用 priority)进行排列。堆顶永远是 priority 最小的任务。

最小堆内部状态 (以priority为键):
      (Task_A, p:1)
     /           \
(Task_C, p:5)   (Task_B, p:10)

(注意:Task_C 的 priority 我假设为 5,用于示例)

第 2 步:调度器启动

调度器的主循环开始(通常使用 requestIdleCallbackMessageChannel 来实现)。

第 3 步:获取下一个任务

  1. 调度器问最小堆:“给我下一个最紧急的任务。”
  2. 最小堆不需要遍历,直接返回堆顶元素:Task_A。这个操作是 O(1),极快。
  3. 调度器将 Task_A 从堆中“取出”(pop),这个操作是 O(log n)

第 4 步:执行时间切片

  1. 调度器开始执行 Task_ACallback
  2. 它启动一个计时器,时间片为 5ms
  3. Task_ACallback 开始执行。假设它是一个复杂的计算,需要 20ms 才能完成。

第 5 步:时间片用尽,让出控制权

  1. 5ms 后,计时器响起。调度器强制中断 Task_A 的执行。
  2. 此时,Task_A 还没完成。调度器需要决定如何处理它。
  3. 关键决策:由于 Task_A 还没完成,我们需要把它重新放回最小堆,以便后续继续执行。它的 priority 通常保持不变。

第 6 步:循环继续

  1. Task_A 被重新插入堆中。堆再次自动调整,Task_A 因为 priority 最低,很可能又回到了堆顶。
  2. 调度器回到第 3 步,再次从堆顶获取任务。它又拿到了 Task_A
  3. 调度器继续执行 Task_ACallback 5ms...
  4. 这个过程重复 4 次后,Task_A 终于执行完毕。

第 7 步:任务完成

当一个任务的 Callback 在一个时间片内执行完毕,调度器就不会把它再放回堆中。它被彻底丢弃。

如果在执行过程中来了新任务怎么办?

假设在第 4 步执行 Task_A 的第一个时间片时,一个 priority0 的紧急任务 Task_D 进来了。它会被立即插入堆中,并成为新的堆顶。当 Task_A 的时间片用尽并被重新插入堆后,调度器下一次获取的将是 Task_D,而不是 Task_A。这就保证了高优先级任务总能被优先处理。

总结

所以,当你看到 React 的 Scheduler(调度器)源码时,你会发现它内部就实现了一个最小堆,用来管理各种不同优先级的更新任务(比如用户输入、数据请求、动画等),然后通过时间切片的机制,在浏览器空闲时去执行这些任务。这正是 React 18 并发特性的基石。

如何调度

理论我们都已经知道了,那么就需要了解调度的实现。

一、什么是原生的 requestIdleCallback (rIC)?

这是浏览器提供的一个 API,它的初衷非常好:

“开发者,你给我一个回调函数。我(浏览器)会在主线程空闲的时候,也就是处理完渲染、用户输入等高优先级任务后,调用你的函数。这样你就可以在不影响用户体验的情况下,做一些不那么重要的事情。”

它还提供了一个 deadline 对象,告诉你还剩多少空闲时间 (timeRemaining()),这简直就是为时间切片量身定做的!

理想很丰满: React 最初也想直接用它来调度低优先级的更新。

二、为什么 React 不直接用原生的 requestIdleCallback

现实很骨感,原生的 rIC 存在几个致命缺陷,导致它无法支撑 React 的并发模式:

  1. 触发频率太低,甚至不触发

    1. 如果浏览器一直很忙(比如有复杂的动画、大量计算),rIC 的回调可能永远不会被执行。这意味着 React 的更新可能会被无限期推迟,导致界面看起来“卡死了”。
    2. React 需要一个更可靠的机制,确保任务最终一定会被执行,而不是“有空再说”。
  2. 执行时机不可控

    1. rIC 通常在一帧的末尾被调用。但 React 的并发调度需要更精细的控制,比如在一帧的中间就插入一个高优先级任务,或者在一个空闲周期内执行多个小任务。rIC 的粒度太粗了。
  3. 兼容性问题

    1. Safari 对 rIC 的实现有 Bug,并且触发频率极低,基本不可用。React 必须保证在所有主流浏览器上表现一致。
  4. 缺乏优先级控制

    1. rIC 只是一个“有空就做”的机制,它本身没有优先级概念。而 React 的调度器需要处理非常复杂的优先级(比如用户输入 > 动画 > 数据获取 > 页面懒加载),rIC 无法满足这种需求。

三、React 的解决方案:自定义调度器

由于原生的 rIC 靠不住,React 团队做了一个大胆的决定:我们自己造一个!

这个自定义的调度器(在 scheduler 包中)就是时间切片和最小堆的完美结合体,它解决了 rIC 的所有问题。

它是如何工作的?

  1. 触发机制:不用 rIC,改用 MessageChannel

    1. React 使用 MessageChannelpostMessage API 来调度任务。这两个 API 会将一个任务作为宏任务 推送到任务队列的末尾。
    2. 这就模拟了一个“空闲”状态:当前同步代码执行完毕,浏览器处理完微任务和渲染后,就会来执行这个宏任务。这比 rIC 可靠得多,因为它保证会在下一个事件循环中被触发
  2. 时间切片的实现

    1. MessageChannel 的回调被触发时,React 的调度器就开始工作。
    2. 它从最小堆中取出优先级最高的任务。
    3. 开始执行这个任务的 Callback(也就是 Work)。
    4. 在执行过程中,它会不断检查时间,看是否超过了预设的时间片(比如 5ms)。
    5. 如果时间片用完,它就主动中断,然后再次通过 MessageChannel 把自己(剩下的工作)安排到下一个宏任务中,从而让出主线程。
  3. 最小堆的作用

    1. 在每次 MessageChannel 回调被触发,准备开始工作时,调度器都会去最小堆里查找当前应该执行哪个任务。这保证了高优先级的任务总是被优先处理。

这里知道 React 使用 MessageChannelpostMessage API 来调度任务就行了,除非你想深入理解,那么推荐直接去看源码。

综合场景举例

现在你已经了解了什么是最小堆、Task、Callback、Work、MessageChannel,并且知道他们之间协作的原理,那么接下来我们来看看一个综合的场景,加深理解。

场景设置

这个页面包含:

  1. 一个侧边栏,上面有多个筛选器(如日期范围、用户类型等)。
  2. 主内容区,显示了三个复杂的图表:一个柱状图、一个折线图和一个饼图。每个图表的数据都需要经过大量计算才能渲染出来。

用户操作

用户在侧边栏点击了“显示上个月数据”的按钮。

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 ATask B 很小,瞬间就完成了。

接下来,轮到了 Task C(重新计算并渲染柱状图)。这是一个大任务,可能需要 100 毫秒才能完成。如果直接执行,页面会卡死 100 毫秒!

这时,时间切片机制启动了:

  1. React 调度器不会一次性执行完 Task C。它会把这个大任务拆分成很多个小 Work(比如,计算第一行数据是一个 Work,计算第二行数据是另一个 Work)。
  2. 调度器开始执行第一个 Work,同时启动一个 5 毫秒的计时器(这就是时间片)。
  3. 5 毫秒后,计时器响起!调度器立即中断当前 Work 的执行,即使 Task C 还没完成。
  4. 它把控制权交还给浏览器。浏览器现在可以去处理其他事情了,比如响应鼠标移动、播放 CSS 动画等。页面依然流畅。
  5. 在下一帧的空闲时间,React 的调度器(通过 MessageChannel)会再次被唤醒。
  6. 它回到最小堆,发现最高优先级的任务仍然是未完成的 Task C
  7. 于是,它从上次中断的地方继续执行 Task C 的下一个 Work,同样只执行 5 毫秒。
  8. 这个“执行 5ms -> 中断 -> 让出控制权 -> 下一帧继续”的循环,就是时间切片。直到 Task C 完全完成,调度器才会从堆里取出下一个任务 Task D,用同样的方式去处理它。

第三步:React 的调度器 (自制 requestIdleCallback) 统一指挥

整个过程的总指挥,就是 React 的调度器。它就是那个“自制的 requestIdleCallback”。

  • 它用最小堆来管理所有待办事项,并决定 “下一个该做什么?”(优先级调度)。
  • 它用时间切片来控制每一个任务的执行方式,确保 “怎么做才不会卡死?”(不阻塞主线程)。
  • 它用 MessageChannel 等技术来获得一个可靠的执行时机,而不是像浏览器原生的 requestIdleCallback 那样“看心情”被调用。

提问 1:阻塞问题

前面提到:

  1. 假设 Task C 是渲染一个柱状图,在执行 5ms 后被中断,此时柱状图尚未渲染完成。
  2. 浏览器接着去处理其他任务,比如播放一个持续的 CSS 加载动画(比如旋转的小图标)。

问题来了:如果这个 CSS 动画一直在运行(比如每帧都需要重绘),那浏览器是否还有“空闲时间”留给 React 继续执行 Task C?换句话说,持续的动画是否会阻塞 React 的后续调度?

答案是:不会的! 这背后有一个关键的浏览器优化机制:主线程合成器线程 的分离。

合成器线程会流畅地播放加载小图标的 CSS 动画(比如旋转)。这个过程完全不占用主厨的时间

提问 2:与宏任务之间的关系

React 的调度器将一个渲染 Task 的 callback 函数放入一个宏任务中执行。在这个宏任务内部,React 通过一个 workLoop 循环来执行具体的 Work 单元,并用 shouldYield() 主动检查时间,确保在一个时间片(约 5ms)内结束。如果 Task 未完成,React 会放弃当前宏任务,让出主线程,并在下一个宏任务中“重启”该任务,利用 Fiber 树的优化机制高效地跳过已完成的部分,继续推进工作。