mCell
5 / 14

时间分片(Time Slicing):React 如何保证界面的流畅度?

从“输入卡顿、滚动掉帧”这个体验问题出发,解释 React 为什么要把渲染拆成可中断任务:一帧 16.67ms 的预算、让出主线程的时机、render/commit 的可中断边界,以及优先级如何影响“先响应还是先渲染”

你在 React 项目里做过“性能优化”大概率都碰到过这种怪事:

  • 数据量一大,输入框打字开始“吞字”
  • 滚动列表时明显掉帧
  • 点击按钮要等一会儿才响应

你可能已经知道 Fiber 让渲染“可中断”。
但这篇要把关键问题讲清楚:React 什么时候中断?凭什么中断?中断后怎么继续?又如何决定优先做哪件事?

这些答案汇总成一个词:时间分片(Time Slicing)


0. 先给结论:Time Slicing 的目标不是“更快渲染”,而是“更可交互”

Time Slicing 的核心目标是:

  • 把一次可能很长的渲染工作拆成很多小段(slices)
  • 在段与段之间主动让出主线程
  • 让浏览器有机会处理用户输入、滚动、绘制
  • 通过优先级让“用户感知更强的任务”先完成

一句话:

牺牲一点“尽快做完所有工作”的冲动,换取更稳定的交互体验。


1. 为什么一帧只有 16.67ms 会把你逼疯?

以 60fps 为例:

  • 每帧 ~ 16.67ms
  • JS + 渲染 + 绘制都要在这点时间里完成

如果你的 React 更新(计算 + 提交 + 浏览器绘制)超过 16.67ms:

  • 帧就会错过
  • 用户看到的就是卡顿(jank)

你可以把“流畅”理解成:

浏览器能按时交作业(每 16.67ms 交一帧)。

而 React 最大的问题是:

  • 一次更新可能很大
  • 如果 React 把主线程占满,浏览器就没法按时交作业

2. React 如何“让出主线程”?关键在于:把 render 拆成可暂停的 work loop

Fiber 时代的 render 阶段(计算阶段)是一种可迭代过程:

  • 每次处理一个 fiber(一个 work unit)
  • 处理完一个 fiber,就检查“要不要停下来”

这意味着 React 在执行中会不断问自己一个问题:

我现在还剩多少时间?继续做会不会影响浏览器下一帧?

如果答案是“会”,就暂停,等下一次有空继续。

这就是 time slicing 的核心行为:合作式调度(cooperative scheduling)


3. render 可以中断,commit 为什么不能?

这是理解 time slicing 的分水岭。

3.1 render 阶段是“纯计算 + 标记”

render 阶段主要做:

  • 计算新的树结构(workInProgress)
  • diff 与复用策略
  • 生成副作用标记(flags)
  • 不直接操作 DOM(或尽量不做不可逆操作)

所以它可以暂停:

  • 中间结果在 workInProgress 里
  • 甚至可以丢弃重算
  • 不会让 UI 半更新、半没更新

3.2 commit 阶段是“把变化应用到真实世界”

commit 阶段会:

  • 插入/删除/更新 DOM
  • 执行 layout effect(useLayoutEffect
  • 触发 ref 赋值
  • 以及与浏览器渲染管线产生强耦合

这一步如果被中断,会发生灾难:

  • DOM 更新一半,页面处于“中间态”
  • 用户看到闪烁、错位
  • 外部系统可能收到一半的更新(不可预测)

所以 commit 必须满足:

短、确定、不可中断(atomic)

结论:

  • Time Slicing 主要发生在 render 阶段
  • commit 是“最后一哆嗦”,必须一次性做完

4. Time Slicing 的关键问题:React 怎么知道“时间不够了”?

你不需要记具体实现细节,但要理解它依赖的事实:

  • 浏览器的主线程要做很多事
  • React 只能在 JS 执行权归自己时工作
  • React 需要一个机制判断“是否应该暂停”

工程上常见做法是:

  • 通过调度器(scheduler)记录时间片预算
  • 在 work loop 里不断检查当前时间
  • 超过预算就 yield(让出)

你可以把它想象成这样:

text
我每次只干一小段 干完问一下:还来得及赶上下一帧吗? 来不及:我先停,等会儿再干

这就是为什么 Fiber 必须把工作拆成“一个个 fiber 单元”——不拆就没法在“合适的颗粒度”暂停。


5. 优先级:为什么有的更新“立刻生效”,有的更新“稍后再说”?

Time Slicing 只是“能暂停”,但还不够。真正决定体验的是:

当有多个更新同时存在,React 应该先做哪个?

例如:

  • 用户在输入框打字(高优先级)
  • 同时触发一个大列表过滤(可能低一点)
  • 或后台数据刷新(更低)

你当然希望:

  • 输入先响应
  • 列表可以稍后渐进更新

于是 React 引入了“优先级”的调度策略(概念层面):

  • 更紧急的更新:更早执行、更少被打断
  • 不紧急的更新:可以被推迟、被打断,甚至被合并

5.1 一个直观例子:输入与大渲染冲突

如果没有优先级:

  • 输入事件触发更新
  • 同时列表过滤触发更新
  • React 可能先把列表渲染跑完(很慢)
  • 输入字符要等列表渲染结束才显示出来

有了优先级:

  • React 会优先处理输入相关的更新
  • 把大列表渲染拆开,慢慢做
  • 用户感知上:输入“跟手”,列表稍后更新也能接受

6. 你在 React 18 里感受到的“并发”,本质就是:渲染可以被打断与重启

很多人把“并发(Concurrent)”理解成“多线程”。不是。

在 React 18 的语境里,并发更贴近:

  • render 可以暂停
  • render 可以继续
  • render 可以被更高优先级的更新打断
  • render 的中间结果可以被丢弃,重新 render

你可以把并发理解成:

React 能同时“筹备”多个更新的渲染工作,并根据优先级选择哪个先提交。

而这依赖两个基础:

  • Fiber 的 work loop(可中断)
  • current / workInProgress 双缓存(可丢弃可重来)

7. 一个容易混淆的点:时间分片 ≠ “每次都分片”

Time Slicing 不是说 React 每次更新都要拆成碎片。

拆不拆,取决于:

  • 更新的工作量
  • 当前主线程压力
  • 更新的优先级
  • 调度器判断“是否该让出”

有些更新很小:

  • 一次性跑完反而更快、更简单

有些更新很大:

  • 必须分片,否则就会卡顿

所以你应该把 time slicing 看成一套“自适应策略”,而不是固定行为。


8. 开发者能做什么?(不谈 API,先谈思维)

当你理解 time slicing 后,你会自然得到一些工程启发:

8.1 避免在高优先级交互路径里做重活

比如输入框 onChange 里做超重计算、超大列表过滤。

思路:

  • 交互先响应(轻)
  • 重计算拆出去(可延后)

8.2 让 UI 更新更容易被“分片”

例如:

  • 大列表用虚拟列表
  • 重组件拆小
  • 把渲染拆成可渐进的块(骨架屏/分页/分段加载)

这会让 React 更容易把工作切碎、调度得更合理。


9. 本文小结:Time Slicing 的四个核心点

  1. 目标是更可交互:避免掉帧与输入延迟
  2. 通过 Fiber 把 render 拆成 work unit,在段与段之间检查是否需要 yield
  3. render 可中断,commit 必须原子不可中断
  4. 优先级决定“先响应用户还是先完成大渲染”,并发本质是可打断/可重启的渲染

留言讨论

Discussion

欢迎交流与反馈