React19事件调度的设计思路

先说结论,React 选择 MessageChannel 完成事件调度,是因为它:

  • 属于宏任务(不会饿死浏览器:JavaScript 一直占着主线程,导致浏览器一直没有机会去做它必须做的事(渲染、响应输入、布局、绘制))
  • 延迟极低(接近微任务,但不会阻塞渲染)
  • 相较于 rAF 不绑定渲染帧
  • 可控、可中断、可让出主线程

一、React 调度和事件循环的密切联系

1、React 在"调度"什么?

React 调度的不是「事件」, React 调度的是:Fiber 渲染任务(render work)

也就是我上篇文章说过的这些东西:

  • beginWork
  • completeWork
  • diff
  • 构建 workInProgress Fiber 树

React Scheduler 的目标只有是:在不阻塞浏览器的前提下,尽可能多地推进 Fiber 渲染进度。

所以 Scheduler 需要满足:

  • 能反复被调用
  • 每次执行一小部分
  • 执行完就"让出主线程"

2、回忆浏览器事件循环

事件循环模型:

Plain 复制代码
┌─────────────┐
│ 宏任务队列(Task)     │  ← setTimeout / MessageChannel / rAF callback
└─────┬───────┘
          ↓
      执行 JS
          ↓
┌─────────────┐
│ 微任务队列(一次性清空) │  ← Promise.then / queueMicrotask
└─────┬───────┘
          ↓
      清空所有微任务
          ↓
      浏览器渲染(paint)

因此,为了满足上述 Scheduler 的需求,我们只能选择 Task(后续详细说明为什么最终选择了 MessageChannel)。

二、React Scheduler 源码(React 19)

packages/scheduler/src/forks/SchedulerHostConfig.default.js

核心逻辑(简化):

TypeScript 复制代码
const channel = new MessageChannel();
const port = channel.port2;

channel.port1.onmessage = performWorkUntilDeadline;

function requestHostCallback() {
  port.postMessage(null); // 用 MessageChannel 来"自我唤醒"
}

Scheduler 执行模型:

Plain 复制代码
MessageChannel 回调触发
↓
performWorkUntilDeadline
↓
while (还有任务 && 没超时) {
  执行 Fiber work
}
↓
时间不够 → 再发一次 MessageChannel(MessageChannel 是"下一次调度 tick"的触发器)

三、为什么不用微任务(Promise / queueMicrotask)

假如 React 用微任务会发生什么?

TypeScript 复制代码
Promise.resolve().then(workLoop)

问题 1:会阻塞渲染

微任务会在 paint 之前全部执行完

意味着:

Plain 复制代码
React 继续 work
→ work 里又调度微任务
→ 浏览器:你先别画
→ UI 卡死

这完全就是 Fiber 的"时间切片"的对立做法。

问题 2:微任务不可中断

  • 微任务一旦开始
  • 浏览器必须清空
  • React 无法"让出主线程",更没法实现并发渲染

四、为什么不用 setTimeout

setTimeout 的问题不是"慢",而是"不稳定"。

