React

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > React > React19事件调度

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

作者:秀秀不只会前端

本文主要介绍了React选择MessageChannel作为事件调度机制的原因,essageChannel属于宏任务,延迟极低且不会阻塞渲染,能够满足React在不阻塞浏览器的前提下,尽可能多地推进Fiber渲染进度的需求

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

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

1、React 在“调度”什么?

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

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

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:微任务不可中断

四、为什么不用 setTimeout

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

问题 1:最小延迟不可靠

五、为什么不用 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()

判断依据:

如果该让出:

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

[宏任务] MessageChannel
  ↓
  React 执行 Fiber work(2~5ms)
  ↓
  shouldYield = true
  ↓
  postMessage 再约一次
  ↓
[浏览器有机会 paint / 处理输入]
  ↓
[下一次 MessageChannel]

七、彩蛋来咯

1、requestAnimationFrame

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

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

因此如果上一帧的 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 ...

后果是:

如果改为 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 无解

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

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

结果:

这是 rAF 的“主战场”

卡顿:场景二

假设场景

第 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

表现为:

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

不管:

当一帧没画出来:

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

3、用户体感 vs setTimeout

setTimeout(雪崩)

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

requestAnimationFrame(稳定但慢)

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

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

您可能感兴趣的文章:
阅读全文