背景
作为 react 高频考点,React Fiber反复出现,为啥会成为高频考点,我觉得,很大程度上是因为 React Fiber架构真的解决了问题,并且巧妙的思想或许在未来可以给我们一些性能优化的启发,所以我们今天一起来走进 React Fiber,感受它的
一、先理解:为什么需要 Fiber 架构?
React 15 及之前使用的是 Stack Reconciler(栈协调器),它采用递归方式遍历虚拟 DOM 树,一旦开始渲染就无法中断。JS 线程是单线程的,如果渲染任务(比如复杂组件树的更新)耗时超过 16.6ms(浏览器 60fps 下每帧的时长),就会阻塞浏览器的渲染、布局、用户交互(如点击/输入),导致页面卡顿。
Fiber 架构的核心目标就是将同步的、不可中断的递归渲染,改成异步的、可中断的迭代渲染,彻底解决渲染阻塞问题。
一、先明确:Stack Reconciler 是什么?
Stack Reconciler(栈协调器)是 React 15 及之前版本的核心渲染引擎,负责两大核心工作:
-
Reconciliation(协调):对比新旧虚拟 DOM 树(Virtual DOM),找出需要更新的节点(即「差异(diff)」);
-
Commit(提交):将找到的差异同步应用到真实 DOM 上。
它的核心特征是:基于 JavaScript 调用栈的同步递归遍历 —— 这也是「Stack(栈)」这个名字的由来(完全依赖 JS 函数调用栈完成遍历)。
二、Stack Reconciler 的递归执行流程
虚拟 DOM 是树形结构,递归是最直观的遍历方式,但也埋下了「无法中断」的隐患。我们先通过一个简化的示例,还原它的执行逻辑:
1. 模拟 Stack Reconciler 的递归遍历代码
// 模拟 React 15 的虚拟 DOM 节点
class VNode {
constructor(tag, children = []) {
this.tag = tag; // 组件/标签名
this.children = children; // 子节点
}
}
// 模拟 Stack Reconciler 的核心递归遍历方法
function reconcile(oldVNode, newVNode) {
// 1. 对比当前节点:如果标签不同,直接标记为替换
if (oldVNode.tag !== newVNode.tag) {
console.log(`标记替换节点: ${oldVNode.tag} → ${newVNode.tag}`);
return;
}
// 2. 递归遍历子节点(核心:深度优先递归)
const oldChildren = oldVNode.children;
const newChildren = newVNode.children;
const maxLen = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLen; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
// 递归处理每个子节点 —— 每递归一次,就向 JS 调用栈压入一层
if (oldChild && newChild) {
reconcile(oldChild, newChild); // 子节点存在,继续递归
} else if (newChild) {
console.log(`标记新增节点: ${newChild.tag}`); // 新节点,标记新增
} else if (oldChild) {
console.log(`标记删除节点: ${oldChild.tag}`); // 旧节点,标记删除
}
}
}
// 模拟一个复杂的组件树(多层嵌套)
const oldTree = new VNode('App', [
new VNode('Header', [new VNode('Logo'), new VNode('Nav')]),
new VNode('Content', [
new VNode('Article', [new VNode('Title'), new VNode('Text')]),
new VNode('Sidebar', [new VNode('Menu'), new VNode('Ad')])
]),
new VNode('Footer')
]);
const newTree = new VNode('App', [
new VNode('Header', [new VNode('Logo'), new VNode('Nav')]),
new VNode('Content', [
new VNode('Article', [new VNode('Title'), new VNode('Text', [new VNode('Span')])]),
new VNode('Sidebar', [new VNode('Menu')])
]),
new VNode('Footer')
]);
// 触发渲染:一旦调用,必须执行完整个递归流程
reconcile(oldTree, newTree);
2. 递归执行的关键特点
从代码中能看到,reconcile 函数的执行完全依赖 JS 调用栈:
- 每递归处理一个子节点,就会向调用栈压入一层
reconcile 函数;
- 只有当某个节点的所有子节点都处理完毕(递归返回),该层函数才会从调用栈弹出;
整个遍历过程必须「一气呵成」—— 从根节点到最深层节点,再逐层返回,中间没有任何暂停的可能。
三、为什么递归遍历「无法中断」?
核心原因是 JavaScript 单线程 + 调用栈的同步特性:
-
JS 单线程模型:浏览器中 JS 线程和渲染线程(布局、绘制)是互斥的 —— 只要 JS 线程在执行代码,渲染线程就会被阻塞;
-
调用栈不可中断:
JS 引擎的调用栈是「同步执行」的,一旦函数开始执行,必须等到函数体完全执行完毕、调用栈清空,才会释放主线程。
举个实际场景:如果你的组件树有 1000 个节点,reconcile 递归遍历需要 50ms 才能完成。这 50ms 内:
- JS 线程被完全占用,浏览器无法处理任何用户交互(点击、输入、滚动);
- 渲染线程被阻塞,页面无法更新,用户会明显感觉到「卡顿」(60fps 下每帧只有 16.6ms,50ms 会丢失 3 帧);
- 即使中途用户触发了更紧急的操作(比如点击按钮),也必须等这 50ms 的递归完成后才能响应 —— 因为调用栈没有「暂停」「插队」的机制。
四、Stack Reconciler 的核心问题
除了「无法中断」,递归遍历还带来两个关键问题:
-
无优先级调度:所有更新任务(比如用户输入的高优先级更新、数据请求的低优先级更新)都被同等对待,同步排队执行 —— 比如用户输入框打字(需要即时响应),却要等一个耗时的列表渲染完成,体验极差;
-
调用栈溢出风险:如果组件树嵌套过深(比如 1000 层),递归遍历会导致调用栈层数过多,直接触发
Maximum call stack size exceeded 错误(栈溢出)。
总结(核心关键点)
- Stack Reconciler 依赖 JS 调用栈的同步递归 遍历虚拟 DOM 树,递归的特性决定了渲染过程「一旦开始就必须执行到底」;
- 无法中断的根源是 JS 单线程 + 调用栈不可中断 —— 递归过程中主线程被完全占用,阻塞渲染和用户交互;
- 直接后果是 长任务导致页面卡顿,且无法区分任务优先级,无法优先响应用户交互等关键操作(这也是 Fiber 架构要解决的核心痛点)。
简单来说,React 15 的 Stack Reconciler 就像「一口气跑完马拉松」,不管中途有没有突发情况,都不能停;而 Fiber 架构则是「分段跑马拉松」,跑一段歇一下,还能优先处理紧急的事(比如喝水、接电话),这也是两者最核心的区别。
二、核心原理拆解
1. Fiber 解决渲染阻塞的核心逻辑
Fiber 解决阻塞的核心是 “任务拆分 + 时间切片 + 优先级调度”,核心逻辑可总结为 3 点:
-
任务拆分:将原本一次性完成的“整棵组件树的渲染更新”拆成无数个小任务(每个小任务只处理一个 Fiber 节点);
-
时间切片(Time Slicing):每次只执行一个小任务,且执行时长不超过浏览器预留的空闲时间,执行完就把控制权还给浏览器;
-
中断与恢复:如果当前帧的空闲时间用完,就暂停渲染任务,记录当前执行到的 Fiber 节点,等浏览器下一次空闲时再从该节点继续执行。
支撑这个逻辑的两个关键基础:
-
Fiber 数据结构:每个 Fiber 节点对应一个组件,除了保存组件的 props/state 外,还增加了
return(父节点)、child(子节点)、sibling(兄弟节点)等指针,形成链表结构——这是“中断后能恢复”的关键(递归栈无法记录中断点,链表可以);
-
Scheduler 调度器:React 自研的调度模块,负责给任务分配优先级、计算空闲时间、控制任务的执行/暂停。
一、拆分的核心基础:Fiber 节点是「可独立处理的最小单元」
要拆分任务,首先得有「可拆分的最小单元」—— Fiber 节点就是这个单元。
React 把每一个组件(无论是类组件、函数组件还是原生标签)都对应成一个 Fiber 节点,并且给每个节点设计了链表指针(child/sibling/return),替代了原来的递归树结构。
每个 Fiber 节点都包含了「处理该节点所需的全部信息」,比如:
- 组件的
props/state;
- 对应的真实 DOM 节点;
- 要执行的操作(比如「更新」「新增」「删除」);
- 链表指针(找到下一个要处理的节点)。
这意味着:处理单个 Fiber 节点不需要依赖其他节点的执行结果,可以单独作为一个「小任务」来执行——这是任务能被拆分的核心前提。
二、任务拆分的核心逻辑:从「递归整树处理」→「迭代逐个节点处理」
Stack Reconciler 是「一次性递归遍历整棵树」,而 Fiber 架构则是把「整树处理」拆成「处理单个节点」的小任务,通过迭代 + 链表遍历的方式逐个执行,具体拆分逻辑如下:
1. 拆分原则
- 大任务:整棵组件树的渲染更新(Reconciliation 阶段);
- 小任务:处理一个 Fiber 节点(包括:对比新旧节点、标记副作用、确定下一个要处理的节点);
- 拆分方式:按「深度优先 + 链表遍历」的顺序,把整树的处理拆成一个个独立的「单个节点处理任务」。
2. 链表遍历的顺序(决定小任务的执行顺序)
Fiber 链表的遍历遵循「深度优先」,但通过指针实现迭代(而非递归),顺序是:
- 先处理当前节点(小任务 1);
- 如果有子节点(
child),下一个处理子节点(小任务 2);
- 如果没有子节点,但有兄弟节点(
sibling),下一个处理兄弟节点(小任务 3);
- 如果既没有子节点也没有兄弟节点,回到父节点(
return),再找父节点的兄弟节点(小任务 4);
- 直到回到根节点,所有小任务执行完毕。
这个过程就像「走迷宫」:先往深走(子节点),走到底就往旁边走(兄弟节点),旁边也走完就退回去(父节点),再往旁边走——每走一步,就是执行一个小任务。
三、代码模拟:直观看到任务是怎么拆分的
下面用简化代码模拟 Fiber 的任务拆分和执行过程,你能清晰看到「整树任务」是如何被拆成「单个节点任务」的:
// 1. 定义 Fiber 节点(最小任务单元)
class FiberNode {
constructor(tag, props) {
this.tag = tag; // 组件/标签名(如 App、Div)
this.props = props; // 组件 props
this.child = null; // 第一个子节点
this.sibling = null; // 下一个兄弟节点
this.return = null; // 父节点
this.effectTag = null; // 要执行的操作(新增/更新/删除)
}
}
// 2. 构建 Fiber 链表(模拟组件树)
// 结构:App → Header → Logo → Nav → Content → Article → Footer
const App = new FiberNode('App', {});
const Header = new FiberNode('Header', {});
const Logo = new FiberNode('Logo', {});
const Nav = new FiberNode('Nav', {});
const Content = new FiberNode('Content', {});
const Article = new FiberNode('Article', {});
const Footer = new FiberNode('Footer', {});
// 设置链表指针(构建依赖关系)
App.child = Header;
Header.return = App;
Header.child = Logo;
Logo.return = Header;
Logo.sibling = Nav;
Nav.return = Header;
Header.sibling = Content;
Content.return = App;
Content.child = Article;
Article.return = Content;
Content.sibling = Footer;
Footer.return = App;
总结(核心关键点)
-
拆分基础:Fiber 节点是「可独立处理的最小单元」,链表指针(
child/sibling/return)让节点能被逐个遍历,无需依赖递归栈;
-
拆分方式:把「整棵组件树的渲染更新」拆成「处理单个 Fiber 节点」的原子小任务,按「深度优先 + 链表遍历」的顺序逐个执行;
-
可中断核心:工作循环每次只执行一个小任务,执行前检查时间,不足则中断并记录断点,恢复时从断点继续——这也是拆分的最终目的:让长任务变可中断。
简单来说,Fiber 的任务拆分就像「把一本厚书拆成一页页来读」:原来的 Stack Reconciler 是「一口气读完整本书」,而 Fiber 是「读一页,看看有没有时间,有就继续读下一页,没有就合上书记下页码,下次从这页继续读」。
为什么 Stack Reconciler 的「一次性递归遍历整棵树」做不到像 Fiber 那样把节点处理拆成独立小任务
一、核心差异:递归遍历中「节点处理不具备独立性」
Fiber 能拆分任务的关键是「单个节点的处理不依赖其他节点的执行上下文」,但 Stack Reconciler 的递归遍历中,处理子节点是父节点处理逻辑的「嵌套部分」,完全依赖父节点的调用上下文,无法单独拆分。
1. 先看 Stack Reconciler 的递归代码(核心片段)
// Stack Reconciler 处理父节点的逻辑
function reconcile(oldVNode, newVNode) {
// 1. 处理当前父节点的逻辑
if (oldVNode.tag !== newVNode.tag) { /* 标记替换 */ }
// 2. 遍历子节点 —— 这是父节点处理逻辑的「一部分」
for (let i = 0; i < maxLen; i++) {
const oldChild = oldVNode.children[i];
const newChild = newVNode.children[i];
// 3. 递归处理子节点 —— 这个调用「嵌套在父节点的函数体中」
if (oldChild && newChild) {
reconcile(oldChild, newChild); // 子节点处理依赖父节点的循环上下文
}
}
}
2. 递归模式下的「依赖陷阱」
从代码能清晰看到:
- 「处理子节点」不是一个「独立任务」,而是父节点
reconcile 函数执行过程中的嵌套步骤;
- 要处理子节点,必须先进入父节点的
reconcile 函数,且父节点的 for 循环必须执行到对应子节点的索引;
子节点的递归调用「依附于父节点的调用栈」—— 父节点的函数没执行完,子节点的调用就无法独立存在;反过来,子节点的递归没返回,父节点的函数也无法结束。
举个通俗例子:
- Fiber 模式:每个节点是「独立的快递包裹」,处理一个包裹只需要包裹本身的信息,不用管其他包裹,能拆成一个个独立任务;
- Stack 递归模式:所有节点是「一串连在一起的鞭炮」,要炸到第 5 个鞭炮,必须先点第 1 个,且中间不能停 —— 第 5 个的爆炸依赖前 4 个的传导,无法单独拆出第 5 个来炸。
二、底层限制:JS 调用栈的「同步不可中断性」
Stack Reconciler 依赖 JS 函数调用栈实现递归,而调用栈的特性决定了「一旦开始递归,必须执行到底」:
| Fiber 架构(迭代+链表) |
Stack Reconciler(递归+调用栈) |
用「变量记录当前节点」(workInProgress),中断时只需保存这个变量 |
用「调用栈记录执行位置」,栈的层级和节点嵌套深度绑定 |
| 中断时:保存当前节点变量,直接退出循环,调用栈清空 |
中断时:无法退出——调用栈必须等嵌套的递归函数全部返回才能清空 |
| 恢复时:从保存的节点变量继续迭代,无需恢复调用栈 |
恢复时:无法实现——调用栈一旦弹出,无法还原之前的嵌套状态 |
关键举例:递归调用栈的「不可回退性」
假设组件树是 App → Header → Logo,递归处理时调用栈的变化是:
- 调用
reconcile(App) → 栈:[reconcile(App)]
- 遍历 App 的子节点,调用
reconcile(Header) → 栈:[reconcile(App), reconcile(Header)]
- 遍历 Header 的子节点,调用
reconcile(Logo) → 栈:[reconcile(App), reconcile(Header), reconcile(Logo)]
此时如果想「中断处理 Logo,先处理其他任务」,不可能:
- 要中断,必须让
reconcile(Logo) 执行完并返回 → 栈变成 [reconcile(App), reconcile(Header)];
- 再让
reconcile(Header) 执行完并返回 → 栈变成 [reconcile(App)];
- 最后让
reconcile(App) 执行完 → 栈清空。
整个过程中,你无法「暂停在 Logo 节点」,因为调用栈的层级和节点处理深度是强绑定的 —— 栈的状态就是执行位置,而栈无法被「冻结」或「保存」,只能按顺序弹出。
而 Fiber 架构中,执行位置是靠「变量(workInProgress)」记录的,不是调用栈:
- 处理到 Logo 节点时,
workInProgress = Logo;
- 想中断?直接退出循环,保存
workInProgress = Logo 即可,调用栈完全清空;
- 恢复时,把
workInProgress 设为 Logo,重新启动循环就能继续处理,无需依赖任何栈状态。
三、处理单元的本质:「整树遍历」vs「单个节点处理」
Stack Reconciler 的「最小处理单元」是「整棵树的遍历」,而 Fiber 的「最小处理单元」是「单个节点的处理」—— 这是能否拆分的根本:
-
Stack Reconciler:
- 开发者调用
setState 后,React 启动的是「整棵树的 reconcile 任务」,这个任务是「不可分割的整体」;
- 即使你只想更新一个 Logo 节点,也必须从 App 根节点开始,递归遍历到 Logo,再逐层返回 —— 整个过程是一个「大任务」,没有更小的可执行单元。
-
Fiber 架构:
- 启动的是「遍历链表的工作循环」,这个循环的「最小执行单元」是「处理单个 Fiber 节点」;
- 想更新 Logo 节点?工作循环可以只执行「处理 Logo 节点」这个小任务,时间不够就暂停,完全不影响其他节点的处理状态。
总结(核心关键点)
-
独立性差异:Stack 递归中,子节点处理是父节点逻辑的嵌套部分,依赖父节点的调用上下文,无法拆成独立任务;Fiber 节点包含全部处理信息,单个节点处理无需依赖其他节点;
-
执行载体差异:Stack 依赖 JS 调用栈,栈的同步特性决定了递归必须执行到底,无法中断/保存状态;Fiber 依赖变量记录当前节点,迭代执行,可随时中断并保存执行位置;
-
最小单元差异:Stack 的最小处理单元是「整树遍历」,Fiber 的最小处理单元是「单个节点处理」—— 这是能否拆分的核心。
简单来说:Stack Reconciler 就像「用一根绳子串起所有珠子,要拿最后一颗必须把整串拉到底」;而 Fiber 是「每个珠子都有独立的标签和位置信息,想拿哪颗就拿哪颗,拿一半放下也能记住位置」。这就是递归遍历无法拆分,而 Fiber 能拆分的根本原因。
「中断后能恢复」的关键是能否精准记住「下一个该处理的节点」
本质上,「中断后能恢复」的关键是 能否精准记住「下一个该处理的节点」 —— 递归栈的执行逻辑天然做不到这一点,而 Fiber 链表通过「显式的节点引用 + 独立变量记录」完美解决了这个问题。下面我用「通俗例子 + 逻辑拆解 + 对比」的方式讲透。
一、先明确:中断点需要记录什么?
不管是递归还是 Fiber,想中断后恢复,必须记住一个核心信息:
中断时,下一个该处理的节点是谁?
比如处理到 App → Header → Logo 后中断,需要记住「接下来该处理 Logo 的兄弟节点 Nav」,恢复时直接从 Nav 开始,而不是从头重新处理 App。
这是恢复的核心前提 —— 递归栈做不到记录这个信息,而链表可以。
二、递归栈为什么「无法记录中断点」?
递归栈的执行位置,是靠 **JS 调用栈的「层级」和「函数执行上下文」** 来隐式记录的,而非显式的节点引用 —— 这意味着一旦中断,这些上下文会全部丢失,无法还原。
1. 递归栈的「执行位置」绑定调用栈层级
举个具体例子:组件树是 App → Header → Logo(子)/ Nav(兄弟),递归处理时的调用栈变化:
// 步骤1:调用 reconcile(App) → 栈:[reconcile(App)]
// 步骤2:遍历 App 子节点,调用 reconcile(Header) → 栈:[reconcile(App), reconcile(Header)]
// 步骤3:遍历 Header 子节点,调用 reconcile(Logo) → 栈:[reconcile(App), reconcile(Header), reconcile(Logo)]
此时想中断(比如时间不足),要处理的下一个节点是 Nav(Logo 的兄弟),但递归栈的问题来了:
- 「下一个该处理 Nav」这个信息,只存在于
reconcile(Header) 函数的执行上下文中(比如循环变量 i=1,对应 Header 子节点的第二个元素);
- 调用栈的层级只记录「当前正在执行 reconcile(Logo)」,但无法直接记录「下一个节点是 Nav」;
- 如果强行中断(比如退出函数),
reconcile(Logo) 会返回,reconcile(Header) 的执行上下文(循环变量 i、Header 节点的引用)会被销毁,调用栈回到 [reconcile(App)];
- 等恢复时,你只知道「之前执行到过 Logo」,但完全不知道「下一个该处理 Nav」—— 只能从头重新调用
reconcile(App),相当于白执行了前面的步骤。
2. 递归栈的「不可恢复性」核心
| 递归栈的特性 |
导致的问题 |
| 执行位置靠「栈层级 + 函数上下文」隐式记录 |
中断时函数上下文销毁,无法记住「下一个节点」 |
| 调用栈只能「先进后出」,无法暂停/保存 |
要么执行完当前递归,要么全部退出,没有中间状态 |
| 没有独立变量记录「待处理节点」 |
恢复时只能从头开始,无法从中断点继续 |
通俗比喻:递归栈就像「在纸上写作业,没写完就被擦掉」—— 你只知道写到了第3题,但不知道第3题写完后该写第4题,只能重新从第1题开始写。
三、Fiber 链表为什么「能记录中断点」?
Fiber 架构放弃了递归栈,改用 「独立变量 + 链表指针」显式记录中断点 —— 中断点的核心信息(下一个待处理节点)被保存在一个独立变量(workInProgress)中,和调用栈无关,就算中断也不会丢失。
1. 链表的「执行位置」绑定「节点引用」
还是同一个例子:组件树 App → Header → Logo / Nav,Fiber 处理时的逻辑:
// 初始化:待处理节点 = App
let workInProgress = App;
// 步骤1:处理 App → 找到下一个节点(App.child = Header)→ workInProgress = Header
// 步骤2:处理 Header → 找到下一个节点(Header.child = Logo)→ workInProgress = Logo
// 步骤3:处理 Logo → 找到下一个节点(Logo.sibling = Nav)→ workInProgress = Nav
此时想中断:
- 只需要保存
workInProgress = Nav 这个变量(记录「下一个该处理 Nav」),然后直接退出循环即可;
- 调用栈会被清空(因为是迭代而非递归),但
workInProgress 变量不会消失;
- 恢复时,直接把
workInProgress 设为 Nav,重新启动循环:处理 Nav → 找到下一个节点(Nav.return = Header → Header.sibling = Content)→ workInProgress = Content,完美继续。
2. 链表指针让「中断点可追溯」
Fiber 节点的 child/sibling/return 指针,让每个节点都能「找到自己的下一个节点」,无需依赖调用栈:
- 就算中断时只记住了
Nav,通过 Nav.return 能找到 Header,通过 Header.sibling 能找到 Content,永远不会「迷路」;
- 而递归栈一旦丢失上下文,节点之间的关系就无从追溯。
3. 链表的「可恢复性」核心
| Fiber 链表的特性 |
解决的问题 |
| 执行位置靠「workInProgress 变量」显式记录 |
中断时只需保存这个变量,上下文不丢失 |
| 节点关系靠指针(child/sibling/return)显式关联 |
从任意节点都能找到下一个待处理节点 |
| 迭代执行,调用栈随时可清空 |
中断时不依赖栈层级,恢复时直接用变量继续 |
通俗比喻:Fiber 链表就像「在作业本上贴便利贴,写不完就贴在第4题上」—— 就算合上书,下次打开看到便利贴,直接从第4题开始写就行,不用重新写。
四、直观对比:递归栈 vs Fiber 链表(中断恢复)
| 场景 |
递归栈 |
Fiber 链表 |
| 处理到 Logo 后中断 |
调用栈层级丢失,循环变量销毁,不知道下一个是 Nav |
保存 workInProgress = Nav,变量不丢失 |
| 恢复执行 |
只能重新从 App 开始递归,重复处理 App/Header/Logo |
直接从 workInProgress = Nav 开始,处理 Nav/Content/... |
| 核心记录方式 |
隐式(栈层级 + 函数上下文) |
显式(独立变量 + 节点引用) |
| 能否恢复 |
❌ 不能,只能重跑 |
✅ 能,精准继续 |
总结(核心关键点)
-
中断点的核心是记录「下一个待处理节点」:
递归栈靠「调用栈层级 + 函数上下文」隐式记录,中断后上下文销毁,无法还原;Fiber 靠 workInProgress 变量显式记录节点引用,中断后变量不丢失。
-
链表指针是恢复的基础:
child/sibling/return 让每个节点都能找到下一个节点,无需依赖调用栈,从任意中断点都能继续遍历。
-
递归栈的本质是「依赖栈状态」,链表的本质是「依赖节点引用」:栈状态不可保存,节点引用可保存 —— 这是「能否中断恢复」的根本区别。
简单来说:递归栈是「靠记忆记位置」,中断后忘了就只能重来了;Fiber 链表是「靠笔记记位置」,中断后看笔记就能精准继续。这就是链表能记录中断点、递归栈做不到的核心原因。
2. Fiber 分片/中断的执行机制 -- Scheduler
Fiber 的执行分为两个核心阶段,只有第一个阶段可中断:
| 阶段 |
名称 |
核心操作 |
是否可中断 |
| 阶段1 |
Reconciliation(协调/渲染) |
对比新旧 Fiber 树、标记 DOM 变更(副作用) |
✅ 可中断、可暂停、可重启 |
| 阶段2 |
Commit(提交) |
将标记的副作用应用到真实 DOM |
❌ 不可中断(DOM 操作必须一次性完成,避免页面不完整) |
分片/中断的具体执行流程:
-
任务调度:当组件触发更新(如 setState),Scheduler 会根据任务优先级(比如用户输入 > 数据请求)将任务加入调度队列;
-
启动迭代遍历:不再递归遍历组件树,而是基于 Fiber 链表做迭代式深度优先遍历:
- 先处理当前 Fiber 节点(比如计算 props、对比子节点);
- 处理完后,检查“剩余空闲时间”:如果还有时间,就取下一个 Fiber 节点(child → sibling → return)继续处理;如果没有时间,就暂停遍历;
-
中断逻辑:暂停时,React 会记录当前“工作中的 Fiber 节点”(workInProgress),然后调用
requestIdleCallback(或 React 模拟的空闲回调),把控制权还给浏览器,让浏览器执行布局、绘制等操作;
-
恢复逻辑:当浏览器进入下一次空闲期,Scheduler 会唤醒暂停的任务,从之前记录的 workInProgress 节点继续遍历,直到所有 Fiber 节点处理完毕;
-
提交阶段:协调阶段完成后,React 会一次性执行所有标记的 DOM 变更,这个过程不可中断,保证 DOM 状态的一致性。
Scheduler 调度器的具体实现原理
Scheduler 本质是一个「智能任务调度中心」,核心目标是让高优先级任务优先执行,低优先级任务利用空闲时间执行,且所有任务都能被中断/恢复。下面我会从「优先级设计」「空闲时间计算」「任务执行/暂停控制」三个核心维度,拆解它的实现逻辑,还会附上简化代码模拟核心流程。
一、核心前置认知
Scheduler 是独立于 React 核心的模块(甚至可以单独使用),它的实现不依赖 Fiber 架构,但为 Fiber 提供了「可中断渲染」的基础能力。其核心设计思路是:
用「过期时间」量化任务优先级,而非单纯的“高/中/低”标签;
用「requestAnimationFrame + postMessage」模拟高精度空闲回调,替代原生 requestIdleCallback;
用「工作循环 + 时间检查」实现任务的执行、中断与恢复。
二、模块1:任务优先级的实现
Scheduler 不直接用“优先级等级”,而是用过期时间(expirationTime) 来定义优先级——优先级越高,任务的过期时间越短(越早需要执行)。
1. 优先级与过期时间的映射
React 定义了 5 类核心优先级(对应不同的过期时间阈值),本质是「任务必须在多久内执行,否则用户会感知到卡顿」:
| 优先级名称 |
过期时间(当前时间 + X) |
适用场景 |
核心特点 |
| ImmediatePriority |
0ms(立即过期) |
同步执行的紧急任务(如报错) |
阻塞渲染,必须立即执行 |
| UserBlockingPriority |
250ms |
用户交互(点击/输入/滚动) |
250ms内执行,否则感知卡顿 |
| NormalPriority |
5000ms |
普通任务(如网络请求回调) |
不紧急,可延迟 |
| LowPriority |
10000ms |
低优先级任务(如非关键数据加载) |
可大幅延迟 |
| IdlePriority |
Infinity(永不过期) |
空闲任务(如日志/统计) |
只有浏览器空闲时才执行 |
2. 优先级队列的实现:最小堆(小顶堆)
Scheduler 用最小堆管理所有待执行任务,堆的排序依据是「过期时间」——堆顶永远是「过期时间最早(优先级最高)」的任务,保证高优先级任务先执行。
- 为什么用最小堆?
堆的「取顶(O(1))」「插入(O(logn))」效率远高于普通数组排序,适合频繁新增/取出任务的场景;
- 核心操作:
- 新增任务:插入堆中,堆自动调整,保持堆顶是最高优先级任务;
- 执行任务:每次从堆顶取出任务执行,执行完后重新调整堆。
三、模块2:空闲时间计算的实现
原生 requestIdleCallback 有两个致命问题:① 兼容性差;② 触发时机不稳定(帧率低于 60fps 时可能不触发)。因此 Scheduler 用「requestAnimationFrame (rAF) + postMessage」模拟高精度的空闲时间计算。
1. 核心原理
浏览器每帧(16.6ms)的执行顺序是:
事件处理 → 宏任务 → 微任务 → rAF → 布局 → 绘制 → 空闲时间
Scheduler 利用 rAF 精准获取每帧的开始时间,再用 postMessage 触发宏任务,在宏任务中计算「当前帧剩余的空闲时间」。
2. 空闲时间计算的步骤(代码级逻辑)
// 步骤1:用 rAF 获取每帧的开始时间
let frameStartTime = 0;
requestAnimationFrame((timestamp) => {
frameStartTime = timestamp; // 记录当前帧的开始时间(ms)
});
// 步骤2:计算当前帧的剩余空闲时间
function calculateRemainingTime() {
// 16.6ms = 1000ms / 60fps(每帧总时长)
const frameDuration = 16.6;
const elapsedTime = Date.now() - frameStartTime; // 已用时间
const remainingTime = frameDuration - elapsedTime; // 剩余空闲时间
// 预留 5ms 安全阈值,避免占用渲染时间
return Math.max(0, remainingTime - 5);
}
// 步骤3:用 postMessage 触发空闲任务执行
// 原因:postMessage 的宏任务会在「绘制完成后、下一次 rAF 前」执行,正好对应空闲时间
const channel = new MessageChannel();
channel.port2.onmessage = () => {
const remainingTime = calculateRemainingTime();
if (remainingTime > 0) {
// 有空闲时间,执行调度任务
workLoop(remainingTime);
} else {
// 无空闲时间,等待下一次 rAF
requestAnimationFrame(channel.port1.postMessage.bind(channel.port1));
}
};
3. 5ms 阈值的作用
计算剩余时间时,会强制减去 5ms——这是为了预留足够时间给浏览器完成「布局/绘制」,避免任务执行超时导致丢帧。
四、模块3:任务执行/暂停的实现
Scheduler 的核心是「工作循环(workLoop)」,它会逐次取出高优先级任务,执行“一小段”后检查剩余时间,不足则暂停,空闲时恢复。
1. 核心流程(简化代码模拟)
// 模拟:最小堆任务队列(简化版,仅示意)
const taskQueue = [];
// 1. 新增任务(带优先级)
function scheduleTask(callback, priorityLevel) {
// 步骤1:计算过期时间(当前时间 + 优先级对应的阈值)
const expirationTime = Date.now() + getExpirationTimeByPriority(priorityLevel);
// 步骤2:插入最小堆队列
taskQueue.push({ callback, expirationTime, isPending: true });
taskQueue.sort((a, b) => a.expirationTime - b.expirationTime); // 简化堆排序
// 步骤3:触发调度(启动工作循环)
startWorkLoop();
}
// 2. 工作循环(核心:执行/中断/恢复)
function workLoop(remainingTime) {
// 取出堆顶任务(最高优先级)
let currentTask = taskQueue[0];
while (currentTask && remainingTime > 0) {
// 检查任务是否过期/取消
if (currentTask.expirationTime < Date.now() || !currentTask.isPending) {
taskQueue.shift(); // 移除过期/取消的任务
currentTask = taskQueue[0];
continue;
}
// 执行任务的「一小段」(而非一次性执行完)
// 关键点:callback 返回是否需要继续执行(true=未完成,false=完成)
const needContinue = currentTask.callback(remainingTime);
if (needContinue) {
// 任务未完成,计算剩余时间(模拟执行消耗)
remainingTime -= 1; // 假设执行一小段消耗 1ms
// 剩余时间不足 5ms,中断任务
if (remainingTime < 5) {
console.log('⚠️ 时间不足,暂停任务');
break; // 退出循环,任务留在队列中
}
} else {
// 任务完成,移除队列
taskQueue.shift();
currentTask = taskQueue[0];
}
}
// 任务未执行完,下次空闲时恢复
if (currentTask) {
// 重新触发 postMessage,等待下一次空闲
channel.port1.postMessage('continue');
}
}
// 3. 启动工作循环
function startWorkLoop() {
requestAnimationFrame((timestamp) => {
frameStartTime = timestamp;
channel.port1.postMessage('start'); // 触发 postMessage 回调
});
}
// 辅助函数:根据优先级获取过期时间
function getExpirationTimeByPriority(priority) {
const priorities = {
'immediate': 0,
'user-blocking': 250,
'normal': 5000,
'low': 10000,
'idle': Infinity
};
return priorities[priority] || 5000;
}
// 测试:新增一个用户交互任务(高优先级)
scheduleTask((remainingTime) => {
console.log(`执行任务,剩余时间:${remainingTime}ms`);
// 模拟任务需要多次执行(返回 true 表示未完成)
return remainingTime > 10; // 剩余时间>10ms 才继续,否则暂停
}, 'user-blocking');
2. 关键逻辑拆解
-
任务可中断:任务的回调函数不是一次性执行完,而是返回「是否需要继续执行」——如果剩余时间不足,工作循环会退出,任务留在队列中;
-
任务可恢复:中断后,Scheduler 会再次触发
postMessage,下一次空闲时重新启动工作循环,从队列中取出未完成的任务继续执行;
-
任务取消:每个任务都有
isPending 标记,外部可通过修改该标记取消任务,工作循环会跳过已取消的任务。
五、Scheduler 与 Fiber 的联动
Scheduler 并不直接操作 Fiber 节点,而是给 Fiber 的「工作循环」提供“时间切片”能力:
- Fiber 把「处理单个节点」作为小任务,传给 Scheduler;
- Scheduler 按优先级调度这个小任务,执行前检查空闲时间;
- 时间不足时,Scheduler 暂停执行,通知 Fiber 记录当前
workInProgress 节点;
- 下一次空闲时,Scheduler 恢复执行,Fiber 从
workInProgress 继续处理节点。
总结(核心关键点)
-
优先级实现:用「过期时间」量化优先级,结合「最小堆」保证高优先级任务先执行;
-
空闲时间计算:基于
rAF + postMessage 模拟高精度空闲回调,替代原生 requestIdleCallback,并预留 5ms 安全阈值;
-
执行/暂停控制:工作循环逐次执行任务的“小单元”,执行中持续检查剩余时间,不足则中断,下次空闲时从断点恢复。
简单来说,Scheduler 就像「交通调度员」:给紧急车辆(高优先级任务)开绿灯,普通车辆(低优先级任务)错峰通行,且所有车辆都能临时靠边(中断),等道路空闲后再继续行驶(恢复)。
调度逻辑的核心底气:单个 Fiber 执行足够短
React 这套调度逻辑的核心底气:从设计上把「单个 Fiber 执行足够短」做成「必然结果」,而非「偶然运气」,因此哪怕多执行一个,带来的耗时增量也只是微秒级,完全在浏览器的帧时间安全范围内,不会有任何“大碍”。
更准确地说,这个「坚信」不是凭空的设计自信,而是三层硬约束下的必然结论,让「单个 Fiber 执行短」成为不可打破的规则,这也是整个 Scheduler 敢放弃“精准预估”、采用“实时校验+容忍多执行一个”的底层前提:
1. 处理逻辑的硬约束:只做「纯内存原子操作」
单个 Fiber 节点的处理被严格限定在无阻塞、无复杂计算、无IO的内存操作内:
props浅对比、副作用标记、链表指针读取,这些操作的耗时由 JS 引擎的内存访问速度决定,天然稳定在 0.1ms 以内,不会出现任何耗时突变。
不存在“一个 Fiber 处理突然花了 1ms”的可能,因为这类操作从根源上被排除在单个 Fiber 的处理逻辑之外。
2. 组件拆分的工程要求:复杂逻辑必须拆分为多个 Fiber - 对开发者,非强制
React 的 Fiber 树构建规则强制要求:任何包含复杂计算/逻辑的组件,必须拆分为子组件,进而对应多个 Fiber 节点。
比如一个渲染时要做大量数据计算的组件,React 会要求开发者拆成「计算子组件+展示子组件」,让每个子组件对应一个 Fiber,单个 Fiber 只承担一部分简单逻辑,从拆分层面保证单个处理的轻量化。
3. 阶段隔离的硬约束:耗时操作被彻底排除在 Reconciliation 阶段
React 把所有耗时操作(DOM 操作、样式计算、动画执行)都隔离到「Commit 阶段」,而 Reconciliation 阶段(Fiber 节点处理的阶段)只做纯内存的对比和标记。
Commit 阶段本身是不可中断的,但它的耗时操作是批量一次性执行的,且基于 Fiber 阶段的标记精准执行,不会有冗余操作,而 Fiber 阶段的轻量化则完全不受这些耗时操作的影响。
再补一层:“多执行一个也无碍”的实际体感
假设剩余时间是 1.6ms,单个 Fiber 执行耗时 0.1ms,就算因为实时校验的时机问题,多执行了3个,总耗时也只是增加 0.3ms,达到 1.9ms,远低于浏览器 16.6ms 的帧时长,更低于 5ms 的安全阈值。
这个增量在浏览器层面属于「微秒级误差」,不会导致帧丢失,用户完全感知不到任何卡顿——这就是 React 敢“容忍多执行一个”的核心原因:增量耗时在人类感知和浏览器渲染的安全范围内。
最终结论
你的判断切中了核心:React 所有的调度策略(执行前预估、执行后实时校验、可中断恢复),都是建立在「单个 Fiber 执行足够短」这个不可动摇的基础上的。
这个基础不是“坚信”出来的,而是通过「逻辑限定、组件拆分、阶段隔离」三层硬约束做出来的,正是因为这个基础的存在,Scheduler 才不用纠结于“精准预估耗时”,不用害怕“多执行一个”,只用做简单的实时时间校验,就能实现高效、不阻塞的调度。
简单说:先把单个任务的耗时压到极致,再谈调度的容错性——这是 React Fiber 架构最底层的设计哲学。
剩余空闲时间 的具体计算方法
简单来说,剩余时间的计算核心是:以浏览器每帧的总时长(≈16.6ms)为基准,减去当前帧已消耗的时间,再预留 5ms 安全阈值,最终得到可用于执行 React 任务的空闲时间。下面我用「原理 + 代码 + 例子」的方式把计算逻辑拆透。
一、计算的核心前提
浏览器要保证页面流畅(60fps),每帧的总时长必须控制在 1000ms ÷ 60 ≈ 16.666ms(以下简称 16.6ms)。每帧内浏览器需要依次完成:
事件处理 → 宏任务 → 微任务 → requestAnimationFrame(rAF) → 布局(Layout) → 绘制(Paint) → 空闲时间
Scheduler 计算的「剩余时间」,就是当前帧总时长 - 已消耗时长,代表浏览器完成核心工作(布局、绘制)后,还剩多少时间可以执行 React 的低优先级任务。
二、剩余时间的完整计算步骤(带代码模拟)
Scheduler 对剩余时间的计算分为 4 步,核心依赖 requestAnimationFrame (rAF) 的高精度时间戳(而非 Date.now()),保证计算准确性:
步骤1:获取当前帧的「开始时间」(核心基准)
rAF 的回调函数会接收一个高精度时间戳(timestamp),这个时间戳是浏览器给出的「当前帧开始执行的时间」,是计算的唯一基准(比 Date.now() 更准,避免系统时间偏差)。
// 存储当前帧的开始时间(全局变量)
let frameStartTime = 0;
// 注册 rAF,在每帧开始时记录帧起始时间
function registerFrameStartTime() {
requestAnimationFrame((timestamp) => {
// timestamp:浏览器提供的高精度时间戳(单位:ms),代表当前帧的开始时间
frameStartTime = timestamp;
// 每帧都要重新注册,保证帧开始时间实时更新
registerFrameStartTime();
});
}
// 启动帧时间监听
registerFrameStartTime();
步骤2:计算「当前帧已消耗的时间」
从帧开始到「当前时刻」,已经用掉的时间,公式为:
已消耗时间 = 当前高精度时间 - 帧开始时间
代码实现:
// 获取当前高精度时间(兼容浏览器)
function getCurrentTime() {
// performance.now() 是高精度时间(微秒级),比 Date.now() 更适合计算短时间间隔
return performance.now();
}
// 计算已消耗时间
function getElapsedTime() {
return getCurrentTime() - frameStartTime;
}
步骤3:计算「原始剩余时间」
原始剩余时间 = 每帧总时长(16.6ms) - 已消耗时间,代表当前帧理论上还剩的全部时间:
// 每帧总时长(60fps 下的标准值)
const FRAME_DURATION = 1000 / 60; // ≈16.666ms
// 计算原始剩余时间
function getRawRemainingTime() {
const elapsedTime = getElapsedTime();
return FRAME_DURATION - elapsedTime;
}
步骤4:应用「安全阈值」并处理边界情况
为了避免 React 任务占用过多时间导致浏览器来不及完成「布局/绘制」,Scheduler 会预留 5ms 安全阈值,并保证剩余时间不会为负数(负数代表当前帧已超时):
// 安全阈值:预留 5ms 给浏览器完成布局/绘制
const SAFE_THRESHOLD = 5;
// 最终可用的剩余时间(Scheduler 实际使用的)
function getRemainingTime() {
const rawRemaining = getRawRemainingTime();
// 1. 减去安全阈值;2. 保证结果 ≥ 0(避免负数)
const remaining = rawRemaining - SAFE_THRESHOLD;
return Math.max(0, remaining);
}
三、关键细节解释(为什么要这么算?)
1. 为什么用 performance.now() 而非 Date.now()?
-
Date.now() 是「系统时间」,精度低(毫秒级),且可能被用户手动修改、时区同步等影响;
-
performance.now() 是「相对时间」(从页面加载开始计时),精度高达微秒级(1ms = 1000μs),完全不受系统时间影响——适合计算「帧内短时间间隔」。
2. 为什么要减 5ms 安全阈值?
16.6ms 的帧时长中,浏览器需要至少 10ms 左右完成「布局 + 绘制」(这是实践验证的经验值)。如果 React 任务把剩余时间用完(比如剩 5ms 全占用),浏览器会来不及完成渲染,导致帧丢失、页面卡顿。
减 5ms 后,即使 React 用满剩余时间,浏览器仍有足够时间完成核心渲染工作。
3. 剩余时间为 0 意味着什么?
如果 getRemainingTime() 返回 0,说明:
- 当前帧已消耗的时间 ≥ 11.6ms(16.6 - 5);
- 没有空闲时间执行 React 任务,Scheduler 会立即暂停任务,等待下一次帧的空闲时间。
四、实际场景举例(直观理解)
假设当前帧的执行情况如下:
- 帧开始时间(frameStartTime):1000.0ms;
- 当前时间(performance.now()):1010.0ms;
计算过程:
- 已消耗时间 = 1010.0 - 1000.0 = 10ms;
- 原始剩余时间 = 16.6 - 10 = 6.6ms;
- 最终剩余时间 = 6.6 - 5 = 1.6ms(取正值);
结论:Scheduler 只会给 React 任务分配 1.6ms 的执行时间,超时就中断。
再举一个超时场景:
- 已消耗时间 = 15ms;
- 原始剩余时间 = 16.6 - 15 = 1.6ms;
- 最终剩余时间 = 1.6 - 5 = -3.4ms → 取 0;
结论:无空闲时间,暂停任务。
总结(核心关键点)
-
计算基准:以 60fps 下每帧 16.6ms 为总时长,剩余时间 = 总时长 - 已消耗时间 - 5ms 安全阈值;
-
时间精度:依赖
rAF 的高精度时间戳和 performance.now(),避免系统时间偏差;
-
边界处理:剩余时间最小为 0,保证不会出现负数(超时则暂停任务);
-
核心目的:预留足够时间给浏览器完成渲染,避免 React 任务阻塞页面流畅性。
简单来说,剩余时间的计算逻辑就是:先算当前帧还剩多少时间,再扣掉浏览器必须的渲染时间,剩下的才给 React 用——这是时间切片能「不阻塞渲染」的根本保障。
rAF 和 postMessage 的分工
Scheduler 并不是「在 requestAnimationFrame (rAF) 里开启任务」,而是用 rAF 获取「帧开始时间」(作为计算剩余时间的基准),真正执行任务的时机是在 postMessage 的宏任务回调中;时间不足时暂停任务后,也不是直接由 postMessage 触发处理剩余任务,而是下一次帧周期重新走「rAF 记时间 → postMessage 执行任务」的流程。
下面我用「精准流程拆解 + 通俗比喻」把这个逻辑讲透,你就能清楚 rAF 和 postMessage 的分工,以及任务暂停/恢复的完整链路:
一、先纠正核心偏差:rAF 和 postMessage 的核心分工
| 角色 |
requestAnimationFrame (rAF) |
postMessage 宏任务 |
| 执行时机 |
每帧「布局/绘制前」(帧内核心工作前) |
每帧「绘制完成后」(帧内空闲时间) |
| 核心作用 |
记录「帧开始时间」(计算剩余时间的基准) |
执行 React 任务(Fiber 处理)+ 检查剩余时间 |
| 是否直接执行任务 |
❌ 不执行,只记时间 |
✅ 是,真正的任务执行入口 |
二、Scheduler 完整执行流程(纠正后的准确版本)
用步骤拆解,你能清晰看到「记时间 → 算剩余 → 执行任务 → 暂停/恢复」的全链路:
步骤1:rAF 标记「帧开始时间」(准备工作)
浏览器每帧启动时,先触发 rAF 回调:
requestAnimationFrame((timestamp) => {
// 记录当前帧的开始时间(高精度时间戳),作为后续计算剩余时间的基准
frameStartTime = timestamp;
});
👉 这一步不执行任何 React 任务,只做「时间基准标记」,为后续计算“这一帧还剩多少空闲时间”铺路。
步骤2:postMessage 触发「任务执行」(核心)
rAF 执行后,浏览器会完成「布局/绘制」等核心工作,之后触发 postMessage 宏任务(这才是空闲时间):
const channel = new MessageChannel();
channel.port2.onmessage = () => {
// 1. 先计算当前帧的剩余空闲时间(基于 rAF 记录的 frameStartTime)
const remainingTime = getRemainingTime(); // 比如算出 1.6ms
if (remainingTime > 0) {
// 2. 有空闲时间 → 执行 React 任务(处理 Fiber 节点)
const hasMoreWork = workLoop(remainingTime); // 执行任务,返回是否有剩余任务
if (hasMoreWork) {
// 3. 任务没做完(时间不足暂停)→ 等待下一次帧周期
// 不是直接触发 postMessage,而是等下一次 rAF 后再走流程
requestAnimationFrame(() => channel.port1.postMessage(''));
}
} else {
// 4. 无空闲时间 → 直接等下一次帧周期
requestAnimationFrame(() => channel.port1.postMessage(''));
}
};
步骤3:workLoop 执行任务 + 时间不足则暂停
workLoop 里就是处理 Fiber 节点的核心逻辑:
- 每处理一个 Fiber 节点,就检查「已用时间是否 ≥ 剩余时间」;
- 时间不足时,
workLoop 返回 true(表示有剩余任务),Scheduler 就停止执行;
- 剩余任务不会立刻由 postMessage 触发,而是等下一个帧周期:先由 rAF 重新记录帧开始时间,再由 postMessage 回调执行剩余任务。
三、通俗比喻:把「帧周期」比作「一节课」
- rAF = 上课铃:打铃时记录“上课开始时间”(帧开始时间),只记时间,不讲课;
- 浏览器的布局/绘制 = 老师讲核心知识点:必须优先完成,不能打断;
- postMessage = 课间休息:核心知识点讲完后,才有休息时间,此时才可以做“刷题(处理 Fiber 任务)”;
- 剩余时间不足 = 课间休息快结束了:刷不完题就暂停,把剩下的题留到「下一节课的课间休息」(下一次帧的 postMessage)再做;
- 下一次 rAF = 下一节课的上课铃:先记新的上课时间,再等新的课间休息(postMessage)刷题。
总结(核心关键点)
-
rAF 只记时间,不执行任务:它的作用是给剩余时间计算提供基准,不是任务执行入口;
-
postMessage 才是任务执行的核心时机:只有在绘制完成后的空闲时间(postMessage 回调),才会处理 Fiber 任务;
-
暂停后不是立即触发 postMessage:剩余任务会等「下一次帧周期」,重新走「rAF 记时间 → postMessage 执行任务」的流程,保证始终在空闲时间执行任务,不阻塞渲染。
简单来说:Scheduler 是「先靠 rAF 定时间基准,再靠 postMessage 找空闲时间执行任务,时间不够就等下一轮空闲时间继续」—— 而非“rAF 开任务,postMessage 收尾”。
requestIdleCallback:想法很好,落地很拉垮
一、先明确:requestIdleCallback 到底是干啥的?
requestIdleCallback 是浏览器原生提供的「空闲时间调度 API」,核心设计目标和 React Scheduler 一致——让开发者在浏览器“没活儿干”的时候执行低优先级任务,避免占用核心渲染时间导致卡顿。
1. 核心工作逻辑
浏览器每帧(16.6ms)的执行顺序是:
事件处理 → 宏任务 → 微任务 → requestAnimationFrame(rAF) → 布局 → 绘制 → 【空闲时间】→ requestIdleCallback 回调
只有当「布局+绘制」完成后,当前帧还有剩余时间(比如只用了 10ms,剩 6.6ms),浏览器才会触发 requestIdleCallback 的回调函数。
2. 核心能力
回调函数会接收一个 IdleDeadline 对象,能拿到两个关键信息:
-
timeRemaining():返回当前帧还剩多少空闲时间(比如 6.6ms);
-
didTimeout:标记任务是否过期(若浏览器长期无空闲,任务会强制触发)。
3. 简单示例(原生用法)
// 注册一个空闲任务
requestIdleCallback((deadline) => {
// 只要还有空闲时间,就执行任务
while (deadline.timeRemaining() > 0) {
doLowPriorityWork(); // 比如日志收集、非紧急数据处理
}
});
简单说:requestIdleCallback 就是浏览器给开发者的「空闲时间接口」,让你把不紧急的任务塞到帧的空闲期执行。
二、为什么 React 不用它?(核心还是那两个致命问题,展开讲透)
虽然 requestIdleCallback 的设计初衷和 React Scheduler 一致,但它的两个硬伤让 React 完全无法用——咱们之前提过「兼容性差、触发时机不稳定」,这里结合实际场景展开:
| 问题 |
具体表现 |
对 React 的致命影响 |
| 兼容性极差 |
- IE 全版本不支持 - iOS Safari 仅 14.5+ 支持 - 安卓 WebView 大量不支持 |
React 要适配多端/多浏览器,依赖它会导致旧设备用户直接失去「可中断渲染」能力,退回卡顿状态 |
| 触发时机极不稳定 |
- 帧率 < 60fps 时(页面卡顿),浏览器判定「无空闲时间」,回调完全不触发 - 浏览器为了“保核心渲染”,会刻意压缩触发机会 |
React 的低优先级任务(比如列表渲染)会无限积压,甚至永远不执行;中断后的任务也无法恢复 |
| 无优先级调度能力 |
所有注册的任务都是“同等优先级”,无法让用户交互(高优先级)插队 |
用户点击按钮后,低优先级任务还在占用空闲时间,导致交互响应卡顿 |
三、关键补充:React 自研 Scheduler(rAF + postMessage)和 requestIdleCallback 的核心差异
React 不是“不用空闲时间调度”,而是「抛弃原生的 requestIdleCallback,自己实现了一个更靠谱的版本」:
| 特性 |
原生 requestIdleCallback |
React Scheduler(rAF + postMessage) |
| 兼容性 |
差(仅新版浏览器支持) |
好(rAF + postMessage 是基础 API,全浏览器兼容) |
| 触发稳定性 |
帧率低时完全不触发 |
不管帧率多少,每帧都会尝试执行(剩余时间为0就等下一帧,不会积压) |
| 优先级调度 |
无 |
支持5级优先级,高优先级任务(比如点击)可插队中断低优先级任务 |
| 时间控制精度 |
依赖浏览器内置计算,无法自定义 5ms 安全阈值 |
自研剩余时间计算,可精准预留 5ms 给浏览器渲染 |
总结(核心关键点)
-
requestIdleCallback 是浏览器原生的「空闲时间调度 API」,作用是在帧的空闲期执行低优先级任务;
- React 不用它的核心原因:兼容性差(覆盖不全)、触发不稳定(帧率低时不执行)、无优先级调度,无法满足 Fiber 架构“可中断、高优先级优先”的核心需求;
- React 转而用
rAF + postMessage 自研 Scheduler,本质是「重新实现了一个更稳定、更可控、带优先级的 requestIdleCallback」。
简单来说:requestIdleCallback 是个“想法很好但落地拉胯”的原生 API,React 嫌它不好用,就自己造了个更强大的版本——这就是 Scheduler 的本质。