浅谈React19事件调度的设计思路

 更新时间:2026年05月11日 09:16:53   作者:秀秀不只会前端  
本文主要介绍了React选择MessageChannel作为事件调度机制的原因,essageChannel属于宏任务,延迟极低且不会阻塞渲染,能够满足React在不阻塞浏览器的前提下,尽可能多地推进Fiber渲染进度的需求

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

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

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

1、React 在“调度”什么?

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

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

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

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

所以 Scheduler 需要满足:

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

2、回忆浏览器事件循环

事件循环模型:

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

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

二、React Scheduler 源码(React 19)

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

核心逻辑(简化):

const channel = new MessageChannel();
const port = channel.port2;

channel.port1.onmessage = performWorkUntilDeadline;

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

Scheduler 执行模型:

MessageChannel 回调触发

performWorkUntilDeadline

while (还有任务 && 没超时) {
  执行 Fiber work
}

时间不够 → 再发一次 MessageChannel(MessageChannel 是“下一次调度 tick”的触发器)

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

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

Promise.resolve().then(workLoop)

问题 1:会阻塞渲染

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

意味着:

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 被绑定到“渲染帧”

一帧 ≈ 16.6ms

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

2、rAF 在后台不执行

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

六、还得是 MessageChannel ~

MessageChannel 是什么?

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 并不是“无脑跑”,而是每一小段都问一句:

shouldYield()

判断依据:

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

如果该让出:

requestHostCallback() // 再发一个 MessageChannel
return;

[宏任务] 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,这样虽然画质变差了,但是动画流畅度确实更好了。

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

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

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

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

假设这样写动画:

setTimeout(step, 16)

发生了什么?

Task A (20ms)  超过 16ms

setTimeout 回调排队

Task B (又 20ms)

Task C ...

后果是:

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

如果改为 rAF:

requestAnimationFrame(callback) // “当浏览器准备开始下一次渲染之前,调用我”
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(这是“慢”,不是“死”)

到此这篇关于浅谈React19事件调度的设计思路的文章就介绍到这了,更多相关React19事件调度内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • 基于React编写一个全局Toast的示例代码

    基于React编写一个全局Toast的示例代码

    前些日子在做项目的时候,需要封装一个Toast组件,我想起之前用过的库,只要在入口文件中引入就可以在全局中使用,还是很方便的,借这次机会也来实现一下,所以本文介绍了React中如何编写一个全局Toast,需要的朋友可以参考下
    2024-05-05
  • antd table动态修改表格高度的实现

    antd table动态修改表格高度的实现

    本文主要介绍了antd table动态修改表格高度的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-07-07
  • React实现路由返回拦截的三种方式

    React实现路由返回拦截的三种方式

    最近项目为了避免用户误操作导致数据丢失,增加返回拦截功能,但是之前由于qiankun的报错导致这个功能一直有一些问题,所以专门独立搞了一个专题研究在react中各种方式实现这个功能,需要的朋友可以参考下
    2024-05-05
  • react中实现修改input的defaultValue

    react中实现修改input的defaultValue

    这篇文章主要介绍了react中实现修改input的defaultValue方式,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-05-05
  • React18中请求数据的官方姿势适用其他框架

    React18中请求数据的官方姿势适用其他框架

    这篇文章主要为大家介绍了官方回答在React18中请求数据的正确姿势详解,同样也适用其他框架,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-07-07
  • React native ListView 增加顶部下拉刷新和底下点击刷新示例

    React native ListView 增加顶部下拉刷新和底下点击刷新示例

    这篇文章主要介绍了React native ListView 增加顶部下拉刷新和底下点击刷新示例,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-04-04
  • 前端面试题必会之前端react面试题

    前端面试题必会之前端react面试题

    在前端面试过程中经常会问到一些面试题,今天小编抽空给大家讲解前端面试题之必会react面试题,需要的朋友可以参考下
    2023-03-03
  • React学习笔记之事件处理(二)

    React学习笔记之事件处理(二)

    这篇文章主要跟大家介绍了关于React中事件处理的相关资料,文中通过示例代码介绍的非常详细,对大家学习React具有一定的参考学习价值,需要的朋友们下面跟着小编一起来学习学习吧。
    2017-07-07
  • React 之最小堆min heap图文详解

    React 之最小堆min heap图文详解

    这篇文章主要为大家介绍了React 之最小堆min heap图文详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-11-11
  • 浅谈React Native 中组件的生命周期

    浅谈React Native 中组件的生命周期

    本篇文章主要介绍了浅谈React Native 中组件的生命周期,非常具有实用价值,需要的朋友可以参考下
    2017-09-09

最新评论