普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月18日首页

大白话讲 React 原理:Scheduler 任务调度器

作者 庞囧
2025年10月18日 17:23

前言

这篇文章是我自己理解 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 开始(JavaScript 就是这样),那么:

某个节点在数组中的位置是 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。一旦时间片用完,即使任务还没完成,我们也主动中断,把主线程的控制权交还给浏览器,让它去处理更高优先级的工作(如渲染、用户交互)。等浏览器空闲下来,我们再从中断的地方继续执行

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

三个核心概念详解

1. Task (任务)

  • 定义: 一个完整的、待调度的工作单元。它是一个抽象的概念,代表了一项需要完成的工作。

  • 角色: 它是被调度的对象。调度器(Scheduler)管理着一个任务队列,决定哪个 Task 应该在何时被执行。

  • 核心属性:

  • id: 任务的唯一标识。
  • callback: 任务具体要执行的函数(见下文)。
  • priority: 任务的优先级(如:立即执行、高、中、低、空闲时执行)。
  • expirationTime: 任务的过期时间,如果超过这个时间还没执行,可能会被提升优先级。
  • startTime: 任务的开始时间。
  • 关键特性:可中断与可恢复。 这是时间切片能够实现的基础。Task 的执行状态可以被保存,并在下次被调度时恢复。
  • 比喻: 一份完整的 “工作订单”。比如“渲染一个包含1000个商品的列表”就是一份大订单。

2. Callback (回调函数)

  • 定义: Task 中具体要执行的逻辑代码,通常是一个函数。

  • 角色: 它是任务的 “灵魂”和“内容”。没有 Callback,Task 就只是一个空壳,不知道要做什么。

  • 执行方式: Callback 函数在被调用时,通常会接收一个 didTimeout 参数和一个 deadline 对象,让函数内部可以判断自己是否还有时间继续执行。

  • 比喻: “订单上的具体施工步骤”。比如,对于渲染列表的订单,Callback 里面就写着:“1. 计算每个商品的位置;2. 创建对应的 DOM 节点;3. 插入到页面中……”

3. Work (工作单元)

  • 定义: 从 Task 的 Callback 中拆分出的、一小段实际被执行的代码片段。

  • 角色: 它是调度器实际执行的最小单位。我们不是一次性执行完整个 Callback,而是在一个时间片内,执行 Callback 中的一小部分,这一小部分就是一个 Work。

  • 实现: 通常通过 while 循环结合 deadline.timeRemaining() 来实现。循环体里的每一次迭代,都可以看作是一个 Work。

  • 比喻: “施工步骤中的一个具体动作”。比如“创建一个 DOM 节点”这个动作,就是一个 Work。

关键区别:Work vs. 时间片

这是最容易混淆的地方,必须彻底分清!

概念 本质 作用 比喻
时间片 一段时间预算(例如 5ms) 限制任务的执行时长,防止其无限运行。 “老板规定,你处理这个订单最多只能用5分钟”
Work 一小段代码 任务的具体执行内容,在时间片内被运行。 “在这5分钟内,你拧了3颗螺丝”,“拧一颗螺丝”这个动作就是一个 Work。

它们的关系:

调度器在一个 时间片 (5ms) 内,会尽力去执行一个或多个 Work (拧螺丝)。如果时间片用完了,即使当前的 Work (拧螺丝) 刚进行到一半,也要强制中断,把“扳手”放下,等待下一个时间片再继续。

工作流程比喻(餐厅后厨)

  • 主线程: 厨师(一次只能专心做一件事)。
  • 调度器: 后厨经理(负责分配任务和监督)。
  • 大订单: 一个100人的宴会订单。
  • Callback: 宴会菜单上的菜谱(比如“宫保鸡丁”的详细做法)。
  • Work: 菜谱中的一个步骤(比如“切丁”、“腌制”、“下锅爆炒”)。
  • 时间片: 经理规定,每5分钟必须停下来看一下门口有没有新客人点单

流程:

  1. 经理拿到宴会订单,把它拆成100个“炒一份宫保鸡丁”的小任务。
  2. 经理对厨师说:“开始做第1份宫保鸡丁,记住,每5分钟停一下!”
  3. 厨师开始执行菜谱:切丁、腌制...(这些都是 Work)。
  4. 5分钟后,经理的闹钟响了(时间片到),他立刻打断厨师:“停!先去处理门口新客人的点单(高优先级任务,如用户点击)!”
  5. 厨师放下手中的锅铲,去给新客人点单、上菜。
  6. 处理完高优先级任务后,经理对厨师说:“好了,回来继续做刚才那份宫保鸡丁吧。”
  7. 厨师从中断的地方(比如刚要下锅爆炒)继续执行。
  8. 这个过程不断重复,直到宴会订单完成。餐厅既没耽误大生意,也没怠慢新客人,运转流畅。

