首页 / 文章

React的调度器与事件循环:究竟谁来决定任务何时执行

了解 React 的协作式调度器如何在 JavaScript 事件循环中运行,为何过渡操作会让出控制权,以及为何没有调度器能够拯救被阻塞的线程。

发布者:REACTAPP.TOP阅读约 13 分钟3017 词

来源: How Does React’s Scheduler Work With the JavaScript Event Loop?

React 不仅决定屏幕上哪些内容需要变化,还通过优先级、执行通道以及可中断的渲染机制来决定这些操作的执行时机。然而 React 本质上只是 JavaScript,而 JavaScript 已经拥有用于决定代码执行时机的事件循环。那么为什么 React 还要自带调度器呢?实际上这两者中谁才是主导者?本文将厘清二者之间的关系,说明诸如过渡效果和 useDeferredValue 这类并发功能的位置,并解释无论采用多么巧妙的调度策略,在 Web 环境和 React Native 中都存在无法突破的硬性限制。

从调用栈入手,而非 React

普通的 JavaScript 是在调用栈上逐帧执行代码的。在下面的示例中,没有任何异步操作:一条日志记录、一个用于记录日志的函数调用,以及最后一条日志。

console.log("A");

function calculate() {
  console.log("B");
}
calculate();
console.log("C");

输出结果完全按照代码的执行顺序呈现:

A
↓
B
↓
C

现在将函数调用替换为延迟时间为零的定时器:

console.log("A");
setTimeout(() => {
  console.log("B");
}, 0);
console.log("C");

顺序发生了变化:

A
C
B

这会让人们感到意外,因为延迟时间实际上是:

0

解释是:setTimeout()从来不是“立即执行此代码”的意思。它只是表示回调在延迟时间过后才有资格被执行,而且仍需等待当前正在运行的代码结束,等运行时系统处理它。决定何时执行的是事件循环的任务。

简述事件循环

对于理解 React 来说,一个简化的图示就足够了:

              ┌─────────────┐
              │ Call Stack  │
              └──────┬──────┘
                     │
                     ↓
              JavaScript Runs
                     │
                     ↓
              Stack becomes free
                     │
                     ↓
                Event Loop
                     │
              ┌──────┴──────┐
              ↓             ↓
         Microtasks       Tasks

代码在栈上运行,直到栈为空。只有此时事件循环才会处理队列中的任务:首先执行微任务(如已解决的 Promise 回调),然后再处理下一个任务(如定时器回调或输入事件)。真正的浏览器和运行时还会添加更多细节,包括任务之间的渲染步骤,但这里有一条规则最为重要:正在运行的同步 JavaScript 不能被其他 JavaScript 打断。如果线程处于忙碌状态,所有其他任务都必须等待。有关队列的更多信息,请参阅闭包与事件循环。

故意冻结的应用程序

要了解这条规则对 React 的影响,可以想象这样一个组件:其点击处理函数会在更新状态之前进入一个繁忙循环,持续约五秒。示例使用了 React Native 的 Button,但在 React DOM 中行为完全相同:

function App() {
  const [count, setCount] = useState(0);

function handlePress() {
    const start = Date.now();
    while (Date.now() - start < 5000) {
      // intentionally blocking JavaScript
    }
    setCount(c => c + 1);
  }
  return (
    <Button
      title={`Count: ${count}`}
      onPress={handlePress}
    />
  );
}

在这五秒钟内,JavaScript 线程:

JavaScript Thread

的状态如下:

██████████████████████████████       BLOCKED FOR 5 SEC

已排队的更新、高优先级任务、准备处理的 Fiber 树——这些都不重要,因为 React 本身也是 JavaScript,需要轮流使用线程。调度器无法从运行时外部抢占 CPU。

谁在掌控一切

人们很容易认为 React 处于主导地位,能够指挥 JavaScript:

React Scheduler
      ↓
Controls JavaScript

但实际上情况恰恰相反。运行时负责分配执行时间,而 React 的调度器决定如何使用这些时间:

JavaScript Runtime
      ↓
Provides execution opportunity
      ↓
React Scheduler
      ↓
Coordinates React work

调度器完全运行在JavaScript提供的环境中。它能够巧妙地安排React自身的任务,但只有在获得线程控制权时才会执行操作。

那为何还要有调度器呢?

如果React无法覆盖事件循环,它的调度器又能起到什么作用?试想一个包含数千个元素的界面:

