时间分片(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(让出)
你可以把它想象成这样:
我每次只干一小段 干完问一下:还来得及赶上下一帧吗? 来不及:我先停,等会儿再干
这就是为什么 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 的四个核心点
- 目标是更可交互:避免掉帧与输入延迟
- 通过 Fiber 把 render 拆成 work unit,在段与段之间检查是否需要 yield
- render 可中断,commit 必须原子不可中断
- 优先级决定“先响应用户还是先完成大渲染”,并发本质是可打断/可重启的渲染
留言讨论
Discussion
欢迎交流与反馈