时间切片

45 阅读4分钟

这是一个非常深刻的问题,直击了浏览器运行机制的核心。

作为一个 Senior Dev,我们要回答“为什么需要时间切片”,不能只说“为了不卡顿”,必须从浏览器的底层运行机制JavaScript 的执行模型这两个维度来解释。

简单来说,Scheduler 实现时间切片是为了解决JavaScript 执行与浏览器渲染互斥的问题。

以下是详细的深层逻辑:

1. 根本矛盾:GUI 渲染线程与 JS 引擎线程是互斥的

在浏览器中,JavaScript 的执行页面的绘制(Layout, Paint) 是运行在同一个主线程(Main Thread)上的。这就像是一个只有一个窗口的办事大厅:

  • 窗口里只有一个人(主线程)。
  • 他既要负责处理逻辑(执行 JS 代码,React 的 Diff 算法)。
  • 又要负责画图(浏览器重排、重绘、响应动画)。

互斥意味着: 当 JS 在执行时,浏览器就无法绘制,也无法响应用户的点击;反之亦然。

2. “16.6ms 黄金法则”与长任务(Long Task)

为了让用户感觉页面是“流畅”的,屏幕刷新率通常是 60Hz,也就是说,每一帧的预算只有 16.6ms(1000ms / 60 ≈ 16.6ms)。

在这 16.6ms 里,浏览器需要做很多事:

  1. 执行 JS 脚本。
  2. 样式计算(Style Calculation)。
  3. 布局(Layout)。
  4. 绘制(Paint)。
  5. 合成(Composite)。

如果没有时间切片(React 15 及以前): 假设你的 React 应用组件树很深,一次更新(Diff + Patch)需要计算 50ms。 因为 JS 执行是同步不可中断的(Run-to-completion),在这 50ms 内,主线程一直被 React 占用。

  • 结果: 浏览器错过了一次甚至两次画面刷新(掉帧)。
  • 用户感知: 页面卡顿、点击按钮没反应、输入框文字出不来。这就是所谓的“长任务阻塞”。

3. 时间切片(Time Slicing)的解法

Scheduler 的核心目标就是:打破“执行完才能停”的规则。

它把那个 50ms 的大任务,切成 10 个 5ms 的小任务:

  1. 执行 5ms (React 工作): 比如 diff 了 50 个组件。
  2. Scheduler 检查时间: “哎呀,5ms 到了,该把主线程交还给浏览器了(Yield to host)。”
  3. 浏览器喘息: 浏览器利用这个间隙去处理点击事件、去绘制一帧动画。
  4. 继续执行: 浏览器忙完了,Scheduler 再把 React 叫回来,继续 diff 剩下的组件。

这样做的结果是: 虽然处理完整个任务的总时间可能从 50ms 变成了 55ms(因为有切换开销),但在用户看来,界面始终是活的,每 16ms 都能刷新一次,完全感知不到卡顿。


4. 为什么 Scheduler 要自己实现?(技术深挖)

你可能会问:“JS 不是有 setTimeout 吗?浏览器不是有 requestIdleCallback 吗?为什么要 React 自己写一个 Scheduler?”

这也是面试中常考的高级点:

(1) requestIdleCallback 的缺陷

浏览器原生提供了 requestIdleCallback (rIC),它的初衷就是在“浏览器空闲”时执行低优先级任务。React 最初确实想用它,但遇到了两个问题:

  • 兼容性: iOS Safari 很多版本不支持,且不稳定。
  • 触发频率不稳定: rIC 只有在浏览器真的“没事干”时才触发。如果用户一直在滚动页面,或者有高频动画,rIC 可能一秒钟只能执行很少几次(甚至只有 20fps)。这会导致 React 的更新迟迟不执行。

(2) setTimeout 的精度问题

如果用 setTimeout(fn, 0),浏览器会有 4ms 的最小延迟(在嵌套层级深时)。对于追求极致性能的 React 来说,每切一片浪费 4ms 是不可接受的。

(3) React Scheduler 的实现 (MessageChannel)

React 团队最终选择了使用 MessageChannel 配合 requestAnimationFrame (早期) 或者微任务机制来模拟。

  • 它创建了一个宏任务(MacroTask)。
  • React 默认给每个任务切片分配 5ms 的时间。
  • 它使用 performance.now() 高精度计算当前任务执行了多久,一旦超过 5ms,就通过 MessageChannel 发送消息,触发下一个宏任务,从而把控制权**让出(Yield)**给浏览器,让浏览器有机会在这个间隙插入绘制任务。

总结

为什么 Scheduler 需要实现时间切片?

  1. 物理限制: JS 线程和 UI 渲染线程互斥。
  2. 体验底线: 必须在 16.6ms 内归还控制权给浏览器,否则就会掉帧(卡顿)。
  3. 架构需求: React 的更新往往是计算密集型的(CPU Bound),必须通过切片把“大山”炸碎成“石子”,才能在单线程的 JS 世界里实现“并发”的错觉,保证高优先级的交互(如输入)永远能插队执行