App
├── Header
├── Search
├── Filters
├── ProductList
│   ├── Product
│   ├── Product
│   ├── Product
│   ├── ...
│   └── Product × 5000
└── Footer

在纯同步模型下,渲染这棵节点树会形成一个连续不断的单块任务:

Start Render
     ↓
████████████████████████████
     ↓
Finish Entire Render

与此同时,用户仍在持续进行交互操作:

Type
Tap
Scroll

如果渲染是一个庞大的单一任务,这些交互操作就会排队等待该任务完成,从而导致界面卡住。React需要的是将渲染拆分成多个独立单元,并与宿主系统协作,定期交还线程控制权,以便进行输入处理和画面绘制。

协作式调度

React 的内部调度器正是这种协作型系统。与旧式的指令处理方式不同:

Render everything NOW

它会不断针对待处理任务提出一系列问题:

There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?

从流程上看,这个循环是这样的:

Pending React Work
        ↓
     Scheduler
        ↓
Check Priority
        ↓
Perform Some Work
        ↓
Should We Yield?
   ↙             ↘
 YES             NO
  ↓               ↓
Give Host       Continue
a Chance        Rendering

React 会执行一段任务,检查是否需要让出控制权,如果是则将控制权交还给宿主环境,以便浏览器(或原生运行时)能够处理输入、执行其他任务并绘制界面。之后它会从上次停止的地方继续,或重新考虑下一步要处理的任务。

关键在于协作。React 并没有剥夺 JavaScript 的任何功能;它只是对自己的任务进行结构化处理,从而避免不必要的线程占用。不过,只有所有参与方都配合,协作才能实现。

两层结构,两个问题

将这种差异表述为两个独立的问题会更有帮助。

  • 事件循环会询问:JavaScript何时可以执行其队列中的任务?
  • React的调度器会询问:既然已经有了线程,哪些React任务应该优先处理?

将这些层次结构叠加起来,看起来是这样的:

Browser / Native Runtime
          ↓
    JavaScript Runtime
          ↓
       Event Loop
          ↓
 JavaScript gets to run
          ↓
   React Scheduler
          ↓
 Choose React Work
          ↓
       Fiber
          ↓
   Render / Reconcile
          ↓
       Commit

每一层都在其上层结构内部运行。

当输入操作与大规模渲染同时进行时

想象React正在渲染一个庞大的产品列表的过程中:

Rendering Products...
██████████████░░░░░░░░

此时用户开始逐个输入搜索关键词:

R
Re
Rea
React

现在React中有两种类型的任务在争夺同一条线程:

Large List Rendering
        +
User Input Update

用户更关心输入操作:即使列表渲染稍有延迟,每个字符也应立即显示。通过为按键操作设定优先级并中断列表渲染,可以实现这一目标。

Fiber如何提供分界点

只有当工作内容确实可以拆分时,调度才有意义。这正是Fiber的作用:每个fiber代表任务树中的一个部分,从而使渲染变成一系列小步骤,而非一个整体操作:

App Fiber
   ↓
Header Fiber
   ↓
Search Fiber
   ↓
List Fiber
   ↓
Product Fiber
   ↓
Product Fiber
   ↓
...

通过并发渲染,React可以处理部分步骤,暂停以处理更紧急的任务,然后再继续或重新开始:

Start Rendering
      ↓
Perform Some Fiber Work
      ↓
Pause
      ↓
Handle More Urgent Work
      ↓
Continue / Restart

React的文档正是从这种可中断的角度来描述并发渲染的:渲染可以启动、暂停、稍后恢复,或者如果已过时则被放弃,而这一切都在任何内容被提交之前发生。这与说React并行运行JavaScript的说法截然不同,实际上它并非如此。

并发不等于并行

这句话:

Concurrent React

人们往往会想到为每项任务分配专用线程:

Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering

这种模型是错误的。React 的并发机制是在单线程上交错执行任务,更接近于以下方式:

Work A
██████░░░░░░        ↓ pauseWork B
████        ↓Work A
██████

任务 A 执行一段时间后暂停,接着任务 B 执行,之后任务 A 再继续。不同的渲染内容会随着时间被协调处理,但组件的代码永远不会同时在多个线程上执行。

在过渡期间有紧急任务到达

假设 React 正在渲染一个被包裹在过渡机制中的复杂更新:

startTransition(() => {
  setResults(expensiveResults);
});

