React 19.3.1 源码深度剖析-第 5 章 Scheduler 调度器与时间切片

3 阅读1小时+

React 19.3.1 源码深度剖析-第 5 章 Scheduler 调度器与时间切片

源码版本:React 19.3.1(packages/scheduler/src/forks/Scheduler.js 等) 阅读前置:第 3 章《Fiber 架构与双缓冲》(含 Lane 优先级模型)、建议先读第 8 章《并发特性(Concurrent Features)》以便理解“为什么需要可中断渲染”。 本章目标:把 React 并发渲染的“发动机”彻底讲透——调度器如何把纤程(Fiber)工作拆成可中断、可让出、可续体(continuation)的时间切片(time slice),以及它如何用最小堆与 MessageChannel 宏任务在浏览器主线程上实现协作式多任务。


0. 本章在整套书中的位置

React 19 的“并发”(Concurrent)不是一个特性,而是一整套机制的总和:

  • 第 3 章的 Lane 负责“谁更重要、谁先被处理”——这是优先级语义层;
  • 第 3 章的 Fiber 负责“工作可以暂停、可恢复、可丢弃”——这是可中断的数据结构层;
  • 本章的 Scheduler 负责“在什么时候、让出多久、把时间片切到多细”——这是执行节奏层(引擎);
  • 第 8 章的 useTransition / useDeferredValue / Suspense 负责“把可中断能力暴露给开发者”——这是能力暴露层。

没有 Scheduler,Fiber 的“可中断”只是一句空话:Fiber 允许你暂停,但必须有人决定“何时暂停”。这个“决定者”就是 Scheduler。performUnitOfWork 每次循环开头都会问一句 shouldYieldToHost()(其实是经由 reconciler 工作的 workLoop 问),而这个判断背后的时钟、切片长度、让出时机,全部由本章要剖析的 Scheduler 提供。

一句话心智模型:

Scheduler 是一台装在浏览器主线程上的“协作式抢占调度器”:它用两个最小堆管理任务,用 MessageChannel 宏任务把工作切成 5ms 一片,在任务过期前让出主线程,把“可中断的 Fiber 渲染”变成现实。

1. 动机与背景:调度器为什么存在

要理解 Scheduler 存在的必要性,必须回到 React 渲染模型的根本矛盾:渲染是 CPU 密集的,而浏览器主线程是稀缺且单一的资源。

1.1 浏览器主线程上的“隐形税”

现代浏览器(Chromium / Gecko / WebKit)把几乎所有与“页面”相关的事情都塞进同一个线程——JavaScript 执行、样式计算(Style)、布局(Layout)、绘制(Paint)、合成(Composite)以及用户输入事件的分发,绝大多数都发生在**主线程(Main Thread)**上。虽然合成与某些绘制可以下放到合成线程(Compositor Thread),但 JS 逻辑、React 的 reconciliation、DOM 变更全部在主线程串行执行。

这意味着一条铁律:

只要 JavaScript 在跑,用户就既点不动按钮、也看不到页面更新。

这就是所谓的“长任务(Long Task)”问题。W3C 的 Long Tasks API 把“执行时间超过 50ms 的任务”定义为长任务;一旦一个 task 超过一个帧预算(60fps 下约为 16.67ms),浏览器就会丢掉这一帧,用户肉眼看到的就是卡顿(jank)、输入延迟、滚动不跟手。Google 的 RAIL 性能模型把响应(Response)目标定在 100ms 内、动画(Animation)定在 16ms 内、空闲(Idle)用于预加载。React 作为一个 UI 库,所有渲染都吃主线程时间,所以它天然是“长任务制造机”。

1.2 React 15 的 Stack Reconciler:不可中断的噩梦

在 React 15 及更早版本里,reconciler 是“栈式”的:一个 setState 触发一次完整递归,从根组件一路递归遍历整棵虚拟 DOM 树,边遍历边生成真实 DOM 变更,整个过程是一个同步的、不可打断的函数调用栈。你可以把它想象成一列没有中途站点的火车:一旦发车,必须到终点才能停。

这带来两个致命问题:

  1. 渲染与交互互斥:如果组件树很大(比如渲染 10000 行表格),这一次递归可能耗去几百毫秒。在这几百毫秒里,浏览器无法处理任何用户输入、无法绘制、无法响应滚动。用户体验是“点了没反应,页面冻住”。
  2. 优先级无从谈起:栈式递归里“先算哪棵子树”完全由组件树顺序决定,你无法说“这个输入框的更新比那个列表的更新更紧急,先把它算完”。所有更新一视同仁地同步执行完。

React 团队在 2016–2017 年的多次演讲(比如 Lin Clark 的 “React Fiber” 演讲)里明确点出:问题不是“渲染慢”,而是“渲染不可中断”。如果我们能让渲染“暂停,去处理一下用户输入,再回来接着渲染”,卡顿问题就迎刃而解。

1.3 Fiber:把工作切成可以暂停的单元

第 3 章已经讲透,React 16 引入的 Fiber 架构把“渲染一个组件树”这个大任务,拆成了一个个可独立执行、可暂停、可恢复的工作单元(unit of work)。performUnitOfWork 每处理一个 Fiber 节点就检查一次“是否该让出了”。Fiber 提供了“能暂停”的能力,但它自己并不知道“何时暂停”。

这就是关键的空缺:Fiber 是“可中断的数据结构”,但还缺一个“决定何时中断的时钟与策略”。这个空缺由 Scheduler 填补。一句话:

Fiber 说“我可以停”,Scheduler 说“现在就停”。

1.4 为什么不直接用浏览器原生的 requestIdleCallback?

一个自然的想法是:浏览器既然提供了 requestIdleCallback(RIC),何不把渲染切片塞进“空闲时段”?React 早期(v16 实验期)确实尝试过 RIC,但最终放弃了,原因有以下几点,这些原因直接塑造了自研 Scheduler 的设计:

  1. 兼容性差:requestIdleCallback 长期只有 Chrome 实现,Safari / iOS WebView 始终不支持。一个要服务全体用户的 UI 库不能依赖一个浏览器独有的 API。
  2. 触发时机太保守:RIC 只在“浏览器确信接下来一段时间空闲”时才回调,而 React 的渲染往往是“用户刚交互完、正需要尽快把结果画出来”的场景——此时浏览器并不空闲,RIC 会迟迟不触发,导致渲染被无限推迟(latency 暴涨)。
  3. 50ms 钳制(clamp):为了不饿死其他任务,RIC 的 deadline 被钳制在 50ms 量级,而且它不与渲染(帧)对齐。一个 50ms 的回调足以让三帧掉光,和“时间切片”的初衷(每片 < 16ms)相悖。
  4. 不表达优先级:RIC 只允许你设置 timeout,没有多优先级概念,无法区分“用户输入”和“后台预取”的紧急程度。
  5. 无法主动让出以绘制:React 需要在“主线程被 JS 占满、但浏览器其实想绘制了”的时候主动让出,RIC 不提供这个信号。

因此 React 团队从 16.5/16.6 起把调度逻辑从 react-reconciler 抽出,独立成 @react-three 之外的一个包——scheduler,并自研了一套基于 MessageChannel 宏任务的调度机制。这就是本章要剖析的对象。值得强调的是:本版本(19.3.1)的 DOM 主分支 Scheduler.js 已经把当年独立的 SchedulerDOM.js 合并进来,所以“选择 MessageChannel 还是 setTimeout”的逻辑直接在本文件里。

1.5 “协作式抢占”:在单线程里假装多任务

操作系统有“抢占式调度”:内核可以在任意时刻强制把 CPU 从一个进程抢走给另一个。但 JavaScript 是单线程,没有真正的抢占——除非当前函数主动 return 或 await,否则没人能打断它。

React 的解法是协作式(cooperative)调度:每个工作单元做完后主动问一圈“我是不是该让出了?”,如果答案是 yes,就主动 return,把控制权交还给浏览器(即结束当前宏任务),等下一个宏任务再继续。Scheduler 的 workLoop 就是“主动问、主动 return”的执行者。这套机制在单线程里模拟出了一个抢占式调度器的效果:低优任务即使没跑完,也会被高优任务“挤掉”,因为高优任务总是排在最小堆前面、更早被取出来执行。

理解这一点至关重要:Scheduler 不是真的在“抢占”你的函数,而是你的函数(Fiber work)在“每做一个小步骤后,礼貌地让出”。 所以“可中断渲染”的另一个名字是“协作式可中断渲染”。

1.6 时间切片(Time Slicing)到底是什么

“时间切片”是 Scheduler 对外最直观的能力:把一整块大渲染工作,切成若干小块(默认每块约 5ms),块与块之间把主线程让出来给浏览器处理输入和绘制。注意这里有两个数字容易混淆:

  • 帧预算 16.67ms:一帧的时间(60fps)。这是“屏幕不卡”的硬上限。
  • 时间切片 5ms(frameYieldMs):Scheduler 默认的“每片工作量上限”,刻意设在帧预算的约 1/3 处。

Scheduler 默认不在 5ms 就让出一次(否则一帧里要反复进出宏任务,开销太大),而是跨越多帧累积,每片最多吃 5ms——也就是说它“每 5ms 的主线程占用后让出一次”,但让出后下一个宏任务可能要等下一帧才被调度。这一点在 §9 讲 shouldYieldToHost 与 frameInterval 时会有精确到行的论证。

1.7 小结:调度器解决的三件事

把本节压缩成一句话清单,Scheduler 解决的是:

  1. 节奏:一次最多在主线程上跑多久(5ms 切片)才让出。
  2. 优先级:哪块工作先跑( Immediate > UserBlocking > Normal > Low > Idle,由最小堆排序保证)。
  3. 可恢复:跑不完就存下进度,下一个宏任务接着跑(续体机制 + 堆里留存未完成任务)。

下节我们用一张全景图把这些关系钉死。

2. 整体心智模型:一张图看懂调度器

Scheduler 对外只有几个极简的 API:scheduleCallback(投递任务)、cancelCallback(取消)、shouldYield(查询是否该让出)、getCurrentPriorityLevel / runWithPriority(优先级上下文)、requestPaint(请求优先绘制)、forceFrameRate(调切片长度)。它的内部却由三层协作完成工作:

flowchart TB
    subgraph 外部[&#34;调用方(react-reconciler / 应用代码)&#34;]
        A[&#34;unstable_scheduleCallback(priority, callback, options)&#34;]
    end

    subgraph 内核[&#34;Scheduler 内核(forks/Scheduler.js)&#34;]
        B[&#34;计算 startTime / timeout / expirationTime&#34;]
        C{&#34;startTime > now ?&#34;}
        D[&#34;push 到 timerQueue<br/>(按 startTime 排序的堆)&#34;]
        E[&#34;push 到 taskQueue<br/>(按 expirationTime 排序的堆)&#34;]
        F[&#34;requestHostCallback()&#34;]
        G[&#34;MessageChannel 宏任务<br/>performWorkUntilDeadline&#34;]
        H[&#34;flushWork → workLoop&#34;]
        I{&#34;expirationTime > now<br/>且 shouldYieldToHost()?&#34;}
        J[&#34;执行 callback<br/>(Fiber work)&#34;]
        K{&#34;callback 返回函数?&#34;}
        L[&#34;设为续体 currentTask.callback<br/>立即 return true(让出)&#34;]
        M[&#34;pop 出队,继续下一个&#34;]
        N[&#34;让出主线程,结束宏任务&#34;]
    end

    subgraph 宿主[&#34;浏览器主线程&#34;]
        O[&#34;处理用户输入 / 绘制 / 布局&#34;]
    end

    A --> B --> C
    C -- 是(延时任务)--> D
    C -- 否(立即任务)--> E --> F
    D -. 到点后 advanceTimers 转移 .-> E
    F --> G --> H --> I
    I -- 是,让出 --> N --> O
    I -- 否,继续 --> J --> K
    K -- 是 --> L --> N
    K -- 否 --> M --> I
    O -. 下一帧再 postMessage .-> G

这张图把本章要讲的几乎所有关键角色都标了出来。请记住三个箭头方向:投递(schedule)→ 排队(heap)→ 执行(workLoop)→ 让出(yield)→ 再投递,这就是 Scheduler 的主循环。

下面把“一次完整链路”用时序图再表达一遍,这是本章必须画透的全链路(scheduleCallback → 消息循环 → workLoop → yield):

sequenceDiagram
    participant App as 调用方/事件
    participant Sch as Scheduler
    participant Heap as taskQueue/timerQueue
    participant MC as MessageChannel
    participant BT as 浏览器主线程

    App->>Sch: scheduleCallback(Normal, work)
    Sch->>Sch: 算 startTime/timeout/expirationTime
    Sch->>Heap: push(taskQueue) 按 expirationTime
    Sch->>MC: requestHostCallback → port.postMessage
    MC->>BT: 作为宏任务入队(绕开 4ms 钳制)
    BT-->>Sch: performWorkUntilDeadline 被调用
    Sch->>Sch: flushWork → workLoop 取堆顶
    loop 每个 task / 每个时间片
        Sch->>Sch: currentTime = now()
        alt expirationTime > now 且 shouldYield()
            Sch-->>MC: return true(让出)
            MC->>BT: 触发下一宏任务(下一帧)
        else 任务未过期
            Sch->>Sch: 执行 callback(Fiber work)
            alt 返回函数(续体)
                Sch-->>MC: currentTask.callback=续体, return true
            else 完成
                Sch->>Heap: pop(taskQueue)
            end
        end
    end
    Sch-->>BT: 无更多任务 → isMessageLoopRunning=false

读完这张图,你基本就能用一句话向别人讲清楚 Scheduler 了。接下来我们钻进它的数据结构。

3. 核心数据结构:Task、两个最小堆、全局状态

Scheduler 的“状态”极其集中:全部任务就放在两个数组(被当作最小堆使用)里,再加上十来个模块级变量。理解了这些,整个文件的运行逻辑就通透了。本节把每个字段、每个变量的来历、作用、赋值时机、关联对象讲清楚。

3.1 Task 类型:一个被调度的工作单元

这段来自 packages/scheduler/src/forks/Scheduler.js:49-57,是本章所有讨论的核心对象:

// packages/scheduler/src/forks/Scheduler.js:49-57
export opaque type Task = {
  id: number,
  callback: Callback | null,
  priorityLevel: PriorityLevel,
  startTime: number,
  expirationTime: number,
  sortIndex: number,
  isQueued?: boolean,
};

注意它写的是 export opaque type,也就是不透明类型(opaque type):模块外只能拿到 Task 这个类型名,不能直接构造或读取它的字段(Flow 的封装能力)。这保证了任务对象只能由 Scheduler 自己创建,调用方只能拿着返回的引用去 cancelCallback。

逐字段拆解:

字段类型含义何时赋值与谁关联
idnumber全局自增的任务序号,用于稳定排序的同值决胜(tie-breaker)unstable_scheduleCallback 里 taskIdCounter++SchedulerMinHeap.compare 用它做二级排序键
callbackCallback | null真正要执行的工作。可能是一个函数,也可能是 null(表示已取消)。函数若返回另一个函数,则那个返回值是“续体”投递时给定;执行前被置 null;续体返回时重新赋回Fiber 的 performWorkOnRootViaSchedulerTask
priorityLevelPriorityLevel任务的优先级(0~5,0 表示无)投递时由调用方指定决定 timeout,间接决定 expirationTime
startTimenumber任务最早可执行的时刻(延迟任务用)delay>0 时为 now+delay,否则为 nowtimerQueue 的排序键
expirationTimenumber任务过期的时刻;过期后不再让出startTime + timeouttaskQueue 的排序键;workLoop 用它判断“是否必须做完”
sortIndexnumber当前在堆中的排序键:在 taskQueue 里等于 expirationTime,在 timerQueue 里等于 startTime入队时赋值;从 timerQueue 转移时改写为 expirationTimeSchedulerMinHeap.compare 的一级排序键
isQueuedboolean?仅 profiling 构建下使用,标记任务是否已入队(用于 cancel 时打点)enableProfiling 时赋值SchedulerProfiling

有几个极易踩坑的设计点,先在此点破,后面会有源码印证:

  • callback 为 null 是“已取消”的标记,而不是“已执行完”。已执行完的任务是通过“从堆里 pop 掉”来移除的,其 callback 在执行前就被置 null 了,但节点仍留在堆顶直到被 pop。所以“callback===null”同时可能表示两种状态,需要结合上下文区分(§10 会专门讲这个坑)。
  • sortIndex 不是固定字段:一个任务在 timerQueue 里时 sortIndex = startTime,被 advanceTimers 转移到 taskQueue 时会被改写成 expirationTime(见 §8 advanceTimers)。这就是为什么堆里的排序键是“动态的”。
  • 续体(continuation)的载体就是 callback 字段本身:任务执行后如果返回函数,Scheduler 把这个返回值写回 currentTask.callback,然后让出;下一轮 workLoop 取出该任务时,callback 已经是续体函数,于是直接执行续体。这是一个非常巧妙的“原地复用”,避免了额外数据结构。

3.2 两个最小堆:taskQueue 与 timerQueue

这段来自 packages/scheduler/src/forks/Scheduler.js:78-80:

// packages/scheduler/src/forks/Scheduler.js:78-80
// Tasks are stored on a min heap
var taskQueue: Array<Task> = [];
var timerQueue: Array<Task> = [];

两个数组,都被 SchedulerMinHeap 当作二叉最小堆使用。二者职责严格区分:

  • taskQueue(任务队列):存放已经到期、可以立即执行的任务。排序键是 expirationTime(过期时间越早越靠前)。
  • timerQueue(定时器队列):存放还没到 startTime 的延时任务。排序键是 startTime(开始时间越早越靠前)。

为什么一定要用两个堆、而不是一个?因为延时任务的“什么时候该被拿出来”和“拿出来后谁先跑”是两件事:

  1. 在延时阶段,我们关心“哪个延时任务最早到点”——所以用 startTime 排序。
  2. 一旦到点转入可执行队列,我们关心“哪个任务最紧急(最该优先、最该先过期)”——所以用 expirationTime 排序。

如果把所有任务放一个堆,无法同时按两个维度排序。advanceTimers 负责在“到点”时把一个任务从 timerQueue 迁移到 taskQueue,并改写 sortIndex(§8 详述)。

关键约束(全章最重要的一个不变式):因为这两个数组被当作数组实现的二叉堆,而二叉堆只能高效删除堆顶(peek/pop),不能 O(1) 删除任意中间节点。所以:

取消一个任务时,Scheduler 不能把它从堆里“抠掉”,只能把它的 callback 置为 null,等轮到它(成为堆顶被 pop)时再丢弃。详见 unstable_cancelCallback(§11.1)。

这个约束直接决定了“取消任务不是即时生效”,也决定了 workLoop 里到处要检查 callback === null。

3.3 模块级全局状态变量

Scheduler 不用类、不用实例对象,全部状态都是模块级 var/局部变量。下面把 forks/Scheduler.js 里所有全局状态一次性列清:

变量行号作用
getCurrentTime59取当前时间的函数,优先用 performance.now(),否则退化成 Date.now() 差值
maxSigned31BitInt761073741823(= 2^30 - 1),31 位有符号整数上限,Idle 任务的 expirationTime 用它,代表“永不过期”
taskIdCounter83任务 id 自增计数器,保证插入顺序稳定
currentTask85当前正在执行的 Task,供 workLoop 与 profiling 使用
currentPriorityLevel86当前优先级上下文,由 runWithPriority/next/wrapCallback 维护,决定新投递任务的默认优先级
isPerformingWork89是否正在执行工作循环(防重入标志)
isHostCallbackScheduled91是否已经向宿主(浏览器)投递了一个任务回调(防止重复 requestHostCallback)
isHostTimeoutScheduled92是否已经用 setTimeout 注册了“延时任务到点”的定时器
needsPaint94是否有 pending 的绘制请求(由 requestPaint 置位),shouldYieldToHost 会因此立即让出
localSetTimeout/localClearTimeout/localSetImmediate97-101捕获原生 API 的本地引用,防止被 polyfill 改写
isMessageLoopRunning449消息循环是否正在跑(防止重复启动 MessageChannel 宏任务)
taskTimeoutID450requestHostTimeout 返回的定时器句柄
frameInterval456当前时间切片长度,默认 frameYieldMs(5),forceFrameRate 可改
startTime457当前这批工作的起始时间戳,shouldYieldToHost 用它算“主线程已被占用多久”

这些变量看似零散,其实可以分三类理解:

  1. 队列相关:taskQueue、timerQueue、taskIdCounter。
  2. 执行上下文相关:currentTask、currentPriorityLevel、isPerformingWork、isHostCallbackScheduled、isHostTimeoutScheduled、isMessageLoopRunning、needsPaint。
  3. 时钟相关:getCurrentTime、startTime、frameInterval、taskTimeoutID。

记住 currentPriorityLevel 是个上下文变量:调用方用 runWithPriority 临时切换它,Scheduler 内部在 workLoop 执行 callback 前会把 currentPriorityLevel 设为 currentTask.priorityLevel(见 §7 workLoop 逐行),这样“嵌套在 React 工作里的回调”能继承正确的优先级。

下一节我们把 SchedulerMinHeap 这个“堆引擎”彻底讲透,因为 taskQueue/timerQueue 的所有排序语义都依赖它。

4. SchedulerMinHeap:两个队列的排序引擎

taskQueue 和 timerQueue 都是数组,但它们被当作**二叉最小堆(binary min-heap)**使用,所有“取最小、插入、删除堆顶”的算法都在 packages/scheduler/src/SchedulerMinHeap.js 里。理解这个文件,才能理解“为什么最高优先级/最早过期的任务总在堆顶”。

4.1 堆的基本约定

// packages/scheduler/src/SchedulerMinHeap.js:10-15
type Heap<T: Node> = Array<T>;
type Node = {
  id: number,
  sortIndex: number,
  ...
};

注释里的 ... 表示 Node 是结构化的(“至少有 id 和 sortIndex 两个字段”),实际存放的是 Task 对象。Heap 就是普通数组。

二叉堆用数组的“隐式父/子索引”表示一棵完全二叉树:

  • 节点 i 的父节点在 (i - 1) >>> 1(>>> 是无符号右移,等价于 Math.floor((i-1)/2),但对负数安全)。
  • 节点 i 的左孩子在 (i + 1) * 2 - 1,右孩子在 (i + 1) * 2。
  • peek(看堆顶)永远是 heap[0],即排序键最小者。

为什么用堆而不是有序数组?因为 Scheduler 的核心操作是“频繁取最小 + 频繁插入”,堆的插入/取最小都是 O(log n),而有序数组的插入是 O(n)。React 应用里任务量大、调度频繁,O(log n) 至关重要。

4.2 比较器 compare:先 sortIndex,后 id

// packages/scheduler/src/SchedulerMinHeap.js:91-95
function compare(a: Node, b: Node) {
  // Compare sort index first, then task id.
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

这是整个堆的“排序灵魂”。它定义了两个任务谁更小(更靠前):

  1. 一级键 sortIndex:在 taskQueue 中是 expirationTime,在 timerQueue 中是 startTime。
  2. 二级键 id:当 sortIndex 相等时,用 id(即 taskIdCounter 的自增序号)决胜,保证先入队的先执行,即 FIFO 稳定排序。

这点极其关键:id 是防止“同优先级任务饿死/乱序”的兜底。如果没有 id 决胜,两个 sortIndex 相同的任务在堆调整时的相对顺序是不确定的(取决于堆的结构),可能出现“后来者反而先跑”的诡异现象。id 把“插入顺序”变成确定的决胜键,使调度变成稳定的。

compare 返回负数表示 a < b(a 更靠前)。注意它只用 >/< 比较,绝不直接用 === 判等来定序,且对 NaN 不友好——但 sortIndex 在 Scheduler 里永远是合理数字(expirationTime/startTime),所以无需担心。

4.3 push:插入并上浮(siftUp)

// packages/scheduler/src/SchedulerMinHeap.js:17-21
export function push<T: Node>(heap: Heap<T>, node: T): void {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}

push 很简单:先把节点塞到数组末尾(成为完全二叉树的最后一个叶子),再调用 siftUp 把它“上浮”到正确位置。

// packages/scheduler/src/SchedulerMinHeap.js:42-57
function siftUp<T: Node>(heap: Heap<T>, node: T, i: number): void {
  let index = i;
  while (index > 0) {
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    if (compare(parent, node) > 0) {
      // The parent is larger. Swap positions.
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // The parent is smaller. Exit.
      return;
    }
  }
}

逐行讲解 siftUp:

  • while (index > 0):只要不是根节点,就继续比较。
  • parentIndex = (index - 1) >>> 1:算出父节点索引。用 >>> 而非 /2,因为位运算更快且对边界安全。
  • if (compare(parent, node) > 0):父节点“大于”(更靠后于)当前节点,说明当前节点应该上移。注意 compare(parent, node) > 0 等价于 parent.sortIndex > node.sortIndex(或 sortIndex 相同但 parent.id > node.id)。
  • 若需要上移,交换二者位置(heap[parentIndex] = node; heap[index] = parent;),并把 index 更新为 parentIndex,继续向上。
  • 一旦 compare(parent, node) <= 0(父节点更小或相等),说明位置已正确,直接 return 退出,无需继续。

复杂度:最坏情况从叶子走到根,深度为 log2(n),所以 push 是 O(log n)。

4.4 peek:看堆顶(O(1))

// packages/scheduler/src/SchedulerMinHeap.js:23-25
export function peek<T: Node>(heap: Heap<T>): T | null {
  return heap.length === 0 ? null : heap[0];
}

peek 只是返回 heap[0],空堆返回 null。这是 workLoop 取“下一个要执行的任务”的方式,也是 handleTimeout 判断“下一个延时要点到没”的方式。

4.5 pop:取堆顶并下沉(siftDown)

// packages/scheduler/src/SchedulerMinHeap.js:27-40
export function pop<T: Node>(heap: Heap<T>): T | null {
  if (heap.length === 0) {
    return null;
  }
  const first = heap[0];
  const last = heap.pop();
  if (last !== first) {
    // $FlowFixMe[incompatible-type]
    heap[0] = last;
    // $FlowFixMe[incompatible-type]
    siftDown(heap, last, 0);
  }
  return first;
}

pop 的逻辑:

  • first = heap[0]:记下要返回的原堆顶。
  • last = heap.pop():把最后一个元素从数组末尾弹出(这样数组长度 -1,同时拿到一个“待安置”的节点)。
  • 如果 last !== first(即堆里不止一个元素),把 last 放到堆顶 heap[0],然后 siftDown(heap, last, 0) 把它“下沉”到正确位置。
  • 返回 first(原堆顶)。

为什么用“最后一个元素顶到堆顶再下沉”而不是直接删堆顶?因为堆顶在数组中间不好删,而数组末尾的删除是 O(1) 的(pop)。这个“尾元素顶上、再下沉”是标准二叉堆删除算法的经典写法,保证删除后仍是合法最小堆。

4.6 siftDown:下沉调整

// packages/scheduler/src/SchedulerMinHeap.js:59-89
function siftDown<T: Node>(heap: Heap<T>, node: T, i: number): void {
  let index = i;
  const length = heap.length;
  const halfLength = length >>> 1;
  while (index < halfLength) {
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    // If the left or right node is smaller, swap with the smaller of those.
    if (compare(left, node) < 0) {
      if (rightIndex < length && compare(right, left) < 0) {
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    } else if (rightIndex < length && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      // Neither child is smaller. Exit.
      return;
    }
  }
}

逐行讲解 siftDown:

  • halfLength = length >>> 1:完全二叉树中,有孩子的节点索引一定 < length/2(因为最后一个有孩子的节点的左孩子在 length-1,其父为 (length-2)/2)。循环条件 index < halfLength 提前终止于叶子节点,避免无意义比较。
  • 取出左孩子 left、右孩子 right 的索引与节点。
  • if (compare(left, node) < 0):左孩子比当前节点小,说明需要下沉。接着比较右孩子:
    • 若 right 也存在且 compare(right, left) < 0(右孩子比左孩子还小),则和更小的右孩子交换,下沉到 rightIndex。
    • 否则和左孩子交换,下沉到 leftIndex。
  • else if (rightIndex < length && compare(right, node) < 0):左孩子不小于当前节点,但右孩子更小,则和右孩子交换。
  • else:左右孩子都不比当前节点小,说明已在正确位置,return。

注意这里永远选两个孩子中更小的那个交换,这正是“最小堆”的保证:父节点必须 ≤ 两个孩子。

4.7 一个完整示例:把 5 个任务 push 进 taskQueue

设我们依次 scheduleCallback,得到 5 个任务(用 expirationTime 当 sortIndex,id 自增):

idsortIndex(expirationTime)
1100
250
3200
450
580

按顺序 push,每次 push 后 siftUp。最终 taskQueue 数组(堆顶在 0)会是:[id2(50), id4(50), id3(200), id1(100), id5(80)]。堆顶是 id2(expirationTime=50 且 id 更小);id2 与 id4 同 sortIndex,但 id2 先入队,因 compare 的二级键 id 而胜出在前。peek 永远拿到 id2,pop 拿掉 id2 后 siftDown 重新平衡,下一堆顶仍是 id4。这就是“同过期时间按 FIFO”的来源。

4.8 复杂度小结与“为什么不是数组 sort()”

操作堆实现若用 array.sort()
插入 pushO(log n)O(n log n)
取最小 peekO(1)O(1)(若已排序)
删堆顶 popO(log n)O(n)(要 splice)

React 的 workLoop 是“取一个、执行、再取一个”的循环,配堆的 O(log n) 是恰到好处的。如果用排序数组,每次插入都要重排,n 一大就会成为渲染瓶颈。这也呼应了第 3 章说的“Fiber 把大任务切碎”——切碎后任务数量变多,对堆的插入/删除频率变高,O(log n) 的优势就更明显。

5. 优先级体系:5 个级别与它们的超时

Scheduler 的优先级由 packages/scheduler/src/SchedulerPriorities.js 定义,而“每个优先级对应多久过期”由 packages/scheduler/src/SchedulerFeatureFlags.js 定义。这两个文件加起来不到 30 行,但每一行都关系到“时间切片何时结束、紧急任务如何被保障”。

5.1 五个优先级常量

// packages/scheduler/src/SchedulerPriorities.js:10-18
export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;

// TODO: Use symbols?
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;

注意:

  • PriorityLevel 是 0|1|2|3|4|5 的联合类型,数字越小优先级越高(ImmediatePriority=1 最高,IdlePriority=5 最低,NoPriority=0 是“无/默认占位”)。
  • 注释 // TODO: Use symbols? 说明 React 团队知道用裸数字不够安全(容易被误传),但出于性能与 bundle 体积考虑沿用了数字。在 Scheduler 这种被高频调用的内层,数字比较比 Symbol 比较快且无分配。
  • NoPriority=0 不是“最低优先级”,而是“尚未设置”的哨兵。在 unstable_scheduleCallback 里若传入未知优先级,会被当作 NormalPriority 处理(结合 §6 的 switch)。

5.2 每个优先级对应的超时(timeout)

“过期时间” = startTime + timeout。timeout 由优先级决定,定义在 SchedulerFeatureFlags.js:

// packages/scheduler/src/SchedulerFeatureFlags.js:10-18
export const enableProfiling = false;
export const frameYieldMs = 5;

export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;
export const enableRequestPaint = true;

export const enableAlwaysYieldScheduler = __EXPERIMENTAL__;

注意 SchedulerFeatureFlags.js 里没有 immediatePriorityTimeout 和 idlePriorityTimeout 的常量——它们写在 unstable_scheduleCallback 的 switch 里直接硬编码(-1 和 maxSigned31BitInt)。我们把全部五档整理成表:

优先级数字timeoutexpirationTime 含义典型来源(React 侧)
ImmediatePriority1-1startTime - 1,即“立即过期”旧版同步渲染(现多改用 microtask,见 §11)
UserBlockingPriority2userBlockingPriorityTimeout = 250250ms 后过期离散/连续用户输入(点击、输入、拖拽)
NormalPriority3normalPriorityTimeout = 50005000ms(5s)后过期普通 setState、网络返回后的更新
LowPriority4lowPriorityTimeout = 1000010000ms(10s)后过期后台分析、非紧急日志
IdlePriority5maxSigned31BitInt = 1073741823约 12.4 天后过期(≈“永不过期”)requestIdleCallback 风格的后台任务、offscreen

把 timeout 翻译成“调度语义”:

  • Immediate(-1):expirationTime = startTime - 1 < startTime ≤ now,所以任何时刻都已过期。workLoop 看到 expirationTime <= currentTime 就会“不让出、必须做完”——这正是“同步、不可中断”的语义(§7 会证明)。
  • UserBlocking(250ms):用户正在交互,你希望在 250ms 内给出反馈,否则用户会感觉卡。所以给它一个较短的过期窗口,过期后强制让出也来不及了——于是它会被优先做完。
  • Normal(5000ms):普通更新,5 秒内做完都算“及时”,所以有充足的时间切片空间。
  • Low(10000ms):更不紧急,可以一直被更急的任务挤到后面。
  • Idle(maxSigned31BitInt):永不强制过期,只在主线程真的空闲时才做。注意它不是真的 12.4 天,而是“大到几乎不会触发过期判断”,等价于“只在其它事都没得做时才执行”。

5.3 为什么用“过期时间”而非“绝对优先级数字”来排序?

这里有个反直觉但极其重要的设计:taskQueue 不是按 priorityLevel(1~5)排序的,而是按 expirationTime 排序。那“优先级”体现在哪?

答案是:优先级 → timeout → expirationTime,而 expirationTime 越早(越小)越靠前。高优任务 timeout 小,所以很快过期、很快被排到堆顶;低优任务 timeout 大,过期得晚,自然排在后面。换句话说:

“优先级”被编码进了“过期时刻的早晚”里。队列按“谁先过期”排序,等价于按“谁最该先被服务”排序。

这个编码的妙处在于:它让“优先级”和“时间压力”统一成一个可比较的数字。一个 UserBlocking 任务如果已经等了 240ms(快过期了),它就会排到一个刚投递的 Normal 任务前面——因为它更接近过期。这正是“饥饿保护(starvation avoidance)”的一种自然体现:等得越久越紧急。React reconciler 侧还有 markStarvedLanesAsExpired 显式把等太久的 Lane 标记为过期(§11.2),与这里的设计哲学一致。

5.4 maxSigned31BitInt 的来历

// packages/scheduler/src/forks/Scheduler.js:73-76
// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823;

注释解释了:1073741823 = 2^30 - 1,是 V8 在 32 位系统上对“31 位有符号整数”能安全表示的最大值(JavaScript 的位运算基于 32 位有符号整数,最高位是符号位,所以安全整数运算范围是 31 位)。用这个值当 Idle 的 expirationTime,保证它永远大于任何真实时间戳(当前时间 performance.now() 要涨到 12.4 天才会接近它),从而使得 Idle 任务在 workLoop 的“是否过期”判断里永不为真——除非主线程真的空闲到只剩 Idle 任务。这是一个“足够大但又不至于溢出 32 位运算”的巧妙取值。

5.5 实验开关 enableAlwaysYieldScheduler

// packages/scheduler/src/SchedulerFeatureFlags.js:18
export const enableAlwaysYieldScheduler = __EXPERIMENTAL__;

这个开关在 19.3.1 里是 __EXPERIMENTAL__(即构建期常量,默认关闭)。它改变 workLoop 的让出策略:默认情况下只有“任务未过期且 shouldYieldToHost() 为 true”才让出;开启后,每执行完一个任务就主动让出一次,即使时间片还没满。这是 React 团队在调研“更激进的让出是否能改善交互响应”的实验特性(§10.4 专门深挖它的行为差异与代价)。本章以“默认关闭”为主线索讲解,但会在边界章节完整分析它对 workLoop 控制流的影响。

5.6 enableRequestPaint 与 needsPaint 的伏笔

enableRequestPaint = true 在本版本是开启的。它控制 requestPaint() 能否置位 needsPaint,进而让 shouldYieldToHost() 在“有绘制请求”时立即让出(§9 详述)。这一条是“时间切片 + 优先绘制”的桥梁:Scheduler 不该在浏览器想画一帧的时候还霸着主线程。

6. unstable_scheduleCallback:任务投递全流程逐行

unstable_scheduleCallback 是 Scheduler 唯一的对入口。所有“要做的工作”都从它进入:React reconciler 的 scheduleCallback(ReactFiberRootScheduler.js:626)最终调它,应用直接调用 scheduler.unstable_scheduleCallback 也是它。本节逐行拆解这个函数(源码 packages/scheduler/src/forks/Scheduler.js:335-427)。

// packages/scheduler/src/forks/Scheduler.js:335-427
function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: {delay: number},
): Task {
  var currentTime = getCurrentTime();

  var startTime;
  // $FlowFixMe[invalid-compare]
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }

第 1 步:取当前时间,算 startTime。

  • currentTime = getCurrentTime():拿到一个高精度时间戳(通常是 performance.now(),单位毫秒,浮点)。
  • options 可能带 delay。注意 typeof options === 'object' && options !== null 这个判断——它排除了 undefined、null、原始类型。delay 必须 typeof === 'number' && delay > 0 才视为“延时任务”。
  • 若 delay > 0,startTime = currentTime + delay(未来某个时刻才开始);否则 startTime = currentTime(立刻可开始)。

这里有个边界细节:delay 等于 0 或负数时,走 else 分支,startTime = currentTime,当成“立即任务”处理,不会进 timerQueue。也就是说 scheduleCallback(pri, cb, {delay: 0}) 和 scheduleCallback(pri, cb) 语义完全一致。

  var timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      // Times out immediately
      timeout = -1;
      break;
    case UserBlockingPriority:
      // Eventually times out
      timeout = userBlockingPriorityTimeout;
      break;
    case IdlePriority:
      // Never times out
      timeout = maxSigned31BitInt;
      break;
    case LowPriority:
      // Eventually times out
      timeout = lowPriorityTimeout;
      break;
    case NormalPriority:
    default:
      // Eventually times out
      timeout = normalPriorityTimeout;
      break;
  }

  var expirationTime = startTime + timeout;

第 2 步:按优先级算 timeout,再算 expirationTime。

  • 这是 §5.2 那张表的代码实现。switch 把 priorityLevel 映射到 timeout:
    • Immediate → -1
    • UserBlocking → userBlockingPriorityTimeout(250)
    • Idle → maxSigned31BitInt(1073741823)
    • Low → lowPriorityTimeout(10000)
    • Normal 及 default → normalPriorityTimeout(5000)
  • expirationTime = startTime + timeout:这是任务“过期时刻”。注意 default 分支兜底——如果调用方传了非法的优先级(比如 0/NoPriority 或随便一个数字),会被当作 NormalPriority,不会崩溃。这是一个防御性设计:Scheduler 不信任外部传入的优先级。
  • 对 Immediate:expirationTime = startTime - 1,必然 < currentTime,所以一投递就“已过期”。
  var newTask: Task = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  // $FlowFixMe[constant-condition]
  if (enableProfiling) {
    newTask.isQueued = false;
  }

第 3 步:构造 Task 对象。

  • id: taskIdCounter++:拿当前计数器并自增,每个任务拿到全局唯一、递增的 id(这是 compare 的二级排序键,见 §4.2)。
  • callback:直接引用调用方传来的函数(不拷贝,零分配)。
  • priorityLevel:原样存。
  • startTime/expirationTime:上两步算的。
  • sortIndex: -1:初值为 -1,一个明显的哨兵值,表示“还没入队、排序键未定”。真正入队时会被改写为 startTime 或 expirationTime。
  • profiling 构建下把 isQueued 置 false(仅打点用)。
  if (startTime > currentTime) {
    // This is a delayed task.
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // All tasks are delayed, and this is the task with the earliest delay.
      if (isHostTimeoutScheduled) {
        // Cancel an existing timeout.
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // Schedule a timeout.
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    // $FlowFixMe[constant-condition]
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
    // Schedule a host callback, if needed. If we're already performing work,
    // wait until the next time we yield.
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    }
  }

  return newTask;
}

第 4 步:按 startTime 决定入哪个堆,并触发宿主回调。

分两大分支:

分支 A:startTime > currentTime(延时任务)

  • newTask.sortIndex = startTime:在 timerQueue 里用 startTime 排序(越早到点越靠前)。
  • push(timerQueue, newTask):入定时器堆。
  • 关键判断 peek(taskQueue) === null && newTask === peek(timerQueue):
    • peek(taskQueue) === null:任务队列是空的(所有活儿都在延时)。
    • newTask === peek(timerQueue):刚 push 的这个任务正好是 timerQueue 的堆顶(最早到点)。
    • 两者同时成立,说明“当前没有任何可立即执行的任务,且这个新任务就是最近的那个延时任务”。此时才需要向浏览器注册一个 setTimeout 定时器,等它到点。
  • 若之前已经注册过 isHostTimeoutScheduled,先 cancelHostTimeout() 取消旧的(因为新的更早到点,旧的定时器没用了),再注册新的 requestHostTimeout(handleTimeout, startTime - currentTime)。
  • 注意:如果 timerQueue 里已经有更早的延时任务(即 newTask !== peek(timerQueue)),就不重新注册定时器——因为已有的定时器会在更早的时刻触发 handleTimeout,那次触发会顺带把本次的新任务也检查、转移。避免重复 setTimeout。

分支 B:立即任务(startTime <= currentTime)

  • newTask.sortIndex = expirationTime:在 taskQueue 里用 expirationTime 排序。
  • push(taskQueue, newTask):入任务堆(堆顶就是最该先执行的)。
  • profiling 打点。
  • if (!isHostCallbackScheduled && !isPerformingWork):只有在“还没有向宿主注册回调”且“当前没正在执行工作循环”时才注册。这俩条件缺一不可:
    • isHostCallbackScheduled 已为 true → 已经有人(上一个任务)注册了 MessageChannel 消息,无需重复。同一批任务共享一个宏任务,靠 workLoop 内部循环一口气跑多个。
    • isPerformingWork 为 true → 当前正在 flushWork 里跑,新任务会被 workLoop 在下一轮 peek 时自然看到(因为 taskQueue 已经 push 进去了),不能在“工作循环内部”再嵌套启动一个消息循环(否则重入)。Scheduler 靠这个标志实现“重入保护”。

第 5 步:返回 Task 引用。

  • return newTask:调用方拿到这个对象,后续可以拿它去 unstable_cancelCallback。注意返回的是同一对象引用,Scheduler 内部后续修改(比如把 callback 置 null、把 callback 改成续体)都会反映在这个对象上。

6.1 scheduleCallback 的全流程归纳(决策树)

flowchart TD
    S[&#34;scheduleCallback(pri, cb, opts)&#34;] --> T[&#34;currentTime = now()&#34;]
    T --> A[&#34;算 startTime:delay>0? now+delay : now&#34;]
    A --> B[&#34;按 pri 算 timeout(Immediate -1 / UserBlocking 250 / Normal 5000 / Low 10000 / Idle MAX)&#34;]
    B --> C[&#34;expirationTime = startTime + timeout&#34;]
    C --> D[&#34;构造 Task:id=taskIdCounter++, sortIndex=-1&#34;]
    D --> E{&#34;startTime > now ?&#34;}
    E -- 是 --> F[&#34;sortIndex=startTime → push(timerQueue)&#34;]
    F --> G{&#34;taskQueue空 且 本任务是timerQueue堆顶?&#34;}
    G -- 是 --> H[&#34;取消旧timeout(若有) → requestHostTimeout(handleTimeout, 到点延迟)&#34;]
    G -- 否 --> I[&#34;不动定时器(已有更早的)&#34;]
    E -- 否 --> J[&#34;sortIndex=expirationTime → push(taskQueue)&#34;]
    J --> K{&#34;!isHostCallbackScheduled 且 !isPerformingWork?&#34;}
    K -- 是 --> L[&#34;isHostCallbackScheduled=true → requestHostCallback()&#34;]
    K -- 否 --> M[&#34;等待现有消息循环/本轮workLoop消化&#34;]
    H --> R[&#34;return newTask&#34;]
    I --> R
    L --> R
    M --> R

6.2 容易混淆的三种“不立即执行”

startTime > currentTime 的延时任务、已入队但 isHostCallbackScheduled 已置位的立即任务、正在 isPerformingWork 期间新加入的立即任务——这三种都会“不立刻执行”,但原因完全不同:

  1. 延时任务:因为时间点没到,躺在 timerQueue,等 handleTimeout 转移。
  2. 第二种:不是不执行,而是“合并进同一个即将到来的宏任务”,由 workLoop 连续处理(省去反复 postMessage 的开销)。
  3. 第三种:因为当前宏任务正在跑,workLoop 的 while 循环下一轮 peek(taskQueue) 就会看到它,所以不另起炉灶。

理解这三者的差异,是读懂 Scheduler 为什么“看起来一个 scheduleCallback 有时立刻跑、有时过一会儿跑、有时下帧跑”的关键。

7. 主机回调与消息循环:把工作切进宏任务

scheduleCallback 把任务推进堆后,只是“登记”了工作。真正的执行要等浏览器“有空”时通过宏任务(macrotask)召唤 Scheduler。这套“召唤—执行—再召唤”的机制就是消息循环(message loop)。本节讲 requestHostCallback、performWorkUntilDeadline,以及为什么选 MessageChannel 而非 setTimeout。

7.1 requestHostCallback:启动消息循环

// packages/scheduler/src/forks/Scheduler.js:563-568
function requestHostCallback() {
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

逻辑极简:若消息循环还没在跑(!isMessageLoopRunning),就置位并调用 schedulePerformWorkUntilDeadline,把 performWorkUntilDeadline 作为一个宏任务投递出去。isMessageLoopRunning 这个标志保证同一时刻只有一个消息循环在跑——哪怕有多个任务被 schedule,它们共享同一个正在运行的循环(靠 workLoop 内部的 while 一口气处理多个任务),不会各自 postMessage。

7.2 performWorkUntilDeadline:一次宏任务的入口

// packages/scheduler/src/forks/Scheduler.js:498-528
const performWorkUntilDeadline = () => {
  // $FlowFixMe[constant-condition]
  if (enableRequestPaint) {
    needsPaint = false;
  }
  if (isMessageLoopRunning) {
    const currentTime = getCurrentTime();
    // Keep track of the start time so we can measure how long the main thread
    // has been blocked.
    startTime = currentTime;

    // If a scheduler task throws, exit the current browser task so the
    // error can be observed.
    //
    // Intentionally not using a try-catch, since that makes some debugging
    // techniques harder. Instead, if `flushWork` errors, then `hasMoreWork` will
    // remain true, and we'll continue the work loop.
    let hasMoreWork = true;
    try {
      hasMoreWork = flushWork(currentTime);
    } finally {
      if (hasMoreWork) {
        // If there's more work, schedule the next message event at the end
        // of the preceding one.
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
};

逐行讲解:

  • if (enableRequestPaint) needsPaint = false;:进入新的一轮工作前,先把“需要绘制”的标记清掉。因为上一轮让出是为了绘制,绘制已经在两次宏任务之间被浏览器消费了,所以这里重置。注意 performWorkUntilDeadline 是宏任务回调,它在“上一次让出后、浏览器有机会绘制/处理输入之后”才被执行——所以到这里 needsPaint 的使命已完成。

  • if (isMessageLoopRunning):防御性判断(理论上进入这个回调时循环应该在跑)。

  • startTime = currentTime:这是时间切片的“起点时钟”。shouldYieldToHost 之后会用 getCurrentTime() - startTime 算“主线程被这一批工作占用了多久”。注意 startTime 是模块级变量(§3.3),每进入一次宏任务就重置。

  • let hasMoreWork = true;:先乐观假设还有活。

  • try { hasMoreWork = flushWork(currentTime); }:flushWork 返回 true 表示“还有没干完的活”(让出了或还有任务),false 表示“全干完了”。

  • 注释特意说明没有用 try-catch 包住错误:因为如果用 try-catch,开发者在 DevTools 里设的某些断点/错误来源追踪会失效。React 故意让错误“冒泡”出这个宏任务——这样错误能被浏览器的全局错误处理器观察到,用户也能看到真实的报错栈。代价是:一旦 flushWork 抛错,hasMoreWork 仍为 true(初始值),于是 finally 里会再 schedule 一次……等等,这不就死循环了吗?

    这里有个 subtle 的点:注释说“if flushWork errors, then hasMoreWork will remain true, and we'll continue the work loop”。但实际上 flushWork 内部(§8)对 profiling 构建有 try-catch,生产构建没有。若生产构建里 callback 抛错,错误会冒出 performWorkUntilDeadline,hasMoreWork 保持 true,finally 会再次 schedulePerformWorkUntilDeadline——但出错的那个 currentTask 在 flushWork 返回前不会被执行完,也不会被 pop(因为 workLoop 是 while 中途 throw 出来的),所以它仍在 taskQueue 堆顶。下一次宏任务再跑它,又会抛错……看起来会无限重试。实际上 React 依赖的是:错误最终会被 React 的错误边界(Error Boundary)捕获并清理掉 root 的回调(cancelCallback),从而终止循环。换句话说,Scheduler 把“错误恢复”的责任交给了 React 上层的错误边界机制,自己只负责“不让一个崩溃的任务悄悄消失”。这是一个有意为之的权衡。

  • finally 块:hasMoreWork 为真 → 再 schedulePerformWorkUntilDeadline()(把下一个宏任务排进队列,继续干活);为假 → isMessageLoopRunning = false(循环结束,主线程回到空闲,等待下次 requestHostCallback 重新启动)。

这里的 “schedule 下一个宏任务” 而非 “递归调用” 是关键:schedulePerformWorkUntilDeadline 是异步的(postMessage / setTimeout),它把控制权交还浏览器,让浏览器能在两次宏任务之间处理输入、绘制、回收——这正是“时间切片”成立的前提。如果它是同步递归调用 performWorkUntilDeadline(),那就不叫让出了,主线程会被一直霸占。

7.3 schedulePerformWorkUntilDeadline 的三个分支:为什么是 MessageChannel

这是本章“必讲透”的核心问题之一。源码 packages/scheduler/src/forks/Scheduler.js:530-561:

// packages/scheduler/src/forks/Scheduler.js:530-561
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // There's a few reasons for why we prefer setImmediate.
  //
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // (Even though this is a DOM fork of the Scheduler, you could get here
  // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
  // https://github.com/facebook/react/issues/20756
  //
  // But also, it runs earlier which is the semantic we want.
  // If other browsers ever implement it, it's better to use it.
  // Although both of these would be inferior to native scheduling.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    // $FlowFixMe[not-a-function] nullable value
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

这是一段“特性探测 + 三级降级”的代码。按优先级选调度宿主:

分支 1:setImmediate(Node.js / 老 IE)
  • 条件:typeof localSetImmediate === 'function'。localSetImmediate 在文件顶部捕获为 setImmediate(若环境有)。这在 Node.js 和老 IE 里存在。
  • 为什么 Prefer setImmediate?
    1. 不让 Node 进程挂起:MessageChannel(Node 15+ 也有)创建的端口会阻止 Node 进程退出(因为它持有活跃的句柄)。在 jsdom + Node 混合测试环境下,这会让测试进程无法自然结束。setImmediate 没有这个副作用。
    2. 语义更靠前:setImmediate 的回调在 I/O 之后、下个 tick 之前执行,比 MessageChannel 的 onmessage 在某些实现里更早,符合 React “尽快干活”的语义。
  • 注意注释自承:“both of these would be inferior to native scheduling(都比原生调度差)”。React 团队理想中是想用浏览器原生的 scheduler.postTask(优先级感知的调度 API),但因兼容与稳定性尚未全面采用。
分支 2:MessageChannel(DOM / Worker,默认路径)
  • 条件:typeof MessageChannel !== 'undefined'(所有现代浏览器、Web Worker 都有)。

  • 做法:创建一个 MessageChannel,拿到 port2,把 port1.onmessage 设为 performWorkUntilDeadline,schedulePerformWorkUntilDeadline 就是 port.postMessage(null)。

  • 为什么 Prefer MessageChannel 而不是 setTimeout(…, 0)? 这是面试/深挖高频题,源码注释给了直接答案:“We prefer MessageChannel because of the 4ms setTimeout clamping.” 展开讲有三点:

    1. 绕开 setTimeout 的 4ms 钳制:HTML 规范规定,嵌套超过 5 层的 setTimeout 回调,其最小延迟被钳制到 4ms(即使你传 0)。React 的渲染往往是“调度→执行→再调度→再执行”的深层嵌套,setTimeout(fn, 0) 在这种场景下实际延迟会跳到 4ms,造成不必要的延迟累积。而 MessageChannel 的 postMessage 产生的 message 事件宏任务不受这 4ms 钳制,延迟可以低到亚毫秒级(实测约 0.1~1ms),让“让出后再快速回来”的代价极小。
    2. 不受渲染阻塞、是独立宏任务队列:message 事件位于宏任务队列,但浏览器在调度 message 事件时不受当前帧渲染的直接阻塞(相对 requestAnimationFrame 而言)。Scheduler 不需要“帧对齐”——它要的是“尽快回来继续干”,而不是“在下一帧开始时干”。rAF 会在每帧开始时才触发,对于“切片内多次让出再回来”太慢了。
    3. 稳定且可控:MessageChannel 在所有现代浏览器都可用,行为一致,且不受 setTimeout 被页面节流(timer throttling,后台标签页 setTimeout 会被降到 1s)影响那么严重(虽然 message 在后台标签页也会被节流,但前台交互场景表现更好)。
  • 还有一个工程细节:port.postMessage(null) 传的是 null(一个极小的消息负载),避免序列化开销。port1.onmessage 持续监听,所以一条 channel 可复用——不用每次新建 MessageChannel。

分支 3:setTimeout 兜底(极老/非浏览器环境)
  • 条件:既没有 setImmediate 也没有 MessageChannel(几乎只存在于某些非浏览器 polyfill/极老环境)。
  • 做法:localSetTimeout(performWorkUntilDeadline, 0)。这是“总之先能用”的兜底,性能最差(受 4ms 钳制、受后台节流),但保证 Scheduler 在任何 JS 环境都能跑。

7.4 宏任务 vs 微任务:Scheduler 为什么不用微任务串联工作

值得强调:Scheduler 用宏任务(MessageChannel message)来让出再回来,而不是微任务(microtask / Promise.then)。原因:

  • 微任务在“当前宏任务结束后、下一个宏任务前”全部 flush 完,期间浏览器没有机会渲染、处理输入、绘制。如果用微任务串联,等于“一口气把工作做完”,完全丧失了时间切片的让出效果。
  • 宏任务之间,浏览器有机会做一次渲染、分发输入事件、跑 rAF。这正是时间切片要的“缝隙”。
  • React 19 的同步工作(SyncLane)确实改用微任务了(ensureRootIsScheduled 在 microtask 里 flush sync,见 §11.2),但那是“同步、不可中断”的场景,本就不该让出;而 Scheduler 负责的是“可中断的并发工作”,必须用宏任务。两者分工明确。

7.5 一个完整的“让出—回来”时间线

假设一帧 16.67ms,时间切片 5ms,一个 Normal 任务要跑 30ms:

sequenceDiagram
    participant BT as 浏览器主线程
    participant MC as MessageChannel
    participant W as workLoop

    Note over BT: 用户输入触发更新
    BT->>MC: scheduleCallback → postMessage
    MC->>BT: message 宏任务(t=0)
    BT->>W: performWorkUntilDeadline (startTime=0)
    W->>W: 跑 5ms,shouldYield=true,return true
    BT->>BT: 让出:浏览器可处理输入/绘制
    MC->>BT: 下一个 message 宏任务(t≈5.x)
    BT->>W: 继续 workLoop 跑 5ms,让出
    BT->>BT: 让出
    MC->>BT: 再一个 message(t≈10.x)
    BT->>W: 跑 5ms(还剩15ms)
    Note over W: 若已过期则不让出,一口气跑完剩余
    W->>BT: 全部完成 → isMessageLoopRunning=false

注意:每次让出后,MessageChannel 的下一个 message 不一定紧挨着(浏览器可能先去绘制、先处理用户输入),这正是 Scheduler “配合浏览器节奏”的体现——它不强行占满每一帧,而是“有空就干一点,没空就让”。

8. flushWork 与 workLoop:时间切片的心脏(逐行)

scheduleCallback 把活登记进堆,performWorkUntilDeadline 把活请进宏任务,而真正“一片一片干活、并在该让出时让出”的逻辑,全在 flushWork → workLoop 里。这是本章最该读透的部分。

8.1 flushWork:进入工作循环前的准备

// packages/scheduler/src/forks/Scheduler.js:145-191
function flushWork(initialTime: number) {
  // $FlowFixMe[constant-condition]
  if (enableProfiling) {
    markSchedulerUnsuspended(initialTime);
  }

  // We'll need a host callback the next time work is scheduled.
  isHostCallbackScheduled = false;
  if (isHostTimeoutScheduled) {
    // We scheduled a timeout but it's no longer needed. Cancel it.
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }

  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    // $FlowFixMe[constant-condition]
    if (enableProfiling) {
      try {
        return workLoop(initialTime);
      } catch (error) {
        if (currentTask !== null) {
          const currentTime = getCurrentTime();
          // $FlowFixMe[incompatible-call] found when upgrading Flow
          // $FlowFixMe[incompatible-type]
          markTaskErrored(currentTask, currentTime);
          // $FlowFixMe[incompatible-use] found when upgrading Flow
          currentTask.isQueued = false;
        }
        throw error;
      }
    } else {
      // No catch in prod code path.
      return workLoop(initialTime);
    }
  } finally {
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
    // $FlowFixMe[constant-condition]
    if (enableProfiling) {
      const currentTime = getCurrentTime();
      markSchedulerSuspended(currentTime);
    }
  }
}

逐行讲解:

  • markSchedulerUnsuspended(initialTime)(仅 profiling):给 Profiler 打点“调度器被唤醒”。
  • isHostCallbackScheduled = false;:关键重置。进入工作循环前,先声明“当前这个主机回调已经‘被消耗’了”。这样 workLoop 里若产生新任务,又能重新 requestHostCallback(而不是误以为已经有人注册了)。注意这和 §6 里 if (!isHostCallbackScheduled && !isPerformingWork) 的判据呼应——isPerformingWork 此刻已经为 true,所以即便 isHostCallbackScheduled 被重置,新任务也不会立即去注册回调(因为正处在工作中),而是等本轮 workLoop 自然看到它。
  • if (isHostTimeoutScheduled) { ... cancelHostTimeout(); }:如果之前为某个延时任务注册了 setTimeout 定时器,而现在我们已经进入工作循环了,说明“要么有立即任务要跑,要么那个延时任务的定时器本就要取消”。总之进入 flush 后,旧的 host timeout 不再需要,取消掉,避免重复触发 handleTimeout。isHostTimeoutScheduled = false 一并复位。
  • isPerformingWork = true;:置位“正在工作”标志,用于 §6 的重入保护。
  • const previousPriorityLevel = currentPriorityLevel;:保存进入前的优先级上下文,以便 finally 里恢复(保证 Scheduler 的优先级上下文不被污染到外部)。
  • 然后是 try:profiling 构建里 workLoop 外包一层 catch——workLoop 若在任务执行中抛错,这里 markTaskErrored 打点,把 currentTask.isQueued = false,然后 throw error 向上冒泡(让 performWorkUntilDeadline 也看到错误,进而决定要不要再 schedule,见 §7.2)。生产构建里没有这层 catch,直接 return workLoop(initialTime)(错误直接冒泡出 flushWork)。
  • finally:无论如何都会执行:
    • currentTask = null:清空“当前任务”引用(防止悬挂引用,也防止 profiling 误打点)。
    • currentPriorityLevel = previousPriorityLevel:恢复优先级上下文(§3.3 提到的重建机制)。
    • isPerformingWork = false:工作结束,解除重入保护。
    • profiling 下再打点 markSchedulerSuspended。

flushWork 返回值就是 workLoop 的返回值(true/false),直接透传给 performWorkUntilDeadline 决定“要不要再 schedule 一个宏任务”。

8.2 workLoop:协作式让出的核心循环

// packages/scheduler/src/forks/Scheduler.js:193-266
function workLoop(initialTime: number) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    if (!enableAlwaysYieldScheduler) {
      if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
        // This currentTask hasn't expired, and we've reached the deadline.
        break;
      }
    }
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      // $FlowFixMe[incompatible-use] found when upgrading Flow
      currentTask.callback = null;
      // $FlowFixMe[incompatible-use] found when upgrading Flow
      currentPriorityLevel = currentTask.priorityLevel;
      // $FlowFixMe[incompatible-use] found when upgrading Flow
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      // $FlowFixMe[constant-condition]
      if (enableProfiling) {
        // $FlowFixMe[incompatible-type] found when upgrading Flow
        markTaskRun(currentTask, currentTime);
      }
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
        // If a continuation is returned, immediately yield to the main thread
        // regardless of how much time is left in the current time slice.
        // $FlowFixMe[incompatible-use] found when upgrading Flow
        currentTask.callback = continuationCallback;
        // $FlowFixMe[constant-condition]
        if (enableProfiling) {
          // $FlowFixMe[incompatible-type] found when upgrading Flow
          markTaskYield(currentTask, currentTime);
        }
        advanceTimers(currentTime);
        return true;
      } else {
        // $FlowFixMe[constant-condition]
        if (enableProfiling) {
          // $FlowFixMe[incompatible-type] found when upgrading Flow
          markTaskCompleted(currentTask, currentTime);
          // $FlowFixMe[incompatible-use] found when upgrading Flow
          currentTask.isQueued = false;
        }
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
    if (enableAlwaysYieldScheduler) {
      if (currentTask === null || currentTask.expirationTime > currentTime) {
        // This currentTask hasn't expired we yield to the browser task.
        break;
      }
    }
  }
  // Return whether there's additional work
  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

这是整个 Scheduler 最关键的 70 行,下面逐段拆解。

8.2.1 循环前:先搬延时任务、取堆顶
  • let currentTime = initialTime;:currentTime 从 initialTime(即 performWorkUntilDeadline 进入时的时间)起算,但注意它会在循环里被 getCurrentTime() 刷新。
  • advanceTimers(currentTime);:每次进入 workLoop 先调用一次 advanceTimers,把 timerQueue 里已经到点的延时任务转移到 taskQueue(§10 详述)。这一步保证“延时到点”的任务能立即参与本轮调度。
  • currentTask = peek(taskQueue);:取 taskQueue 堆顶(最该先执行的任务)作为当前任务。
8.2.2 让出判断(默认分支)
    if (!enableAlwaysYieldScheduler) {
      if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
        // This currentTask hasn't expired, and we've reached the deadline.
        break;
      }
    }

这是默认情况下(未开启实验开关)让出的唯一判据,请逐字读:

  • currentTask.expirationTime > currentTime:当前任务还没过期(它的过期时刻在未来)。如果已经过期(<= currentTime),这个条件为假,就不会走到 break——意味着“过期任务必须做完,不让出”。
  • shouldYieldToHost():宿主(浏览器)希望让出(时间片用尽,或有绘制请求)。
  • 两者同时为真 → break,让出当前宏任务,结束 workLoop。

这正是“已过期任务不让出”的代码证明:对于 Immediate 任务(expirationTime = startTime - 1 < currentTime),expirationTime > currentTime 永远为假,所以它永远不会因为时间片而让出——必须一口气做完。这就是“同步/不可中断”语义在 Scheduler 层的落地。对于即将过期的 UserBlocking 任务(已等了 249ms,快到 250ms 阈值),expirationTime > currentTime 仍然为真,所以理论上它还会让出——但只要跨过阈值(expirationTime <= currentTime),就立刻转为“不让出,做完”。这保证了“再紧急的任务,一旦过期就绝不被打断”。

8.2.3 取出 callback 并执行
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
  • const callback = currentTask.callback;:拿到任务真正要执行的函数。
  • currentTask.callback = null;:先把自己的 callback 置 null。这是一个巧妙的“防重入 + 标记”技巧:如果执行期间这个任务被 cancelCallback 置了 null,那 callback(局部变量)已经是我们要跑的旧函数,不受影响;同时这里的置 null 表示“我正在/已经占用这个任务的执行权”。注意:对于“正常完成”的任务,置 null 后不会再恢复;对于“续体”任务,下面会把续体函数写回 currentTask.callback(§8.2.4)。
  • currentPriorityLevel = currentTask.priorityLevel;:把 Scheduler 的优先级上下文切到这个任务的优先级。这样 callback 内部若调用 getCurrentPriorityLevel() 或嵌套 runWithPriority,能拿到正确的值。
  • didUserCallbackTimeout = currentTask.expirationTime <= currentTime:这个布尔值传给 callback 当参数,告诉执行者“你的任务是否已经超时了”。React reconciler 的 performWorkOnRootViaSchedulerTask(root, didTimeout) 就用它决定 forceSync(§11.3)。这是 Scheduler 把“时间压力”暴露给上层工作的唯一通道。
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
  • 执行 callback,并捕获它的返回值 continuationCallback。这一行就是真正干活的时刻(对 React 来说,这就是跑 Fiber 的 performConcurrentWorkOnRoot)。
  • 执行后立刻 currentTime = getCurrentTime(); 刷新时间——因为 callback 可能跑了很久,必须重新读时钟,后面判断是否让出才准确。
8.2.4 续体(continuation):返回函数就立刻让出
      if (typeof continuationCallback === 'function') {
        // If a continuation is returned, immediately yield to the main thread
        // regardless of how much time is left in the current time slice.
        currentTask.callback = continuationCallback;
        if (enableProfiling) {
          markTaskYield(currentTask, currentTime);
        }
        advanceTimers(currentTime);
        return true;
      }

这是“续体机制”的核心(§11 会展开讲 React 侧如何利用它):

  • 如果 callback 返回另一个函数,说明“我这一片干完了,但还有后续,请把这个续体函数留着,下次再跑”。
  • currentTask.callback = continuationCallback;:把续体函数写回 currentTask.callback,于是这个 Task 对象“原地复用”,不需要新建对象、不需要重新入堆(它本来就没被 pop 出 taskQueue)。
  • return true;:直接结束 workLoop 并返回 true(表示“还有活”)。注意这里即使时间片没满也立即让出——注释明说 “regardless of how much time is left”。为什么?因为返回一个续体通常是 React 主动判断“该让出了”(比如 render 阶段告一段落、希望 commit 或处理更高优先任务),Scheduler 尊重这个主动让出。
  • advanceTimers(currentTime) 在让出前再搬一次延时任务,保证下一轮不漏。
8.2.5 任务完成:出队或丢弃
      } else {
        if (enableProfiling) {
          markTaskCompleted(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
  • continuationCallback 不是函数(即 undefined/null/其他)→ 任务正常完成。
  • profiling 打点(仅 profiling 构建执行这块,生产构建 else 分支直接走到 if (currentTask === peek(taskQueue)))。
  • if (currentTask === peek(taskQueue)) pop(taskQueue);:关键判断:只有“当前任务仍是堆顶”时才 pop。为什么不直接 pop?因为 callback 执行期间可能 scheduleCallback 了新任务(新任务 push 进 taskQueue,可能交换了堆顶),此时 currentTask 不一定是堆顶了——贸然 pop 会删错节点。这个 === peek 保护确保“只删我们正在处理的这个堆顶”。如果中途插入了更紧急的任务,currentTask 被挤下堆顶,那它会被留在堆里(因为它已经执行完、callback 已置 null),下一轮 while 取到它时走 else { pop(taskQueue); } 分支(§8.2.6)被丢弃。
  • 再次 advanceTimers(currentTime)。
8.2.6 callback 为 null:已取消,直接出队
    } else {
      pop(taskQueue);
    }
  • 如果 currentTask.callback 不是函数(是 null):说明这个任务在“被排到堆顶之前/期间”被 cancelCallback 取消了(§6 提到取消只是把 callback 置 null)。此时它没活可干,直接 pop(taskQueue) 移出堆。这就是“取消任务 = 置 null,等轮到堆顶时丢弃”的实现落点。
8.2.7 取下一个 + 实验开关让出
    currentTask = peek(taskQueue);
    if (enableAlwaysYieldScheduler) {
      if (currentTask === null || currentTask.expirationTime > currentTime) {
        // This currentTask hasn't expired we yield to the browser task.
        break;
      }
    }
  • currentTask = peek(taskQueue);:取下一个堆顶,进入 while 下一轮。
  • if (enableAlwaysYieldScheduler):这是实验开关开启时的额外让出点。注意它和 §8.2.2 是“或”的关系(默认关时 §8.2.2 生效,开时 §8.2.2 被跳过、这里生效)。开启后逻辑是:“只要下一个任务是 null(没活了)或还没过期,就让出”。也就是说每执行完一个任务就主动让出,即便时间片还没满。这会显著提高交互响应,但增加宏任务切换开销。详见 §10.4。
8.2.8 循环结束:还有活吗?
  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
  • 循环因为 break(让出)退出时,currentTask 仍非 null(还有未处理的堆顶),返回 true → 上层再 schedule 一个宏任务继续。
  • 循环因为 peek(taskQueue) 取到 null(所有任务都处理完)而自然结束,currentTask === null,返回 false。但返回前还要看 timerQueue:如果还有延时任务在等(firstTimer !== null),就 requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime) 注册一个 setTimeout,等最早的延时任务到点时触发 handleTimeout 转移它(§10)。这是“空 taskQueue 但有延时任务时,如何让浏览器在未来唤醒 Scheduler”的机制——注意此时 isMessageLoopRunning 会被 performWorkUntilDeadline 置 false(因为 hasMoreWork=false),所以 Scheduler 进入“靠 setTimeout 守候”的休眠态,直到 handleTimeout 重新 requestHostCallback。

8.3 workLoop 控制流总图

把上面所有分支画成一张图(默认 enableAlwaysYieldScheduler=false):

flowchart TD
    Start[&#34;workLoop(initialTime)&#34;] --> Adv[&#34;advanceTimers(now)&#34;]
    Adv --> Peek[&#34;currentTask = peek(taskQueue)&#34;]
    Peek --> While{&#34;currentTask != null ?&#34;}
    While -- 否 --> End[&#34;返回 false(无活)&#34;]
    While -- 是 --> Yield{&#34;未过期 且 shouldYield()?&#34;}
    Yield -- 是 --> Break[&#34;break → 返回 true(让出)&#34;]
    Yield -- 否 --> CB{&#34;callback 是函数?&#34;}
    CB -- 否 --> PopCancel[&#34;pop(taskQueue) 取消态&#34;]
    CB -- 是 --> Run[&#34;执行 callback(didTimeout)&#34;]
    Run --> Ret{&#34;返回函数是续体?&#34;}
    Ret -- 是 --> Cont[&#34;callback=续体, return true(让出)&#34;]
    Ret -- 否 --> Done[&#34;pop(若仍是堆顶), 继续&#34;]
    PopCancel --> Next[&#34;currentTask=peek(taskQueue)&#34;]
    Done --> Next
    Next --> While

8.4 时间切片在这里如何“生效”

把 §8.2.2 的让出判据和 §7 的 startTime 时钟、shouldYieldToHost 三者连起来,时间切片就完整了:

  1. performWorkUntilDeadline 进入时记录 startTime = now(§7.2)。
  2. workLoop 每执行一个 callback 后刷新 currentTime = now()。
  3. 下一个任务若“未过期”,调用 shouldYieldToHost(),它内部算 now - startTime >= frameInterval(5ms)(§9)。
  4. 若 >= 5ms,shouldYieldToHost 返回 true → workLoop break → 让出宏任务。
  5. 让出后浏览器处理输入/绘制,下一帧(或之后)MessageChannel 再触发 performWorkUntilDeadline,重新记录 startTime,开始下一片。

所以“5ms 切片”是“连续占用主线程不超过约 5ms 就主动让出一次”,而非“每帧只干 5ms 然后睡到下一帧”。实际一帧里可能干 0 片(浏览器忙)、1 片、或因为任务短而干完整个任务——它是对“主线程占用时长”的约束,不是对“每帧工作量”的约束。

9. shouldYieldToHost 与 forceFrameRate:5ms 从哪里来

§8 的让出判据里,shouldYieldToHost() 是“是否该让出”的守门员;frameInterval(默认 5)是切片长度;requestPaint/needsPaint 是“优先绘制”的开关。本节把三者一起讲透,并回答“为什么是 5ms 而不是 16ms”。

9.1 shouldYieldToHost:让出的两个理由

// packages/scheduler/src/forks/Scheduler.js:459-472
function shouldYieldToHost(): boolean {
  if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
    // Yield now.
    return true;
  }
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  }
  // Yield now.
  return true;
}

逐行讲解:

  • 理由一(绘制优先):if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) return true;。当实验开关关闭、enableRequestPaint 开启、且 needsPaint 为 true 时,立即让出。这意味着“React/上层已经请求过绘制(比如 commit 阶段产生了 DOM 变更,希望浏览器尽快画出来),Scheduler 不该再霸着主线程”,哪怕时间片还没满也要让。注意 !enableAlwaysYieldScheduler 条件:若实验开关开启,这个分支被跳过(实验开关用自己的让出逻辑,§10.4)。needsPaint 由 requestPaint() 置位(§9.3)。enableRequestPaint 在 19.3.1 默认 true。
  • 理由二(时间片用尽):timeElapsed = getCurrentTime() - startTime;。startTime 是 performWorkUntilDeadline 进入时记的(§7.2),timeElapsed 就是“本次宏任务里主线程被连续占用的时长”。
    • 若 timeElapsed < frameInterval(默认 5ms)→ return false(不让出,继续干)。注释说“The main thread has only been blocked for a really short amount of time; smaller than a single frame. Don't yield yet.”——因为占用时间还远小于一帧,让出反而增加宏任务切换开销,不划算。
    • 否则 return true(让出)。

两个理由的逻辑关系:理由一是“有绘制需求就优先让”,理由二是“占够 5ms 了也让”。两者任一成立就 yield。shouldYieldToHost 是 Scheduler 对“让出”的唯一裁决函数,被 workLoop 在“任务未过期”时调用,被 React reconciler 侧(workLoop 内部 performUnitOfWork 之外)也会通过 unstable_shouldYield 暴露出去——Fiber 每处理一个单元都会问它。

9.2 frameInterval 与 5ms 的来历

// packages/scheduler/src/forks/Scheduler.js:452-457
// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let frameInterval: number = frameYieldMs;
let startTime = -1;
  • frameInterval 初始为 frameYieldMs,即来自 SchedulerFeatureFlags.js 的 5。
  • 注释点出关键设计哲学:“它每帧让出多次(yields multiple times per frame),且不试图和帧边界对齐(does not attempt to align with frame boundaries)”。展开:
    • 帧预算是 16.67ms(60fps),但 Scheduler 不在 16.67ms 才让出,而是 5ms 就让一次。这样一帧里 React 可能“干 5ms、让、干 5ms、让、干 5ms”,把主线程切成更细的片,给用户输入/绘制留更多缝隙。
    • 它不用 requestAnimationFrame 对齐帧——因为 rAF 只在帧开始时触发,太“粗”;Scheduler 要的是“随时可以被打断、随时让出”,用宏任务即可,不必等帧。需要“帧对齐”的工作(动画)应自己用 rAF,Scheduler 不替它管。
为什么是 5ms?一个工程权衡

这是本章“必讲透的 trade-off”。5ms 不是拍脑袋,而是 React 团队在“响应性”与“调度开销”之间反复权衡的结果:

  1. 给浏览器留足余量:16.67ms 一帧。如果 Scheduler 一次占满 16ms,浏览器就几乎没有时间做样式/布局/绘制,必然掉帧。留到 5ms 让出,意味着“每片最多吃 5ms”,剩下 11ms+ 给浏览器渲染和用户输入。5ms 大约是一帧预算的 30%,是一个“既不会让出太频繁(开销大),又不会占用太久(卡顿)”的甜点。
  2. 宏观任务的切换成本:每次让出 = 一次宏任务调度(postMessage)+ 浏览器一次事件循环回合。如果切片设得太小(比如 1ms),让出/回来的次数暴增,宏任务切换的固定开销会吃掉收益;设太大(比如 15ms),又会卡顿。5ms 是“切换开销可忽略、同时足够碎”的平衡点。
  3. 经验值 + 可调:React 早期版本用的是更保守的值,后来随实测调成 5ms。而且它是可调的——forceFrameRate(fps) 能改 frameInterval(§9.4),所以 5ms 是默认值而非铁律。
  4. 注意 5ms 是“上限”而非“配额”:shouldYieldToHost 里是 timeElapsed < frameInterval → 不让出,即“不到 5ms 坚决不让”。所以单个任务若能在 5ms 内跑完,Scheduler 不会中途打断它;只有跨过 5ms 的那个任务边界才会让出。这避免了“一个 3ms 的小任务被切成两半”的浪费。
与 RAIL 模型的对应

RAIL 建议“动画 16ms、响应 100ms、空闲做预载”。Scheduler 的 5ms 切片直接服务于“动画不卡(每片留给浏览器 11ms+ 去 composite/paint)”与“输入响应(每 5ms 就有机会处理事件)”。它把 RAIL 的“响应”目标拆进了渲染过程本身——不是“渲染完再响应”,而是“渲染中间就响应”。

9.3 requestPaint 与 needsPaint:把绘制权交还浏览器

// packages/scheduler/src/forks/Scheduler.js:474-479
function requestPaint() {
  // $FlowFixMe[constant-condition]
  if (enableRequestPaint) {
    needsPaint = true;
  }
}
  • requestPaint(导出为 unstable_requestPaint)是上层(主要是 React reconciler 的 commit 阶段)用来告诉 Scheduler“我刚改了 DOM,请尽快让出以便浏览器绘制” 的通道。
  • 它只置 needsPaint = true(前提是 enableRequestPaint 开启)。needsPaint 在下一次 performWorkUntilDeadline 进入时被重置为 false(§7.2),所以它只在“两次宏任务之间”有效——即“本轮干活时有人请求了绘制,本轮就该在方便时让出”。
  • 为什么要单独有这个通道?因为 shouldYieldToHost 的时间片判断(5ms)可能还没到,但 commit 阶段已经产生了可见的 DOM 变更,此时继续跑下一个低优渲染任务会推迟用户“看到更新”的时间。React 通过 requestPaint 让 Scheduler 在当前时间片内提前让出,优先把已经算好的 UI 画出来。这是“调度与渲染节奏协同”的关键一环:Scheduler 不只在乎“时间”,也在乎“画”。

9.4 forceFrameRate:运行时调节切片长度

// packages/scheduler/src/forks/Scheduler.js:481-496
function forceFrameRate(fps: number) {
  if (fps < 0 || fps > 125) {
    // Using console['error'] to evade Babel and ESLint
    console['error'](
      'forceFrameRate takes a positive int between 0 and 125, ' +
        'forcing frame rates higher than 125 fps is not supported',
    );
    return;
  }
  if (fps > 0) {
    frameInterval = Math.floor(1000 / fps);
  } else {
    // reset the framerate
    frameInterval = frameYieldMs;
  }
}

逐行讲解:

  • 参数校验:fps 必须在 0~125 之间。超过 125(即 frameInterval < 8ms)不支持——因为宏任务切换本身就有毫秒级开销,强制更小切片只会让开销占比失控。注释用 console['error'](而非 console.error)是故意绕开 Babel/ESLint 对 console.error 的可能改写/警告。
  • fps > 0:frameInterval = Math.floor(1000 / fps)。例如:
    • forceFrameRate(60) → frameInterval = 16(每片 16ms,对齐 60fps 帧,几乎不让出)。
    • forceFrameRate(125) → frameInterval = 8(每片 8ms)。
    • forceFrameRate(30) → frameInterval = 33(每片 33ms,更激进地少让出,适合不关心交互的后台渲染)。
  • fps === 0 或非法:重置 frameInterval = frameYieldMs(5),恢复默认。

注意:forceFrameRate 改变的是“让出的时间间隔”,不改变优先级和过期时间。它影响的是“Scheduler 在主线程上愿意连续占多久”,是一个全局的“节奏旋钮”。React 自身基本不调用它(默认 5ms 足够好),但测试环境或特殊场景可以调。

9.5 shouldYieldToHost 的调用方不止 workLoop

虽然 workLoop 用它在“任务未过期”时判断让出,但 shouldYieldToHost 同时被导出为 unstable_shouldYield,供 React reconciler 在更细的粒度上询问。例如 Fiber 的 workLoopConcurrent(performConcurrentWorkOnRoot 内部)每处理一个 Fiber 单元后也会检查 shouldYieldToHost(),从而让“单个任务内部”也响应让出——即使 workLoop 的“任务边界”还没到。这样“时间切片”是双层的:任务层(workLoop 的任务边界)+ 单元层(Fiber 单元边界),共同保证主线程不被长时间霸占。

不过要厘清:本章聚焦的 forks/Scheduler.js 里,shouldYieldToHost 只在 workLoop 被调用;reconciler 侧的“单元级让出”是通过它自己的 workLoop 调用同一个 unstable_shouldYield 实现,二者共享同一个 frameInterval 时钟。所以 5ms 的约束在“任务级”和“单元级”是统一的。

10. 延时任务的生命周期:advanceTimers / handleTimeout / requestHostTimeout

scheduleCallback 把延时任务(startTime > now)塞进 timerQueue 后,它不会自动跑到 taskQueue。必须有人“在它到点时”把它搬过去。这一节讲清楚这条“延时 → 可执行”的转移链路,以及 Scheduler 如何“睡着等唤醒”。

10.1 advanceTimers:把到点的延时任务搬进 taskQueue

// packages/scheduler/src/forks/Scheduler.js:103-126
function advanceTimers(currentTime: number) {
  // Check for tasks that are no longer delayed and add them to the queue.
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      // Timer was cancelled.
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // Timer fired. Transfer to the task queue.
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
      // $FlowFixMe[constant-condition]
      if (enableProfiling) {
        markTaskStart(timer, currentTime);
        timer.isQueued = true;
      }
    } else {
      // Remaining timers are pending.
      return;
    }
    timer = peek(timerQueue);
  }
}

逐行讲解:

  • let timer = peek(timerQueue);:从 timerQueue 堆顶取最早到点的延时任务(timerQueue 按 startTime 排序,堆顶就是最早该触发的)。
  • while (timer !== null):循环处理,因为可能同时有多个延时任务都已到点(比如积压的定时任务)。
  • 分支 1:timer.callback === null → 这个延时任务被 cancelCallback 取消了。直接 pop(timerQueue) 把它移出堆(同样是因为“不能在堆中间删任意节点”,只能 pop 堆顶;取消只是置 null,等这里 peek 到它时丢弃)。
  • 分支 2:timer.startTime <= currentTime(到点了):
    • pop(timerQueue);:从 timerQueue 移除。
    • timer.sortIndex = timer.expirationTime;:关键改写排序键!任务在 timerQueue 里用 startTime 排序,现在要进 taskQueue,排序语义变成“按过期时间”,所以把 sortIndex 从 startTime 改成 expirationTime。这一步在 §3.1 提过,这里看到实际操作。
    • push(taskQueue, timer);:push 进任务堆。由于 sortIndex 已改为 expirationTime,它会在 taskQueue 里被正确排序(早于过期的靠前)。
    • profiling 打点。
  • 分支 3:timer.startTime > currentTime(还没到点):因为 timerQueue 按 startTime 升序,peek 到的第一个还没到点,说明后面所有都没到点,return 直接结束(不用再检查)。这是堆有序性的红利。
  • 循环末尾 timer = peek(timerQueue); 取下一项继续。

advanceTimers 被三处调用:workLoop 进入时(§8.2.1)、workLoop 处理完一个任务后(§8.2.4/8.2.5)、handleTimeout 里(§10.2)。保证“任何一次开始干活或让出前,到点的延时任务都已经转移好”。

10.2 handleTimeout:定时器触发后的处理

// packages/scheduler/src/forks/Scheduler.js:128-143
function handleTimeout(currentTime: number) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime);

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    } else {
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

逐行讲解:

  • isHostTimeoutScheduled = false;:先声明“这个 setTimeout 定时器已经被触发/消费了”。
  • advanceTimers(currentTime);:把到点的延时任务转移进 taskQueue(§10.1)。
  • if (!isHostCallbackScheduled):只有当当前没有在跑消息循环时,才需要重新唤醒。如果在 scheduleCallback 的同时已经有立即任务在跑(isHostCallbackScheduled 为 true 或 isPerformingWork 为 true),那 workLoop 已经会处理这些转移过来的任务,无需再启动循环。
    • if (peek(taskQueue) !== null):转移后 taskQueue 非空 → 有活可干了。置 isHostCallbackScheduled = true 并 requestHostCallback() 启动消息循环,宏任务里 workLoop 会处理它们。
    • else:taskQueue 仍空(比如转移的延时任务又被某种原因清空,或本来就没活)→ 看 timerQueue 还有没有更晚的延时任务,有则 requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime) 重新注册一个 setTimeout,等下一个最早的延时任务。这形成了“守候链”:Scheduler 在只有延时任务时,靠 setTimeout 一个一个地“睡到下一个到点时刻”。

10.3 requestHostTimeout / cancelHostTimeout:用 setTimeout 守候

// packages/scheduler/src/forks/Scheduler.js:570-584
function requestHostTimeout(
  callback: (currentTime: number) => void,
  ms: number,
) {
  // $FlowFixMe[not-a-function] nullable value
  taskTimeoutID = localSetTimeout(() => {
    callback(getCurrentTime());
  }, ms);
}

function cancelHostTimeout() {
  // $FlowFixMe[not-a-function] nullable value
  localClearTimeout(taskTimeoutID);
  taskTimeoutID = -1 as any as TimeoutID;
}
  • requestHostTimeout(callback, ms):用原生 setTimeout 注册一个 ms 毫秒后的定时器,ms 就是 firstTimer.startTime - currentTime(离最早延时任务还有多久)。回调里用 getCurrentTime() 重新取当前时间传给 callback(这样 handleTimeout/advanceTimers 拿到的是“真实到点时刻”,而非估算的 currentTime)。注意这里用 setTimeout 是刻意的:延时任务的“到点”不需要绕开 4ms 钳制(它本来就是几秒、几百毫秒的延迟),而且 setTimeout 在后台标签页会被节流——对延时任务来说反而省电(不需要后台频繁唤醒)。所以延时守候用 setTimeout,实时切片用 MessageChannel,两者各取所长。
  • cancelHostTimeout():clearTimeout(taskTimeoutID) 并复位句柄。在 flushWork 进入工作循环时(§8.1)、以及 scheduleCallback 注册新的更早定时器前(§6 分支 A)都会被调用,避免“多个 setTimeout 同时守候”。

10.4 延时任务完整状态机

延时任务的一生经过 timerQueue → (到点) → taskQueue → (执行) → 完成/取消:

stateDiagram-v2
    [*] --> PendingTimer: scheduleCallback(delay>0)
    PendingTimer: timerQueue (sortIndex=startTime)
    PendingTimer --> PendingTimer: 新更早的延时任务注册 requestHostTimeout
    PendingTimer --> Ready: advanceTimers 发现 startTime<=now
    Ready: taskQueue (sortIndex=expirationTime)
    Ready --> Running: workLoop peek 取出
    Running --> Done: callback 返回非函数
    Running --> YieldCont: callback 返回续体(让出后回来)
    YieldCont --> Running: 下一宏任务继续
    Running --> Cancelled: cancelCallback 置 callback=null
    Cancelled --> [*]: pop 时丢弃
    Done --> [*]
    PendingTimer --> Cancelled2: cancelCallback 置 callback=null
    Cancelled2 --> [*]: advanceTimers pop 丢弃

注意两个取消出口:延时阶段取消(callback 置 null,在 advanceTimers 的 peek 时 pop 丢弃)与执行阶段取消(在 workLoop 取到时因 callback===null 而 pop 丢弃)。无论哪个阶段,cancelCallback 都只是置 null,真正的移除发生在“轮到它成为堆顶”时——这就是 §3.2 不变式的体现。

10.5 requestHostTimeout 与 enableAlwaysYieldScheduler 的交互

当 enableAlwaysYieldScheduler 开启时,workLoop 每执行完一个任务就额外让出一次(§8.2.7)。这会让 workLoop 更早返回 true,从而更频繁地 schedule 宏任务——但延时任务的唤醒仍只依赖 requestHostTimeout 的 setTimeout,与实验开关无关。换句话说,实验开关改变的是“taskQueue 内任务的让出频率”,不改变“timerQueue → taskQueue 的转移节奏”。

11. 任务取消的真实代价:unstable_cancelCallback 为什么只置 null

§3.2 埋了一个不变式:数组实现的二叉堆只能高效删除堆顶,不能删除任意中间节点。这一节把这条不变式的“后果”讲透——取消(cancel)在 Scheduler 里到底做了什么、没做什么、以及“没做的那部分”如何在后续被补上。这是理解 Scheduler 一切“惰性清理(lazy cleanup)”设计的钥匙。

11.1 unstable_cancelCallback 逐行

这段来自 packages/scheduler/src/forks/Scheduler.js:429-443,是本章最短、却最容易被误读的函数:

// packages/scheduler/src/forks/Scheduler.js:429-443
function unstable_cancelCallback(task: Task) {
  // $FlowFixMe[constant-condition]
  if (enableProfiling) {
    if (task.isQueued) {
      const currentTime = getCurrentTime();
      markTaskCanceled(task, currentTime);
      task.isQueued = false;
    }
  }

  // Null out the callback to indicate the task has been canceled. (Can't
  // remove from the queue because you can't remove arbitrary nodes from an
  // array based heap, only the first one.)
  task.callback = null;
}

逐行讲解:

  • if (enableProfiling):profiling 构建下,如果任务当前还在队里(task.isQueued 为 true),打一个“任务被取消”的事件点,并把 isQueued 置回 false(避免重复打点)。生产构建里这整个 if 被 DCE(dead code elimination,死代码消除)掉,因为 enableProfiling 是 false 这个构建期常量。
  • task.callback = null;:这是取消的全部。注释写得极其直白:“把 callback 置 null 表示任务已取消。(不能从队列里移除,因为你无法从数组实现的堆里删除任意节点,只能删第一个。)”

也就是说,unstable_cancelCallback:不从 taskQueue / timerQueue 里移除节点;不修改 sortIndex、expirationTime、priorityLevel;不触碰任何全局标志(isHostCallbackScheduled、isMessageLoopRunning 等);并且天然是幂等的——重复调用只会反复写 null,无副作用(profiling 下因 isQueued 已置 false 也不会重复打点)。

这里有一个必须点破的语义:task.callback = null 之后,任务对象本身依然“活”在堆里,只是它已经“没有了活儿”。所以“取消”在这个实现里等价于“标记撤销 + 惰性回收”,而不是“立即删除”。

11.2 为什么不能从堆中间删除:pop 的硬约束

回到 SchedulerMinHeap.js,我们看“删除”只有一条路径:

// packages/scheduler/src/SchedulerMinHeap.js:27-40
export function pop<T: Node>(heap: Heap<T>): T | null {
  if (heap.length === 0) {
    return null;
  }
  const first = heap[0];
  const last = heap.pop();
  if (last !== first) {
    heap[0] = last;
    siftDown(heap, last, 0);
  }
  return first;
}

pop 只能删 heap[0](堆顶),做法是把末尾元素搬到堆顶再 siftDown。它没有任何“指定索引删除”的入口。为什么?

  • 用数组表示堆时,“堆序”只保证父子关系(父 ≤ 子),不保证兄弟之间或跨子树之间的顺序。因此给定一个 Task 对象,你无法通过比较 sortIndex 用二分或类似手段快速定位它在数组里的下标——它的位置完全由历史插入/删除的 siftUp/siftDown 轨迹决定。要找到它,只能线性扫描 O(n)。
  • 找到下标 i 后,删除它还需要“用末尾元素填 i,再决定 siftUp 还是 siftDown”——这是 O(log n)。但前置的 O(n) 扫描已经让整体退化成 O(n)。
  • 另一种做法是“标记 + 定期重建堆”,但那会引入额外的时机判断(什么时候重建?重建时怎么保证不丢任务?),复杂度上去了,收益却很低。

React 的选择是第三种、也是最省事的一种:惰性删除(lazy deletion)。取消时不删,只标记;等到节点自然成为堆顶被 peek/pop 到的那一刻,再顺手丢弃。这带来两个好处:

  1. cancelCallback 是 O(1) 的(一次属性写入),在“频繁取消”的场景下极其廉价;
  2. 堆的维护逻辑(siftUp/siftDown)完全不需要改动,不引入任何新分支。

代价是:堆里会短时间滞留“已取消但未回收”的僵尸节点。这个代价有多大?我们量化一下。

11.3 取消的三种时序:僵尸节点何时被回收

“取消”发生在不同时刻,回收路径(谁是那个把僵尸节点弹出去的 pop)也完全不同。分四种情况:

  • 情况一:延时阶段取消。任务还在 timerQueue。因为 advanceTimers 每次都会 peek(timerQueue) 取堆顶,一旦这个被取消的任务成为 timerQueue 堆顶,advanceTimers 里 if (timer.callback === null) pop(timerQueue);(Scheduler.js:107-109)就把它弹掉。注意:如果它在 timerQueue 里不是堆顶(后面还有更早到点的延时任务),它也会一直滞留,直到前面的延时任务都处理完。
  • 情况二:在 taskQueue 且恰是堆顶。workLoop 的 while 循环下一轮 peek(taskQueue) 拿到的就是它,typeof callback === 'function' 为假,走 else { pop(taskQueue); }(Scheduler.js:245-247)直接丢弃。这是最“及时”的回收。
  • 情况三:在 taskQueue 但被压在下面。它要等排在它前面的任务都被处理(执行完或也让出)后,才成为堆顶被回收。如果持续有更高优任务插入,它可能滞留不短的时间。
  • 情况四:正在执行中。这是最容易被忽略的一种:workLoop 在调用 callback 之前就已经把 currentTask.callback = null(Scheduler.js:208),并把原函数存在局部变量 callback 里。此时外部再调 cancelCallback 写 null 是无效的——任务照样跑完。这说明:

Scheduler 的取消只能取消“尚未开始的任务”,无法中止“正在执行的任务”。 因为 JS 单线程,正在跑的同步函数没法被外部打断。

这一点对 React 至关重要:React 通过 root.callbackNode 记录当前排进 Scheduler 的节点,取消时调 Scheduler_cancelCallback。如果那个任务正在 workLoop 里执行,取消是“来不及”的——所以 React 必须自己在 performWorkOnRootViaSchedulerTask 内部检查“我的任务是否已被取消/替换”(§11.5)。

11.4 僵尸节点的真实代价:空间与时间的量化

空间:一个 Task 对象在 64 位 V8 里约 80~120 字节(对象头 + 6 个字段),取消只把它变成“字段为 null 的普通对象”,不释放。滞留数量上界 ≈ “在一个 workLoop 处理周期内被取消、且未被 peek 到”的任务数——React 里通常是个位数(每个 root 只有一个 callbackNode);只有应用自己大量 scheduleCallback 再取消(拿 Scheduler 做节流/防抖)才可能堆积,且若被取消任务的 sortIndex 很大(如 Idle)它会沉到堆底长期滞留。

时间:compare 完全不看 callback,只看 sortIndex 与 id,所以僵尸节点不影响堆序;workLoop/advanceTimers 每轮对堆顶的那次 callback === null / typeof callback === 'function' 判断是 O(1);唯一“浪费”是僵尸节点挡在堆顶时多消耗一次循环迭代,可忽略。

结论:惰性删除的代价在 React 的真实负载下几乎为零,换来的是 cancelCallback O(1) 与堆逻辑零改动——典型的“把复杂度从热路径(取消)搬到冷路径(自然回收)”。

11.5 React 侧如何取消:callbackNode 与 fakeActCallbackNode

React 并不直接把自己暴露给 Scheduler 的取消语义,而是包了一层(ReactFiberRootScheduler.js:624-648):

  • act 环境下的“影子节点”:scheduleCallback 在 __DEV__ && ReactSharedInternals.actQueue !== null 时不真的把任务排进 Scheduler,而是 push 进 actQueue 并返回一个恒等对象 fakeActCallbackNode 当“节点句柄”;cancelCallback 只要判断句柄是不是这个假节点,就跳过真正的 Scheduler_cancelCallback。这是 act 能把调度完全接管为同步执行的机制之一(详见 §14)。
  • 正常路径:Scheduler_scheduleCallback(priorityLevel, performWorkOnRootViaSchedulerTask.bind(null, root)),返回的 Task 被存进 root.callbackNode(ReactFiberRootScheduler.js:500-506)。

React 里有两处关键的“取消/失效”判断:

第一处,在 scheduleTaskForRootDuringMicrotask 里——当 nextLanes === NoLanes、或 root 是正在挂起等数据的 workInProgressRoot、或 root.cancelPendingCommit !== null 时,若 existingCallbackNode !== null 就 cancelCallback(existingCallbackNode),并把 root.callbackNode 置 null、root.callbackPriority 置 NoLane,最后 return NoLane(ReactFiberRootScheduler.js:419-439)。

第二处,在 performWorkOnRootViaSchedulerTask 里——因为“正在执行中的任务无法被 cancel 中止”(§11.3 情况四),React 必须靠节点身份判断自己是否已被替换(ReactFiberRootScheduler.js:545-558):先记下 originalCallbackNode = root.callbackNode,若 flushPendingEffectsDelayed() 返回真(有被动副作用被 flush),就检查 root.callbackNode !== originalCallbackNode——不等说明当前任务已被取消/替换,直接 return null 退出(注释:无需再调 ensureRootIsScheduled,因为节点变化本身就意味着“有新任务或已无活”)。

注意这个判断的本质:即使 Scheduler 已经把 task.callback 置 null(例如被动副作用里触发了影响调度的更新),正在执行的这个 callback 局部变量仍会继续跑。React 只能通过“我自己在 root 上记的节点身份变了没有”来判断“我这一趟是不是已经作废”。这就是“取消不能中止执行中的任务”在 React 侧留下的补偿逻辑。

最后,performWorkOnRootViaSchedulerTask 返回续体前还会再做一次身份校验(ReactFiberRootScheduler.js:599-605):scheduleTaskForRootDuringMicrotask(root, now()) 之后,只有 root.callbackNode === originalCallbackNode 才 return performWorkOnRootViaSchedulerTask.bind(null, root)(返回续体,让 Scheduler 原地复用这个 Task,§8.2.4);否则 return null,表示“我这个 Task 已经作废,让堆把它丢弃”。这条 return null 正是让 Scheduler 里那个僵尸 Task 走“正常完成”分支被 pop 的通道。

11.6 小结:取消的“三个不”与“一个必须”

不立即删(只置 callback = null)、不保证及时(回收取决于它在堆中的位置)、不能中止执行中(已在 workLoop 里跑的任务取消无效);因此 React 侧必须自己判身份(用 callbackNode 校验兜底“我是否已作废”)。

12. 优先级上下文:runWithPriority / next / wrapCallback 的栈式恢复

Scheduler 的优先级有两套东西容易混淆:

  • 任务的优先级:每个 Task 自带 priorityLevel,在 unstable_scheduleCallback 时确定,决定 timeout 与 expirationTime(§5、§6)。
  • 上下文的优先级:一个模块级变量 currentPriorityLevel(Scheduler.js:86),它决定“此刻如果没有显式传优先级,新的工作该算哪一档”。

前者是“数据”,后者是“环境”。本节讲的是后者,由四个 API 共同维护:unstable_runWithPriority、unstable_next、unstable_wrapCallback、unstable_getCurrentPriorityLevel。

12.1 currentPriorityLevel 的语义与初值

// packages/scheduler/src/forks/Scheduler.js:85-86
var currentTask = null;
var currentPriorityLevel: PriorityLevel = NormalPriority;
  • 初值是 NormalPriority(3),即“没有特别指定时,一律按普通优先级”。
  • 它不是某个任务的优先级,而是“当前执行栈所处的位置该按什么优先级看待新工作”。典型用途:一个高优事件处理函数里再 setState,新更新应该继承高优;一个后台任务里再派活儿,应该保持低优。
  • 它是栈式(stack-like)语义:进入一段作用域时保存旧值、写入新值,退出时无条件恢复旧值(finally)。这保证了“嵌套调用后,上下文不会泄漏到外部”。

12.2 unstable_runWithPriority:临时切换优先级

// packages/scheduler/src/forks/Scheduler.js:268-291
function unstable_runWithPriority<T>(
  priorityLevel: PriorityLevel,
  eventHandler: () => T,
): T {
  switch (priorityLevel) {
    case ImmediatePriority:
    case UserBlockingPriority:
    case NormalPriority:
    case LowPriority:
    case IdlePriority:
      break;
    default:
      priorityLevel = NormalPriority;
  }

  var previousPriorityLevel = currentPriorityLevel;
  currentPriorityLevel = priorityLevel;

  try {
    return eventHandler();
  } finally {
    currentPriorityLevel = previousPriorityLevel;
  }
}

逐行讲解:

  • 先做优先级合法性校验(switch ... default):只有五个合法档位之一才保留,其余(包括 NoPriority=0 与任意非法数字)一律降级为 NormalPriority。这与 unstable_scheduleCallback 的 default 分支一致——Scheduler 对外部传入的优先级永远不信任。
  • previousPriorityLevel = currentPriorityLevel:保存旧值(栈式保存的关键)。
  • currentPriorityLevel = priorityLevel:写入新值。
  • try { return eventHandler(); } finally { currentPriorityLevel = previousPriorityLevel; }:用 finally 保证即使 eventHandler 抛错也会恢复上下文。这是整个 Scheduler 里反复出现的“保存—写入—finally 恢复”范式(flushWork、workLoop 里都能见到)。
  • 返回值 T 直接透传,不吞异常,不包装。

语义:在 eventHandler 执行期间,currentPriorityLevel 被“提升/压低”到指定档位;期间若有“读取当前优先级”的逻辑(如某些库用 getCurrentPriorityLevel() 决定新工作优先级),会看到这个新值;eventHandler 一返回,立刻复原。

12.3 unstable_next:把“后续工作”降级到 Normal

unstable_next(Scheduler.js:293-316)与 runWithPriority 的差别只在“新优先级怎么算”:它先 switch (currentPriorityLevel)——若当前是 Immediate / UserBlocking / Normal(≥Normal 的高优),一律降到 NormalPriority;default(低于 Normal 的 Low / Idle)则保持当前档位不动。随后同样是“保存旧值 → 写入新值 → try 执行 → finally 恢复”。

注释用词很妙:“Shift down to normal priority”(降档到 Normal)与“Anything lower than normal priority should remain at the current level”(比 Normal 更低的一律维持原样)。规则的设计意图是:“紧接着这次高优工作之后要做的事”,不该继续霸占高优,但也绝不该被抬升。 例如一次用户输入(UserBlocking)触发了 A 更新,你在 A 的处理里“顺手”做 B(不紧急):用 next 包住 B,B 就被降成 Normal——它不会跟 A 抢 UserBlocking 的过期窗口,也不会因为“从高优环境里来”而被误当成高优;反过来当前已是 Idle 时 next 保持 Idle,不会把纯后台任务“升级”去打扰用户。

12.4 unstable_wrapCallback:捕获“父优先级”的闭包

unstable_wrapCallback(Scheduler.js:318-333)在调用它的那一刻执行 var parentPriorityLevel = currentPriorityLevel; 把当前优先级用闭包“冻住”,然后返回一个新函数:新函数在将来每次被调用时把 currentPriorityLevel 设为 parentPriorityLevel,try { return callback.apply(this, arguments); },finally 再恢复。

两个要点:

  • 它没有调用 unstable_runWithPriority,而是把那段逻辑内联了一遍——注释点明“This is a fork of runWithPriority, inlined for performance.”。原因很实在:wrapCallback 常用于把回调交给外部宿主(setTimeout、事件监听器),可能被高频触发,多一层函数调用就是多一点开销,复制 8 行代码换掉一层间接调用是划算的。
  • callback.apply(this, arguments):保留 this 与全部实参,不改变被包装函数的调用语义。

语义差异一句话总结:

API读取优先级的时机写入优先级的时机典型场景
runWithPriority调用者立即指定立即(同步作用域内)在已知优先级的作用域里执行一段代码
next调用时读当前值并降档立即“这次之后顺手做的事”降级
wrapCallback包装时读一次将来每次被调用时把一个回调“带着优先级”交给异步宿主

12.5 与 workLoop / flushWork 的交互:谁在什么时候改 currentPriorityLevel

currentPriorityLevel 会被模块内多处读写,顺序必须理清:flushWork 进入时保存 previousPriorityLevel = currentPriorityLevel(Scheduler.js:160)、退出时在 finally 恢复(183);workLoop 每执行一个 callback 前执行 currentPriorityLevel = currentTask.priorityLevel(210)——这行至关重要,它让“正在执行的那个任务的优先级”成为该 callback 内部的上下文,于是 callback 里若再 scheduleCallback 且不显式指定优先级,就会继承当前任务的档位;而 workLoop 执行完一个 callback 后并不还原它,而是在下一轮开头被新任务的 priorityLevel 覆盖,直到回到 flushWork 的 finally 才统一恢复。一句话概括:进入 flushWork 保存一次,workLoop 每轮按当前任务改写,退出 flushWork 恢复一次——中间任何嵌套的 runWithPriority/next/wrapCallback 都在这条主线上做局部保存/恢复。

12.6 React 19 还用这些 API 吗?与一个必踩的反模式

必须澄清一个常见误解:React 19 的主渲染路径已经不依赖 currentPriorityLevel 来决定更新优先级。原因是第 3 章的 Lane 体系已经用位掩码把优先级编码在 update.lane 上,requestUpdateLane(ReactFiberWorkLoop.js:812 起)直接从 context 与 transition 推导 Lane,而不再问 Scheduler“当前是什么优先级”。在 19.3.1 里:packages/react-reconciler/src/Scheduler.js:21-22 仍重导出 unstable_getCurrentPriorityLevel,但 unstable_runWithPriority 在 packages/react-dom/src/ReactDOMFB.js:28 这类入口被标注为 DO NOT USE(“Temporarily exposed to migrate off of Scheduler.runWithPriority.”)。所以它们现在的定位是向后兼容 + 供外部库使用,而非 React 内部核心机制。

再点一个必踩的反模式:runWithPriority 是同步作用域语义(finally 里同步恢复),不能跨越 await / setTimeout 这类异步边界——例如 unstable_runWithPriority(UserBlockingPriority, async () => { await fetch('/api'); /* 此处 currentPriorityLevel 已不是 UserBlocking */ }):eventHandler 一旦返回(对 async 函数而言是在第一个 await 处“返回”一个 Promise),finally 就恢复了旧值。要跨越异步边界传播优先级,必须用 wrapCallback——它在包装时捕获、在将来每次被调用时重新写入。一句话记住:优先级上下文是有作用域的,不是有生命周期的。

13. 时间切片与 React 渲染的对接:从 setState 到 shouldYield

前 12 节讲的是 Scheduler 这台“引擎”自身的构造。但引擎要驱动的是 React 的渲染——谁把 root 的渲染排进 Scheduler?谁决定用哪个优先级?谁在 Fiber 单元之间问 shouldYield? 这些都在 reconciler 侧。本节把 packages/react-reconciler/src/ReactFiberRootScheduler.js、ReactFiberWorkLoop.js、ReactEventPriorities.js 与 Scheduler 的连接点逐一钉死。

13.1 桥接层:packages/react-reconciler/src/Scheduler.js

reconciler 不直接 import scheduler,而是经过一层极薄的 ESM 包装(packages/react-reconciler/src/Scheduler.js:14-34):import * as Scheduler from 'scheduler' 之后,把 unstable_scheduleCallback/unstable_cancelCallback/unstable_shouldYield/unstable_requestPaint/unstable_now/unstable_getCurrentPriorityLevel 与五个优先级常量逐一 export const xxx = Scheduler.unstable_xxx。

它存在的原因,注释写得很清楚:“…we're intentionally not using named imports because Rollup would use dynamic dispatch for CommonJS interop named imports.”——故意不用具名导入,因为 Rollup 在处理 CJS 互操作时为具名导入生成动态派发(每次访问走一层属性查找),而 import * as Scheduler + 手动解构是静态绑定,更快。

记住这几个绑定:shouldYield、requestPaint、now 全部从这里进 reconciler。Scheduler 的时钟、让出、绘制请求,就是通过这三个常量被注入 React 的。

13.2 从 setState 到 scheduleCallback:三跳

一次 setState 最终触发调度,链路是:

flowchart LR
    A[&#34;setState → requestUpdateLane&#34;] --> B[&#34;enqueueConcurrentHookUpdate&#34;]
    B --> C[&#34;scheduleUpdateOnFiber&#34;]
    C --> D[&#34;ensureRootIsScheduled(root)&#34;]
    D --> E[&#34;ensureScheduleIsScheduled()&#34;]
    E --> F[&#34;scheduleImmediateRootScheduleTask()&#34;]
    F --> G[&#34;scheduleMicrotask(processRootScheduleInMicrotask)&#34;]
    G --> H[&#34;scheduleTaskForRootDuringMicrotask(root)&#34;]
    H --> I[&#34;Scheduler.unstable_scheduleCallback(pri, performWorkOnRootViaSchedulerTask)&#34;]

第一跳,ensureRootIsScheduled(ReactFiberRootScheduler.js:116-152):若 root 已是 lastScheduledRoot 或 root.next !== null 则走 fast path(已在调度中);否则把它接到 lastScheduledRoot.next 尾部(或作为首个 root 同时赋给 firstScheduledRoot/lastScheduledRoot),然后置 mightHavePendingSyncWork = true 并调 ensureScheduleIsScheduled()。

注释点明:它只做两件事——把 root 加进“待调度 root 链表”,并保证有一个 microtask 待处理。真正“选优先级、调 scheduleCallback”的逻辑都不在这里,而在 scheduleTaskForRootDuringMicrotask。这是“登记”与“决策”刻意分离。

第二个关键点是那个链表:firstScheduledRoot / lastScheduledRoot(ReactFiberRootScheduler.js:99-100)是一条单向链表,用 root.next 串联。注释说:“In an idiomatic app, there's only a single root, but we do support multi root apps, hence this extra complexity. But this module is optimized for the single root case.”(惯常应用只有一个 root,但为支持多 root 才引入这份复杂度,代码为单 root 情况做了优化)。

第二跳,scheduleImmediateRootScheduleTask(ReactFiberRootScheduler.js:650-696)。它默认用微任务,而不是直接调 Scheduler:supportsMicrotasks 为真时调 scheduleMicrotask(() => { ... processRootScheduleInMicrotask(); });否则退回 Scheduler_scheduleCallback(ImmediateSchedulerPriority, processRootScheduleInImmediateTask)。这里有两个设计要点:

  1. 默认微任务、不用 Scheduler:调度决策本身不经过 Scheduler 的 NormalPriority 任务,而是直接在 microtask 里跑。因为“决定该排什么任务”是个 O(root) 的纯计算,不需要时间切片,也不该占用 Scheduler 的优先级队列。
  2. Safari 兼容护栏:Safari 里 append 一个 iframe 会强制唤醒微任务,可能让这个微任务在 render/commit 中间运行。React 检测到 executionContext & (RenderContext | CommitContext) 时,故意改用 Scheduler 的 ImmediatePriority 宏任务(注释坦言“This is wrong semantically but it prevents an infinite loop. The bug is Safari's, not ours.”)。这条“降级为宏任务”的兜底,是 Scheduler 在 React 里承担的第一个非调度职责:当微任务时机不可信时,用宏任务错开一次。

13.3 scheduleTaskForRootDuringMicrotask:按 lane 选 Scheduler 优先级

这是 §13 的核心函数(ReactFiberRootScheduler.js:384-509)。它做四件事:

(1) 先标记饥饿 lane:markStarvedLanesAsExpired(root, currentTime);(ReactFiberRootScheduler.js:397)。这与 §5.3 讲的“过期即不让出”哲学完全同构,但作用在 Lane 层面:markStarvedLanesAsExpired(ReactFiberLane.js:541-588)遍历 pendingLanes,若某 lane 的 expirationTime <= currentTime,就把它或进 root.expiredLanes(ReactFiberLane.js:581-584)。源码 TODO 坦言:“This gets called every time we yield.”——它每次让出都会跑一遍,是让出路径上的已知热函数。

(2) 取下一个要处理的 lane(ReactFiberRootScheduler.js:406-417),用 getNextLanes(ReactFiberLane.js:249)。

(3) 若没活就取消旧任务(ReactFiberRootScheduler.js:419-439,见 §11.5)。

(4) 若含同步 lane,则取消 Scheduler 任务、留给 microtask 末尾 flush(ReactFiberRootScheduler.js:442-456):includesSyncLane(nextLanes) && !checkIfRootIsPrerendering(root, nextLanes) 时,取消 existingCallbackNode、把 root.callbackPriority 设为 SyncLane、root.callbackNode = null 并 return SyncLane。同步工作(SyncLane)根本不进 Scheduler——它会在 processRootScheduleInMicrotask 末尾由 flushSyncWorkAcrossRoots_impl 直接同步跑完(ReactFiberRootScheduler.js:339-341)。这印证了 §7.4:React 19 里“同步”走微任务、“可中断并发”才走 Scheduler。

(5) 否则:把 nextLanes 折算成 Scheduler 优先级并排任务(ReactFiberRootScheduler.js:457-508)。源码用一个 switch (lanesToEventPriority(nextLanes)):DiscreteEventPriority/ContinuousEventPriority → UserBlockingSchedulerPriority,DefaultEventPriority → NormalSchedulerPriority,IdleEventPriority → IdleSchedulerPriority,default → NormalSchedulerPriority(注释明说 “Scheduler does have an ImmediatePriority, but now that we use microtasks for sync work we no longer use that.”)。随后 const newCallbackNode = scheduleCallback(schedulerPriorityLevel, performWorkOnRootViaSchedulerTask.bind(null, root)); 并写入 root.callbackPriority / root.callbackNode 后返回 newCallbackPriority。

注意几点:

  • 映射是“事件优先级 → Scheduler 优先级”,不是“Lane → Scheduler 优先级”,中间隔了一层 lanesToEventPriority(§13.4)。
  • Discrete 与 Continuous 都映射到 UserBlockingSchedulerPriority:Scheduler 只有 5 档而事件层有 4 档,所以离散与连续输入在 Scheduler 层被合并为同一档(都是 250ms 过期)。
  • Immediate 现在只用于 Safari 兜底与 act,正常并发渲染不再用——这是 React 19 与旧版 Scheduler 用法的显著差异。
  • scheduleCallback 用的是模块内的包装(ReactFiberRootScheduler.js:626-639),act 环境下会走 actQueue(§11.5)。
  • 优先级未变则复用旧任务(ReactFiberRootScheduler.js:462-474):若 newCallbackPriority === existingCallbackPriority 且不是 act 特例,直接 return,不重新排任务——避免“同一优先级反复排/取消”的关键优化。

13.4 lanesToEventPriority 折算与“压缩损耗”

lanesToEventPriority(ReactEventPriorities.js:55-67)取 getHighestPriorityLane(lanes)(ReactFiberLane.js:756)即最低位(最优先)的 lane,再自上而下判定落到 Discrete / Continuous / Default / Idle 四档之一。这是有损压缩:Lane 有 31 条而 Scheduler 优先级只有 5 档,expirationTime 只表达粗糙的时间压力(250/5000/10000ms),精细优先级仍由 Lane 决定;这也解释了为什么 getNextLanes 要调用两次(ReactFiberRootScheduler.js:413 与 575):Scheduler 会把多个 callback 合进同一个宏任务、中途不让出微任务,同一浏览器任务里可能已有更新的 lane 被排入(源码 TODO 见 561-570)。

13.5 performWorkOnRootViaSchedulerTask:Scheduler 任务的真实内容

这个函数是 Scheduler 执行的 callback,也是“续体(continuation)”机制的发出者(ReactFiberRootScheduler.js:513-606)。它的收尾是:const forceSync = !disableSchedulerTimeoutInWorkLoop && didTimeout; performWorkOnRoot(root, lanes, forceSync);,然后 scheduleTaskForRootDuringMicrotask(root, now()),最后 if (root.callbackNode != null && root.callbackNode === originalCallbackNode) return performWorkOnRootViaSchedulerTask.bind(null, root); return null;。

三个关键点:

  • forceSync = didTimeout:didTimeout 就是 §8.2.3 里 didUserCallbackTimeout = currentTask.expirationTime <= currentTime 传进来的布尔值——Scheduler 把“你已过期”告诉 React,React 据此强制同步渲染(forceSync 传给 performWorkOnRoot,最终影响 §13.6 的 shouldTimeSlice)。因为 disableSchedulerTimeoutInWorkLoop = false(shared/ReactFeatureFlags.js:36),所以 forceSync 就等于 didTimeout。这就是“过期任务不让出”从 Scheduler 传导到 Fiber 的通道。
  • return performWorkOnRootViaSchedulerTask.bind(null, root):返回一个函数,正好命中 §8.2.4 的续体分支——Scheduler 会把它写回 currentTask.callback 并立即让出。React 用“返回续体”来表达“我这个 root 这一片干完了,但还有活”。
  • return null:任务作废(root.callbackNode 已换或已清),让 Scheduler 走“正常完成”分支把僵尸节点 pop 掉。

13.6 shouldYield 与 now 的注入:双层让出

Fiber 的并发渲染循环是 workLoopConcurrentByScheduler(ReactFiberWorkLoop.js:3072-3079)——while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); },注释即 “Perform work until Scheduler asks us to yield”。其中的 shouldYield 就是 Scheduler.unstable_shouldYield(即 Scheduler 的 shouldYieldToHost,§9.1)。这就是“Fiber 单元级让出”接入 Scheduler 的唯一接口:performUnitOfWork 每处理完一个 Fiber 单元(一个组件、一个 fragment 等)后,循环条件重新问一次 shouldYield();而 shouldYield() 内部比较 getCurrentTime() - startTime >= frameInterval(5ms)——这个 startTime 是 §7.2 里 performWorkUntilDeadline 进入时设的同一个时钟。

于是“时间切片”真正是双层的:任务级——Scheduler workLoop(§8.2.2),粒度是一个 Scheduler Task,条件 expirationTime > now && shouldYieldToHost();单元级——reconciler workLoopConcurrentByScheduler,粒度是一个 Fiber 单元,条件 shouldYield() 为真。

两者共享同一个 frameInterval 与 startTime,所以“5ms 不让出”在两层上一致。也正因如此,shouldYieldToHost 的“理由一(needsPaint 优先让出)”能让渲染中途就停下来把已完成的 DOM 变更画出去——requestPaint() 被调用的地方正是 commit 完成后(ReactFiberWorkLoop.js:4187-4189,注释即“Tell Scheduler to yield at the end of the frame, so the browser has an opportunity to paint.”)。

还有一个 now 的注入点:getCurrentTime(ReactFiberWorkLoop.js:808-810)直接 return now(),而 now 就是 Scheduler.unstable_now。这保证 React 与 Scheduler 用的是同一个单调时钟,不会因为 Date.now() 与 performance.now() 混用而产生比较误差。

13.7 “是否时间切片”:shouldTimeSlice

Scheduler 决定“要不要让出”,React 还有一个自己的开关决定“要不要走可中断的并发循环”(ReactFiberWorkLoop.js:1165-1183):shouldTimeSlice = (!forceSync && !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes)) || checkIfRootIsPrerendering(root, lanes),为真走 renderRootConcurrent,否则走 renderRootSync。四个否定项的含义:本次 Scheduler 任务已过期(didTimeout)不平分;lane 含需要尽快完成的阻塞型(includesBlockingLane,ReactFiberLane.js:684)不平分;root 已有过期 lane(includesExpiredLane,696)不平分以防饥饿;而预渲染即使同步也要用并发循环(“so that prerendering never blocks the main thread”)。结论:“时间切片”是 Scheduler 与 React 的双方共同决策——Scheduler 用 expirationTime 决定要不要让,React 用 shouldTimeSlice 决定要不要采用可中断循环,两者都对“过期”敏感,方向一致。

13.8 多 root 的串行调度

processRootScheduleInMicrotask(ReactFiberRootScheduler.js:259-348)遍历 firstScheduledRoot → root.next 这条链表,对每个 root 调 scheduleTaskForRootDuringMicrotask(ReactFiberRootScheduler.js:289-332):若返回 NoLane(该 root 无活了)就把 root.next 置 null 并从链表中摘除(prev.next = next,必要时更新 firstScheduledRoot / lastScheduledRoot);否则保留在链表中,并在含同步 lane 时置 mightHavePendingSyncWork = true。

关键不变式(注释原话“this microtask is the only place we do this”):

root 可以在任何地方被加入调度链表,但只能在 processRootScheduleInMicrotask 这一个地方被移除。

这避免了并发修改链表导致的微妙重入 bug。而“多 root 如何被串行调度”的答案是:

  • 每个 root 各自得到一个 Scheduler Task(root.callbackNode),它们在同一优先级下按 taskQueue 的 expirationTime + id 排序(§4.2),以 FIFO 顺序串行执行;
  • 每个任务结束时,performWorkOnRootViaSchedulerTask 会再调一次 scheduleTaskForRootDuringMicrotask(ReactFiberRootScheduler.js:599)重排自己这个 root——“多 root 之间的前进”是靠各自续排实现的,而非中央循环轮流推进;
  • 一个 root 让出时,Scheduler 的 workLoop 会继续取堆顶的下一个任务——可能正是另一个 root 的任务,于是两个 root 的时间片被交错切分。这就是“多 root 在单线程上协作式并行”的真相。

13.9 完整对接链路与本章最核心的结论

全链路一句话:setState → ensureRootIsScheduled → scheduleMicrotask → processRootScheduleInMicrotask(标记饥饿 lane + 取 lane)→ lanesToEventPriority → scheduleCallback(UserBlocking) → MessageChannel → performWorkUntilDeadline → performWorkOnRootViaSchedulerTask(didTimeout=false) → performWorkOnRoot → 每个 Fiber 单元问 shouldYield() → 返回续体让出 → 浏览器处理输入/绘制 → 续体再进 → commit 后 requestPaint() → shouldYield() 因 needsPaint 为真 → 返回 null 结束。你应能回答本章最核心的问题:Scheduler 与 React 之间只有四个契约——scheduleCallback(排任务)、shouldYield(问让出)、now(共享时钟)、requestPaint(请求绘制);其余全部由 React 自己在 Lane 层完成。

14. 其它 fork、测试模拟与可观测性

14.1 SchedulerMock:把时间与让出交给测试控制

packages/scheduler/src/forks/SchedulerMock.js 是 scheduler/unstable_mock 的实现(见 packages/scheduler/package.json 的 files)。它保留与真实实现几乎相同的任务模型(同样的 Task 字段与 taskQueue/timerQueue、同样的 advanceTimers),但把三个“宿主”换成可编程桩:时间由 currentMockTime 提供、用 unstable_advanceTime(ms) 手动推进(480-482, 641-654),而非 performance.now()(Scheduler.js:59-71);唤醒只把回调存进 scheduledCallback(451-453),由 flush* 系列显式驱动,而非 MessageChannel/setImmediate/setTimeout;让出判据改成“期望让出次数 / shouldYieldForPaint && needsPaint”(465-478),而非 5ms 时钟(Scheduler.js:459-472)。另有 workLoop 签名多出 hasTimeRemaining 参数(193)、requestHostCallback 只存不跑、forceFrameRate 为 no-op(484-486)。

workLoop 的让出判据在 mock 里被彻底改写(SchedulerMock.js:198-204):

// packages/scheduler/src/forks/SchedulerMock.js:198-204
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break;
    }

即:“没有剩余时间(!hasTimeRemaining)”或“达到期望让出次数” 就 break。测试用 flush* 系列传入 hasTimeRemaining,从而精确控制“在第几个 yield 处停下”。

面向测试的核心导出(SchedulerMock.js:504-687):

  • unstable_flushAllWithoutAsserting()(575-599):反复调用 scheduledCallback 直到 hasMoreWork 为假;返回 false 表示“本来就没活”、true 表示“真干了活”——act / ReactNoop 靠它判断“是否还有未 flush 的工作”。
  • unstable_flushNumberOfYields(count)(504-526)与 unstable_flushUntilNextPaint()(528-552):分别按“期望让出次数”和“模拟 requestPaint() 前的让出”精确停在某个 yield 点,配合 unstable_clearLog() 断言执行顺序。
  • unstable_flushExpired()(558-573):用 scheduledCallback(false, currentMockTime) 调一次,hasTimeRemaining=false 会让 workLoop 把未过期但已无剩余时间的任务也跑完,专门测“过期不让出”。
  • unstable_advanceTime(ms)(641-654):推进虚拟时间,越过 timeoutTime 时触发 scheduledTimeout(即 handleTimeout),可复现地驱动延时任务迁移;unstable_setDisableYieldValue(99-101)则在 StrictMode 第二遍渲染里关闭 yield 记录,避免重复打点污染断言。

这套设计的价值在于:它把“时间”和“宿主”两个不确定源彻底参数化,让“5ms 让出”“延时任务到点”“过期不让出”这些行为在单元测试里 100% 可复现——这是 React 团队能对调度语义写大量精确测试的基础。

14.2 其它 fork:SchedulerPostTask 与 SchedulerNative

  • packages/scheduler/src/forks/SchedulerPostTask.js(unstable_post_task):改用 Chrome 实验性的 scheduler.postTask()。它把 5 档优先级压成三档——Immediate/UserBlocking → 'user-blocking'、Low/Normal → 'user-visible'、Idle → 'background'(SchedulerPostTask.js:79-95);用 TaskController 承载取消(unstable_cancelCallback 直接 controller.abort(),177-180);续体靠 scheduler.yield()(若存在)或再 postTask 实现(144-153)。它的 unstable_requestPaint 是空实现(65-67),因为“反正每帧都让出”;yieldInterval 固定为 5(55)。
  • packages/scheduler/src/forks/SchedulerNative.js:面向 React Native 的 fork,宿主换成 RN 的定时/帧机制。
  • 三个 fork 与 DOM 主 fork 共享同一份 SchedulerMinHeap.js 与 SchedulerPriorities.js——任务模型与堆排序统一,只有宿主原语可替换,这正是 Scheduler 能移植到 test / RN / Chrome 实验环境的原因。

14.3 可观测性:profiling 事件日志与 React Performance Track

Scheduler 自己有可选的 profiling 埋点(packages/scheduler/src/SchedulerProfiling.js),由 enableProfiling 控制(默认 false,SchedulerFeatureFlags.js:10)。它把事件写进一个 Int32Array 日志(SchedulerProfiling.js:17-23, 34-57),事件类型是一组整型常量(SchedulerProfiling.js:25-32):TaskStartEvent=1、TaskCompleteEvent=2、TaskErrorEvent=3、TaskCancelEvent=4、TaskRunEvent=5、TaskYieldEvent=6、SchedulerSuspendEvent=7、SchedulerResumeEvent=8。

每次打点写入 [事件类型, 微秒时间, 任务 id, ...](例如 markTaskStart 写 [TaskStartEvent, ms * 1000, task.id, task.priorityLevel],SchedulerProfiling.js:89)。enableProfiling 为真时,unstable_Profiling 会暴露 startLoggingProfilingEvents / stopLoggingProfilingEvents(Scheduler.js:604-613),把日志 buffer 交给 React DevTools。这就是 DevTools “Scheduler” 面板能看到任务开始/运行/让出/完成时间线的数据来源。

React 侧还有更现代的时间线通道:enableSchedulingProfiler(shared/ReactFeatureFlags.js:257-258,仅 __PROFILE__ 且未启用新 track 时)打 performance.mark/measure;enableComponentPerformanceTrack 改用 console.timeStamp + performance.measure,把渲染画进 Chrome DevTools 的 Scheduler ⚛ 轨道组(ReactFiberPerformanceTrack.js:52-53 定义 COMPONENTS_TRACK = 'Components ⚛' 与 LANES_TRACK_GROUP = 'Scheduler ⚛'),并按 lane 分成 Blocking / Gesture / Transition / Suspense / Idle 五条轨道(61-110);让出时长有专门埋点 logYieldTime(startTime, endTime)(583)。另需纠正一个过时知识点:__interactionsRef / interactions 追踪 API 在 React 19 已从代码库移除(packages/ 下已搜不到该符号),其职责被上述 Performance Track 与 Profiler 组件取代。

15. 源码索引表

本章涉及的关键文件、函数与行号速查(行号基于 React 19.3.1 仓库)。行号用于定位阅读,引用时请以仓库当前内容为准。

文件关键符号行号本章位置
packages/scheduler/src/forks/Scheduler.jsTask 类型(opaque)/ 两个堆 / 全局状态(currentTask/currentPriorityLevel/isPerformingWork 等)49-94§3
〃getCurrentTime / maxSigned31BitInt / frameInterval / startTime / taskTimeoutID / isMessageLoopRunning59-76 / 449-457§5.4, §9.2, §13.6
〃advanceTimers / handleTimeout103-143§10.1-10.2
〃flushWork / workLoop145-266§8
〃unstable_runWithPriority / unstable_next / unstable_wrapCallback / unstable_getCurrentPriorityLevel268-333 / 445-447§12
〃unstable_scheduleCallback / unstable_cancelCallback335-443§6, §11.1
〃shouldYieldToHost(导出 unstable_shouldYield)/ requestPaint / forceFrameRate / performWorkUntilDeadline459-528§9, §7.2
〃schedulePerformWorkUntilDeadline(三分支)/ requestHostCallback / requestHostTimeout / cancelHostTimeout / 导出表 / unstable_Profiling530-613§7.3, §10.3, §14.3
packages/scheduler/src/SchedulerMinHeap.jspush / peek / pop / siftUp / siftDown / compare17-95§4
packages/scheduler/src/SchedulerPriorities.js五个优先级常量10-18§5.1
packages/scheduler/src/SchedulerFeatureFlags.jsframeYieldMs / 三个 timeout / enableRequestPaint / enableAlwaysYieldScheduler10-18§5.2, §5.5
packages/scheduler/src/forks/SchedulerMock.jscurrentMockTime / workLoop(hasTimeRemaining,…) / flush* 系列 / advanceTime / reset81, 193-272, 504-654§14.1
packages/scheduler/src/SchedulerProfiling.js事件常量 / Int32Array 日志 / markTaskStart 等17-32, 34-57, 75-187§14.3
packages/scheduler/src/forks/SchedulerPostTask.jspostTask fork / 优先级三档映射 / controller.abort()55-180§14.2
packages/react-reconciler/src/Scheduler.jsscheduleCallback/shouldYield/now/requestPaint 重导出16-34§13.1
packages/react-reconciler/src/ReactFiberRootScheduler.jsfirstScheduledRoot / lastScheduledRoot / ensureRootIsScheduled / ensureScheduleIsScheduled99-169§13.2, §13.8
〃flushSyncWorkAcrossRoots_impl / processRootScheduleInMicrotask / scheduleTaskForRootDuringMicrotask(lane→Scheduler 优先级)185-509§13.3, §13.8
〃performWorkOnRootViaSchedulerTask(forceSync / 续体)/ scheduleCallback / cancelCallback / fakeActCallbackNode / scheduleImmediateRootScheduleTask513-696§13.5, §11.5, §13.2
packages/react-reconciler/src/ReactFiberWorkLoop.jsgetCurrentTime / shouldTimeSlice 判定 / workLoopConcurrent(ByScheduler) / commit 后 requestPaint()808-810, 1165-1183, 3055-3079, 4187-4189§13.6-13.7
packages/react-reconciler/src/ReactEventPriorities.jslanesToEventPriority55-67§13.4
packages/react-reconciler/src/ReactFiberLane.jsgetNextLanes / markStarvedLanesAsExpired / includesBlockingLane / includesExpiredLane / getHighestPriorityLane249 / 541-588 / 684 / 696 / 756§13.3, §13.7
packages/react-reconciler/src/ReactFiberPerformanceTrack.jsLANES_TRACK_GROUP / markAllLanesInOrder / logYieldTime53 / 61-110 / 583§14.3

16. 自测 FAQ(20 条)

Q1. Scheduler 到底解决什么问题? 把 CPU 密集的渲染拆成可中断的时间片,单线程里实现“协作式抢占”:两个最小堆管任务(§3.2)、MessageChannel 宏任务切片(§7.3)、shouldYieldToHost 决定让出(§9.1)。它不解决“渲染慢”,只解决“渲染不可中断”。

Q2. 为什么 taskQueue 和 timerQueue 要分成两个堆? 因为“谁最早到点”(按 startTime)与“谁最该先跑”(按 expirationTime)是两个排序维度,一个堆无法兼顾;迁堆时 advanceTimers 改写 sortIndex(§3.2、§10.1)。

Q3. sortIndex 为什么会被改写? 它在 timerQueue 里等于 startTime,进 taskQueue 时被 advanceTimers 改成 expirationTime(Scheduler.js:113),所以堆的排序键是“动态的”(§3.1、§10.1)。

Q4. compare 为什么是“先 sortIndex 再 id”的双键? sortIndex 决定优先级顺序;id(自增序号)在 sortIndex 相等时决胜,保证同优先级 FIFO 稳定排序(§4.2)。

Q5. 为什么优先级用“过期时间”而不是优先级数字来排序? 优先级 → timeout → expirationTime,越早过期越靠前;“优先级”与“等得多久”被统一成一个可比较数字,天然饥饿保护(§5.3)。

Q6. maxSigned31BitInt = 1073741823 是干什么的? Idle 的 timeout,使 expirationTime 大到“永不过期”,只在主线程真空闲时才跑(§5.4)。

Q7. 为什么延时任务用 setTimeout,到期任务用 MessageChannel? 延时任务本就等几百毫秒/几秒,不需绕开 4ms 钳制,后台节流反而省电;到期任务要“尽快回来继续干”,必须绕开 4ms 钳制(§7.3、§10.3)。

Q8. isHostCallbackScheduled 与 isPerformingWork 为什么要两个标志? 前者防“重复向宿主注册消息循环”,后者防“在工作循环内部重入启动新循环”;scheduleCallback 仅当两者都为假才 requestHostCallback(Scheduler.js:420-423,§6、§8.1)。

Q9. 一次 workLoop 里为什么能连续跑多个任务? while (currentTask !== null) 反复 peek(taskQueue);同一批任务共享一个宏任务,只有“任务未过期且 shouldYieldToHost()”才 break 让出(§8.2)。

Q10. “已过期任务不让出”在代码里是哪一行? currentTask.expirationTime > currentTime && shouldYieldToHost()(Scheduler.js:199);过期任务的左条件为假,永不 break(§8.2.2)。

Q11. 续体(continuation)是什么?为什么返回函数就让出? callback 返回函数即“这片干完了但还有后续”;Scheduler 把它写回 currentTask.callback 并立即让出,不管时间片剩多少(Scheduler.js:220-231);React 用 performWorkOnRootViaSchedulerTask.bind(null, root) 触发(§13.5)。

Q12. unstable_cancelCallback 为什么只置 null? 数组二叉堆只能 O(1) 处理堆顶,删除任意中间节点要先 O(n) 扫描,故用惰性删除:标记 + 等它成为堆顶时丢弃(§11.1、§11.2)。

Q13. 取消能中止“正在执行”的任务吗? 不能。workLoop 在调用 callback 前已把原函数存进局部变量,外部再置 null 无效(§11.3);故 React 必须用 root.callbackNode 身份校验兜底(§11.5)。

Q14. 时间切片为什么是 5ms 而不是 16ms? 5ms ≈ 一帧预算的 1/3,给浏览器留 11ms+ 做样式/布局/绘制,又足够大以摊薄宏任务切换开销;它是“连续占用上限”而非“每帧配额”,可用 forceFrameRate 改(§9.2、§9.4)。

Q15. needsPaint / requestPaint 解决什么? commit 后 React 调 requestPaint() 置位 needsPaint,让 shouldYieldToHost() 在时间片没到也立即让出,把已算好的 DOM 变更尽快画出去(§9.1、§9.3、§13.6)。

Q16. Scheduler 的时钟是哪个?React 用的是同一个吗? getCurrentTime 优先 performance.now(),否则 Date.now() - initialTime(Scheduler.js:59-71);React 的 getCurrentTime 直接 return now()(ReactFiberWorkLoop.js:808-810),二者共享同一单调时钟(§13.6)。

Q17. React 侧怎么决定某个 root 用哪个 Scheduler 优先级? getNextLanes 取 lane → lanesToEventPriority 折算事件优先级 → switch 映射:Discrete/Continuous→UserBlocking,Default→Normal,Idle→Idle(ReactFiberRootScheduler.js:480-498);同步 lane 不进 Scheduler(§13.3)。

Q18. 多个 root 是怎么被调度的? 加进 firstScheduledRoot 链表,由 processRootScheduleInMicrotask 逐个排任务;只有这个 microtask 能移除 root;各 root 任务按过期时间 + id 排序,串行但可交错切分(§13.8)。

Q19. SchedulerMock 和真实实现最大的差别? 时间与宿主被参数化:currentMockTime + unstable_advanceTime 控时间,flush* 手动驱动循环,让出判据改成“期望让出次数”,workLoop 多一个 hasTimeRemaining 参数(§14.1)。

Q20. 为什么 Scheduler 用宏任务而不是微任务串联工作? 微任务会在下一个宏任务前被一次性 flush 完,期间浏览器无法渲染/处理输入,等于放弃让出;宏任务之间才有绘制与输入的空隙(§7.4)。

17. 本章要点小结

  1. 两个堆 + 一条消息循环:taskQueue(按 expirationTime)与 timerQueue(按 startTime)承载全部状态;requestHostCallback → MessageChannel(或 setImmediate/setTimeout)→ performWorkUntilDeadline → flushWork → workLoop,靠 isMessageLoopRunning 保证唯一(§3、§7)。
  2. 5ms 切片与让出三条件:startTime/frameInterval 构成切片时钟;任务未过期、且(时间片耗尽 或 有绘制请求)才让出,过期任务永不让出(§9.2、§8.2.2、§9.1)。
  3. 优先级即过期时刻:优先级 → timeout → expirationTime,队列按“谁先过期”排序,天然饥饿保护(§5.3)。
  4. 取消是惰性的:cancelCallback 只置 null,等节点成为堆顶时回收;执行中的任务取消无效,故 React 必须用 callbackNode 身份校验兜底(§11)。
  5. React 只借四个契约,宿主可替换:scheduleCallback / shouldYield / now / requestPaint;优先级语义与车道全在 reconciler 的 Lane 层(§13);DOM / test / RN / postTask 四个 fork 共享同一套任务模型与堆,只有宿主原语与让出判据不同(§14)。