问题 1:最小延迟不可靠

  • HTML 标准:​最小 4ms(​ HTML Living Standard --- Last Updated 31 January 2026 If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

    setTimeout 在嵌套层级超过 5 层,timeout(延时)如果小于 4ms,那么则会设置为 4ms,这个时差是 React 无法接受的。

  • 精度太粗(Scheduler:"当前帧还能不能再干 2ms 的活?")

五、为什么不用 requestAnimationFrame(rAF)

1、rAF 被绑定到"渲染帧"

Plain 复制代码
一帧 ≈ 16.6ms

但 React 的目标是:​只要主线程空一点,我就推进一点 Fiber;​而不是:"非要等下一帧"。

2、rAF 在后台不执行

浏览器会暂停 rAF(选择性跳过渲染帧),React 更新直接"冻结"!

六、还得是 MessageChannel ~

MessageChannel 是什么?

JavaScript 复制代码
const channel = new MessageChannel();
// 两个频道端口,这两个端口可以相互通信
const port1 = channel.port1;
const port2 = channel.port2;
btn1.onclick = function(){
  // port2 给 port1 发消息
  port2.postMessage(content.value);
}
// port1 监听自己受到的消息
port1.onmessage = function(event){
  console.log(`port1 收到了来自 port2 的消息:${event.data}`);
}

MessageChannel 完美规避掉上述一系列缺点:

MessageChannel + shouldYield => 时间切片。

React 并不是"无脑跑",而是每一小段都问一句:

TypeScript 复制代码
shouldYield()

判断依据:

  • performance.now()
  • 帧预算
  • 用户输入是否 pending

如果该让出:

TypeScript 复制代码
requestHostCallback() // 再发一个 MessageChannel
return;
Plain 复制代码
[宏任务] MessageChannel
  ↓
  React 执行 Fiber work(2~5ms)
  ↓
  shouldYield = true
  ↓
  postMessage 再约一次
  ↓
[浏览器有机会 paint / 处理输入]
  ↓
[下一次 MessageChannel]

七、彩蛋来咯

1、requestAnimationFrame

盲猜很多同学对于上面若干种不如 MessageChannel 的做法还不是很清楚,根本在于事件循环掌握的不好,我这里针对事件循环的**requestAnimationFrame**详细讲讲(其他知识点可以翻看我之前写的关于事件循环的文章,讲解的非常清楚)。

事件循环里面的 requestAnimationFrame 仅仅是一个跟着渲染帧走的"小弟",有渲染才有 rAF:

  • 它不能"缩短"上一个 16.66ms 中 Task 的执行时间
  • 保证回调只会在"浏览器即将渲染下一帧之前"执行

因此如果上一帧的 Task 太重导致错过渲染窗口,浏览器会直接"丢帧",而不是排队执行导致连锁累积卡顿(setTimeout 的做法)

rAF 回调永远不会挤占渲染时机,只会"对齐"渲染节奏
"丢帧"这个概念,对于数码产品经常关注的同学应该会非常熟悉。我们拿游戏"原神"举例子,帧率越高动画越流畅,而如果某一帧事件 Task 执行时间太长(超过 1 帧总时长),rAF 就不再执行,这帧就被自动"丢掉了"。而一些手机厂商为了弥补这个问题,所以就出现了手动"插帧"的做法。

一般地,1s 对应着 60 帧,而 1 帧就是 16.66ms。如果一个 Task 超过了 16.66ms,那么就占用了下一帧的时间,下一帧则不再 rAF/paint (出现丢帧)。但如果我们使用低帧率,假如使用 30 帧 1s,那么 1 帧就是 33.3ms,这样虽然画质变差了,但是动画流畅度确实更好了。

浏览器在一帧内要做的事情(简化):

Plain 复制代码
JS Task(古老说法:宏任务)
→ 微任务
→ rAF
→ 样式计算
→ Layout
→ Paint
→ Composite
→ 屏幕显示

只要 JS Task 超过 ~16ms,浏览器就来不及渲染这一帧​,结果就是:

  • 这一帧直接没画出来(掉帧)
  • 用户看到卡顿

假设这样写动画:

JavaScript 复制代码
setTimeout(step, 16)

发生了什么?

Plain 复制代码
Task A (20ms)  超过 16ms
↓
setTimeout 回调排队
↓
Task B (又 20ms)
↓
Task C ...

后果是:

  • 定时器 只管时间,不管渲染(这是"时间驱动",不是"渲染驱动")
  • 回调会 持续排队
  • 每一帧都被 JS Task 挤爆
  • 卡顿会 累积 + 放大

如果改为 rAF:

JavaScript 复制代码
requestAnimationFrame(callback) // "当浏览器准备开始下一次渲染之前,调用我"
Plain 复制代码
while (true) {
  1. 取一个 Task 执行(macro task)
  2. 执行所有 microtasks
  3. 【渲染检查点】(当前时间 - 上一帧渲染时间 < 16.66ms(60Hz))
     - requestAnimationFrame
     - style / layout / paint
}

当然,如果 Task 一直执行得太久,requestAnimationFrame 一直得不到执行,本质上仍然是卡顿,而且是「主线程被长期占用型卡顿」。所以 rAF 并不能拯救被 JS 完全占死的主线程。

2、用时间轴演示卡顿

卡顿:场景一

类型一:JS 把主线程彻底占死(致命卡顿)

Task 200msTask 200msTask 200ms

结果:

  • rAF
  • Render
  • 输入响应
  • 页面假死

rAF 无解

类型二:单帧偶尔超时(可恢复卡顿)

Task 20ms(偶发)Task 5msTask 5ms

结果:

  • 掉 1 帧
  • 后续帧恢复
  • 动画继续

这是 rAF 的"主战场"

卡顿:场景二

假设场景

  • 屏幕 60Hz(16.6ms / 帧)
  • 每个动画 step 的 JS 执行 18ms
  • 使用 setTimeout(step, 16)

第 1 帧(已经开始出问题)

0ms Task: step 执行(18ms)18ms microtasks18ms ❌ 超过 16.6ms,无法渲染18ms setTimeout 已经到期 → 下一个 step 已在 Task 队列中

结果:没渲染,但 JS 没停

第 2 帧(开始积压)

18ms Task: step 执行(18ms)36ms microtasks36ms ❌ 又错过渲染36ms 下一个 step 继续排队

第 N 帧(雪崩)

Task → Task → Task → Task → Task 18ms 18ms 18ms 18ms 18ms

表现为:

  • JS 一直在跑
  • 浏览器几乎没有 Render 机会
  • 页面看起来 卡住不动
  • CPU 占满

setTimeout 只认:时间到了 → 执行回调

不管:

  • 主线程忙不忙
  • 能不能渲染
  • 用户是不是在滚动 / 点击

当一帧没画出来:

  • rAF:直接跳过
  • setTimeout:继续补执行(它会制造"补帧")

这意味着:错过的帧会变成多余的 JS 工作量

3、用户体感 vs setTimeout

setTimeout(雪崩)

Task Task Task Task Task 18ms 18ms 18ms 18ms

  • JS 连续霸占主线程
  • Render 几乎进不去
  • 页面"僵死"

requestAnimationFrame(稳定但慢)

step →(等下一帧)→ step →(等下一帧)→ step

  • 每帧最多执行一次
  • Render 之间有喘息
  • 页面还能响应输入
  • 动画只是 低 FPS(这是"慢",不是"死")
相关推荐
Emma_Maria2 小时前
本地项目html和jquery,访问地址报跨域解决
前端·html·jquery
奋斗吧程序媛2 小时前
常用且好用的命令
前端·编辑器
2301_796512522 小时前
【精通篇】打造React Native鸿蒙跨平台开发高级复合组件库开发系列:Lazyload 懒加载(懒加载的图片)
前端·javascript·react native·react.js·ecmascript·harmonyos
敲敲了个代码2 小时前
从N倍人力到1次修改:Vite Plugin Modular 如何拯救多产品前端维护困境
前端·javascript·面试·职场和发展·typescript·vite
摘星编程2 小时前
OpenHarmony环境下React Native:Timeline时间轴组件
javascript·react native·react.js
摘星编程2 小时前
在OpenHarmony上用React Native:Timeline水平时间轴
javascript·react native·react.js
Yff_world2 小时前
网络安全与 Web 基础笔记
前端·笔记·web安全
Sapphire~2 小时前
Vue3-19 hooks 前端数据和方法的封装
前端·vue3
浩宇软件开发2 小时前
基于OpenHarmony鸿蒙开发医院预约挂号系统(前端后端分离)
前端·华为·harmonyos