当该渲染仍在执行时,用户又输入了另一个字符。此时的流程大致如下:

Transition Render
██████████░░░░░░░
          ↓      User Types          ↓Urgent Update
████████          ↓React can reconsider
lower-priority work

由于该过渡任务并非紧急,React 可以暂缓处理它,先响应键盘输入,然后再回到优先级较低的任务上,通常会用新的状态重新启动该任务。

延迟值而非更新

useDeferredValue采用了与读取端相同的思路。与其将状态更新标记为非紧急,不如生成该值的延迟副本:

const deferredQuery = useDeferredValue(query);

输入端始终使用真实且最新的状态:

<TextInput
  value={query}
  onChangeText={setQuery}
/>

而成本较高的组件则接收延迟后的副本:

<ExpensiveList query={deferredQuery} />

因此,该字段已经能够显示:

React

与此同时,列表仍会短暂反映之前的查询结果:

Reac

与此同时,React会在一旁准备新的列表,每当有新的按键输入时,它就会丢弃之前的尝试,重新开始最新的查询。这样做并不会让那些计算成本较高的组件运行得更快;其目的纯粹是为了防止不太重要的渲染操作干扰用户正在操作的交互。在这两种方法之间做选择通常很简单:如果你能控制状态更新,就使用startTransition;而如果你只是接收某个值,比如作为属性传入,那就使用useDeferredValue。

限制:调度机制无法抢占你的代码执行

以上所有内容都依赖于React能够定期获取回线程控制权。现在考虑一个需要执行大量同步计算的函数:

function calculateEverything() {
  for (let i = 0; i < 10_000_000_000; i++) {
    // huge synchronous calculation
  }
}

在它运行期间,JavaScript线程:

JS THREAD

会完全被占用:

████████████████████████████Scheduler waiting...React waiting...Input waiting...

调度器、React 以及待处理的输入都会同时等待。协作式调度只能在 React 自身工作的单元之间进行切换,无法打断你编写的循环。因此,并发渲染并不能替代高效的 JavaScript 代码。复杂的计算仍然需要被简化、分块处理、加入缓存,或者从主线程中移出,例如放到工作线程中。

为何渲染与提交要分开

同样的原理也解释了为什么 React 坚持将更新过程分为两个阶段:

Render

以及:

Commit

渲染阶段是 React 确定界面应呈现为何样貌的环节,在并发模式下这一准备过程可能会被中断。但 React 绝不会显示未完成的结果。因此在渲染阶段:

Render

React 可能会完成某些任务流,随后被中断并重新开始或继续执行,只有当整个组件树都准备就绪时才会进行提交:

Fiber A ✓
Fiber B ✓
Fiber C ...
      ↓
Interrupted
      ↓
Continue / Restart
      ↓
Entire Tree Ready
      ↓
COMMIT
      ↓
Visible UI

这就是为什么被中断的渲染永远不会产生混合了不同状态的界面:

Half old UI
+
Half new UI

提交操作只会应用一致且已完成的结果。

为何渲染必须是纯函数

“在渲染过程中保持组件为纯函数”是一条常见的建议,而可中断的渲染正好说明了其重要性。来看一个在组件内部触发分析调用的例子:

function Product() {
  analytics.track("Product Rendered");
return <View />;
}

在并发渲染模式下,React 可能会经历这样的流程:

Starts Render
    ↓
Pauses
    ↓
Abandons Work
    ↓
Renders Again

每一次尝试都会执行组件函数,因此这次调用:

analytics.track(...)

可能会因从未被提交且无人查看的渲染而触发,从而导致指标统计出现虚假的产品浏览量。React需要这样的自由:

Render
Pause
Retry
Abandon
Restart

且这些操作都不会改变外部世界。副作用应放在effects或事件处理函数中,因为它们仅会在已提交的代码执行时运行。关于如何选择逻辑应放置位置的实用指南,请参阅选择合适的React工具:derive、handle、fetch、defer还是effect。

React Native的视角

在React Native应用中,从按键输入到屏幕显示的流程会经过相同的层级,最终由原生渲染器完成:

User Types
    ↓
JS Event Handler
    ↓
setState()
    ↓
React Scheduler
    ↓
Fiber Work
    ↓
React Native Renderer
    ↓
Native UI Update

如果用耗时的同步操作阻塞JavaScript线程,整个流程会在第二步就停止:

User Types
    ↓
JS Thread Busy
████████████████
    ↓