核心要点总结

时间切片是一种 “以退为进” 的调度策略。它通过主动让出控制权,换取了主线程的高响应性。其本质不是让任务执行得更快,而是让长任务的执行过程对用户无感知,从而打造出流畅、不卡顿的用户体验。它是现代前端框架(如 React)实现复杂 UI 更新而不阻塞交互的关键技术之一。

最小堆和时间切片的结合

将时间切片与最小堆结合,是为了解决一个更高级的问题:当有大量任务需要调度时,如何高效、公平地选择下一个要执行的任务?

简单来说:

  • 时间切片 解决了 “如何执行一个任务而不阻塞主线程” 的问题。
  • 最小堆 解决了 “在众多任务中,应该优先选择哪一个来执行” 的问题。

下面我们详细拆解它们是如何天衣无缝地结合在一起的。

一、核心问题:为什么需要最小堆?

想象一下,你有一个任务列表,里面有成千上万个任务。每个任务都有一个“优先级”或者“过期时间”。

  • 任务A: 优先级极高(比如用户点击触发的),但执行时间很短。
  • 任务B: 优先级低(比如后台数据分析),但执行时间很长。
  • 任务C: 优先级中等,但马上就要过期了。

如果你的任务调度器只是一个简单的数组,每次要找下一个任务时,你都需要遍历整个数组,找到优先级最高或最紧急的那个。当任务数量巨大时,这个查找过程本身就会消耗性能,甚至可能造成卡顿。

最小堆就是解决这个“查找”问题的利器。 它是一种特殊的优先级队列,可以让你在 O(1) 的时间内(极快)获取到所有任务中“最小”的那个元素(在我们的场景里,就是优先级最高或最早过期的任务),而插入和删除操作也只需要 O(log n) 的时间,非常高效。

二、概念升级:如何将三个核心概念映射到最小堆

  • Task:一个独立的任务,有很多属性。 一个带有“排序键”的独立任务。 这个排序键就是用来在最小堆中比较的依据。最常见的排序键是:
    1. priority (数字越小,优先级越高)
    2. expiryTime (时间戳越小,越早过期,越紧急)

  • Callback:任务里面具体做什么事情。 保持不变。 它是 Task 的一个属性,定义了任务的具体逻辑。

  • Work:一段 x 毫秒的时间片。 保持不变。 它是执行 Callback 的基本时间单位。

  • 最小堆一个“智能任务队列”或“调度器”。 它专门用来存放所有的 Task,并自动根据它们的“排序键”进行排序,确保我们总能最快地拿到最紧急的任务。

三、结合后的完整工作流程

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

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

第1步:任务创建与入队

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