React can't process JS work yet

尽管原生UI线程本身可能运行正常,但应用仍会出现卡顿现象。在诊断性能问题时,有必要区分各种可能的原因,因为每种原因都需要不同的解决方案:

Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?

过多的渲染调用需要通过记忆化、虚拟化或过渡效果来处理。被阻塞的JavaScript线程则需要将繁重任务移走或拆分。而耗时的原生操作或过载的UI线程则应通过原生性能分析来解决。

完整的处理流程

将所有环节整合起来,一次用户交互会依次经历这些阶段:

USER INTERACTION
       ↓
JavaScript Runtime
       ↓
Event Handler
       ↓
setState()
       ↓
Update Created
       ↓
Lane / Priority
       ↓
React Scheduler
       ↓
Choose Work
       ↓
Fiber Render
       ↓
Can Yield / Restart
       ↓
Reconciliation
       ↓
Completed Work
       ↓
Commit
       ↓
UI Updated

而所有这些都在JavaScript运行时的范围内发生:

┌─────────────────────────────┐
│     JavaScript Runtime      │
│                             │
│ Event Loop gives JS a chance│
│            ↓                │
│     React coordinates       │
│        its own work         │
└─────────────────────────────┘

事件循环为JavaScript分配执行时间;在每次执行周期内,React会决定如何利用这段时间。

值得掌握的解答问题

React的调度器与事件循环是同一回事吗?

不是。事件循环属于宿主运行时环境。React的调度器在运行时允许的时间范围内协调React的各项任务。

Concurrent React会在多个线程上运行组件吗?

不会。并发渲染允许React在单个线程上暂停、恢复、重新开始或放弃渲染任务,组件代码并不会变成并行处理的。

React可以中断渲染吗?

在并发渲染模式下可以。React可以将紧急更新优先处理,并在提交之前丢弃过时的渲染结果。

调度器能拯救被阻塞的线程吗?

不能。耗时的同步JavaScript代码会像其他所有任务一样让React一直等待,直到该线程空闲为止。

为什么组件必须保持纯函数形式?

因为渲染过程可能会多次执行,也有可能被中断或丢弃。渲染过程中的副作用会泄露到那些从未显示在屏幕上的内容中。

心智模型的演变

大多数开发者最初认为,状态改变会直接导致重新渲染:

setState()
   ↓
React renders
   ↓
Screen updates

了解任务队列和调度机制后,会在其中增加几个阶段:

setState()
   ↓
Update
   ↓
Lane
   ↓
Scheduler
   ↓
Render
   ↓
Commit

最底层则是运行时本身,它位于React所有操作的下方:

JavaScript Runtime
       ↓
Event Loop
       ↓
React Scheduler
       ↓
Fiber
       ↓
Render
       ↓
Commit

React会智能地安排自身的任务,但始终在JavaScript环境中进行。从这个角度来看,过渡、可中断的渲染以及被阻塞的JS线程都符合统一的逻辑。

处理流程的未来方向

本文追踪了从状态更新到事件循环的整个路径:

State Update
    ↓
Update Queue
    ↓
Fiber
    ↓
Lanes
    ↓
Scheduler
    ↓
JavaScript Event Loop

在 React Native 中,这一流程还会继续延伸,从 React 的代码提交到实际的平台视图。结构如此简单:

<View>
  <Text>Hello React Native</Text>
</View>

在最终变成 Android 或 iOS 的原生界面之前,它仍需经过打包工具、Hermes 引擎、JSI 以及 Fabric 渲染器。这些层各自带有独立的线程和调度机制,一旦理解了 JavaScript 端的原理,就有必要单独了解它们。

关键要点

  • 事件循环决定 JavaScript 的运行时机;React 的调度器则决定在何时执行相应的 React 任务。
  • React 的调度是协作式的:它在不同的 Fiber 任务之间切换,以便进行输入处理和渲染操作。
  • React 中的并发指的是在单线程上的交替执行,而非并行执行。
  • startTransition会将更新标记为非紧急;useDeferredValue则允许组件使用延迟的值进行渲染。
  • 没有任何调度器能够中断你自己的长时同步代码,因此复杂的计算仍需优化或移出同步执行流程。
  • 提交的操作只处理完整的结果,且渲染过程必须是纯净的,因为它们可以被重试或丢弃。
  • 当五秒长的同步计算占用线程时,高优先级的更新无法进行渲染,它必须等待该代码执行完毕。