我们将这些任务插入到最小堆中。最小堆会自动根据它们的排序键(这里我们假设用 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 回调被触发,准备开始工作时,调度器都会去最小堆里查找当前应该执行哪个任务。这保证了高优先级的任务总是被优先处理。

场景举例

场景设置

这个页面包含:

  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 的后续调度?

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

让我们用一个更精确的模型来理解这个过程。

浏览器的“双核”工作模式

想象一下,浏览器渲染页面就像一个高级厨房。

  • 主线程:这是主厨。他负责所有复杂、精细的工作:

    • 运行 JavaScript (包括 React 的时间切片和任务调度)。
    • 计算 HTML 元素的样式 (CSS)。
    • 构建页面的布局 (Layout)。
    • 绘制页面的初始内容 (Paint)。
    •   主厨一次只能做一件事。如果他被一个超大的 JS 任务卡住,整个厨房就停摆了。
  • 合成器线程:这是糕点师。他专门负责处理简单、重复性、可以并行的工作:

    • 将主厨已经画好的图层(比如加载小图标)进行移动、缩放、旋转。
    • 处理 CSS 的 transformopacity 动画。
    •   糕点师有自己的工作台,不需要主厨的干预。他可以独立、流畅地完成自己的工作。

5ms 后发生了什么?

现在,我们用“主厨”和“糕点师”的模型,重新走一遍流程。假设屏幕刷新率是 60fps,那么每一帧的时间预算是 16.67ms

第 1 帧 (0ms - 16.67ms)

  1. (0ms - 5ms) 主厨工作

    1. React 的调度器唤醒,告诉主厨:“开始渲染柱状图!”
    2. 主厨开始执行 Task C 的第一块 Work(比如计算第一行数据)。
    3. 5ms 后,闹钟响了! 调度器强制中断:“停!时间到,把锅放下!”
  2. (5ms - 10ms) 主厨继续做其他必要工作

    1. 主厨放下柱状图,开始处理这一帧必须完成的任务:计算样式、布局、绘制。他把加载小图标这个图层画好了。
  3. (10ms - 16.67ms) 糕点师开始工作 & 主厨空闲

    1. 主厨把画好的图层(包括加载小图标)交给糕点师。
    2. 糕点师接管! 他开始独立地、流畅地播放加载小图标的 CSS 动画(比如旋转)。这个过程完全不占用主厨的时间
    3. 此时,主厨就空闲下来了!他站在那里,等待下一个指令。这个空闲时间可能很短,比如 3ms。
  4. React 的调度器再次出击

    1. React 的调度器(通过 MessageChannel)一直在观察。它发现主厨空闲了,于是立刻把 Task C 的下一块 Work 作为一个新任务,扔到主厨的任务队列里。
    2. 因为现在主厨是空闲的,他会立刻拿起这个新任务,开始执行下一块 Work

第 2 帧 (16.67ms - 33.33ms)

  • 这个过程会重复。主厨可能又干了 5ms 的柱状图活儿,然后被中断,去处理其他必要工作,然后再次空闲下来,被调度器叫醒继续干柱状图的活儿。
  • 与此同时,糕点师从未停歇,一直在他那边流畅地播放着加载动画。

核心要点总结

  1. CSS 动画不等于 JS 任务:我们通常说的流畅的 CSS 动画(使用 transformopacity)是由合成器线程独立处理的,它不会阻塞主线程。
  2. “空闲时间”是真实存在的:它是指在一帧内,主线程完成了所有必要的渲染工作后,到下一帧开始前的短暂间隙。React 的调度器目标就是抢占这些零碎的空闲时间。
  3. 中断是为了更好的协作:时间切片的中断机制,就是为了确保主厨(主线程)不会被一个巨大的任务(渲染整个柱状图)累死,从而能及时响应其他紧急任务(比如用户点击),并完成每一帧的必要渲染工作。
  4. MessageChannel 的作用:它像一个精准的传令兵,能在当前任务队列清空后,立刻把下一个 React 任务插进去,确保能最大化地利用主线程的空闲时间,比 setTimeout(fn, 0) 更可靠。

所以,你的画面里,加载小图标一直在流畅旋转(糕点师在工作),而柱状图在后台一点一点地、不卡顿地被绘制出来(主厨在见缝插针地工作)。这就是 React 并发渲染带来的流畅体验!

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

React 调度器是如何工作的?

React 的调度器(Scheduler)并不会为每一个 Work 创建一个宏任务。那样效率太低了。它的策略是:

  1. 开启一个“打包会话”(一个宏任务): 调度器通过 MessageChannelsetTimeout 等方式,向浏览器的宏任务队列里放入一个回调函数。我们称这个回调函数为 flushWork。当浏览器轮到这个任务时,flushWork 开始执行。这一个 flushWork 就是一个宏任务。

  2. 在“会话”内疯狂打包(执行多个 Work): flushWork 函数内部有一个循环。这个循环会做以下事情:

    1. 从任务队列中取出一个 Work。
    2. 执行这个 Work 的 Callback。
    3. 执行完毕后,检查当前时间。是否超过了时间片(比如 5ms)?
    4. 如果没超过:继续循环,取出下一个 Work 并执行。
    5. 如果超过了:立即 break 退出循环。
  3. 结束“会话”,并预约下一次:

    1. 当循环因为时间用完而退出时,flushWork 函数就执行完毕了。这个宏任务结束。
    2. 此时,主线程的控制权交还给浏览器,浏览器可以去处理渲染、用户输入等其他事情。
    3. 如果还有未完成的 Work,调度器会在 flushWork 结束前,再次通过 MessageChannel 预约一个新的宏任务,以便在下一轮事件循环中继续打包。

举例说明

假设我们有 Work A, B, C, D,时间片是 5ms。

  1. 调度开始:React 调度器通过 MessageChannelflushWork 回调放入宏任务队列。
  2. 宏任务 #1 开始:浏览器的事件循环取出 flushWork 并执行。
  3. 执行 Work A:耗时 2ms。剩余时间:3ms。
  4. 执行 Work B:耗时 2ms。剩余时间:1ms。
  5. 执行 Work C:耗时 2ms。时间超了!(总耗时 2+2+2 = 6ms)。
  6. 中断:flushWork 中的循环检测到时间超限,立即中断。Work C 可能只执行了一半,或者执行完了但时间也刚好用完。
  7. 宏任务 #1 结束:flushWork 函数执行完毕。主线程空闲。
  8. 浏览器工作:浏览器处理页面渲染、响应用户点击等。
  9. 调度继续:在宏任务 #1 结束前,调度器已经预约了 flushWork 的下一次执行。
  10. 宏任务 #2 开始:在下一轮事件循环中,浏览器取出新的 flushWork 并执行。
  11. 继续执行:循环从上次中断的地方继续,开始执行 Work D…
  12. …如此往复,直到所有 Work 完成。
昨天以前首页
❌
❌