普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月28日首页

React Fiber 架构全方位梳理

作者 sophie旭
2026年1月28日 18:00

背景

作为 react 高频考点,React Fiber反复出现,为啥会成为高频考点,我觉得,很大程度上是因为 React Fiber架构真的解决了问题,并且巧妙的思想或许在未来可以给我们一些性能优化的启发,所以我们今天一起来走进 React Fiber,感受它的

一、先理解:为什么需要 Fiber 架构?

React 15 及之前使用的是 Stack Reconciler(栈协调器),它采用递归方式遍历虚拟 DOM 树,一旦开始渲染就无法中断。JS 线程是单线程的,如果渲染任务(比如复杂组件树的更新)耗时超过 16.6ms(浏览器 60fps 下每帧的时长),就会阻塞浏览器的渲染、布局、用户交互(如点击/输入),导致页面卡顿。

Fiber 架构的核心目标就是将同步的、不可中断的递归渲染,改成异步的、可中断的迭代渲染,彻底解决渲染阻塞问题。

一、先明确:Stack Reconciler 是什么?

Stack Reconciler(栈协调器)是 React 15 及之前版本的核心渲染引擎,负责两大核心工作:

  1. Reconciliation(协调):对比新旧虚拟 DOM 树(Virtual DOM),找出需要更新的节点(即「差异(diff)」);
  2. 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 单线程 + 调用栈的同步特性

  1. JS 单线程模型:浏览器中 JS 线程和渲染线程(布局、绘制)是互斥的 —— 只要 JS 线程在执行代码,渲染线程就会被阻塞;
  2. 调用栈不可中断JS 引擎的调用栈是「同步执行」的,一旦函数开始执行,必须等到函数体完全执行完毕、调用栈清空,才会释放主线程。

举个实际场景:如果你的组件树有 1000 个节点,reconcile 递归遍历需要 50ms 才能完成。这 50ms 内:

  • JS 线程被完全占用,浏览器无法处理任何用户交互(点击、输入、滚动);
  • 渲染线程被阻塞,页面无法更新,用户会明显感觉到「卡顿」(60fps 下每帧只有 16.6ms,50ms 会丢失 3 帧);
  • 即使中途用户触发了更紧急的操作(比如点击按钮),也必须等这 50ms 的递归完成后才能响应 —— 因为调用栈没有「暂停」「插队」的机制。

四、Stack Reconciler 的核心问题

除了「无法中断」,递归遍历还带来两个关键问题:

  1. 无优先级调度:所有更新任务(比如用户输入的高优先级更新、数据请求的低优先级更新)都被同等对待,同步排队执行 —— 比如用户输入框打字(需要即时响应),却要等一个耗时的列表渲染完成,体验极差;
  2. 调用栈溢出风险:如果组件树嵌套过深(比如 1000 层),递归遍历会导致调用栈层数过多,直接触发 Maximum call stack size exceeded 错误(栈溢出)。

总结(核心关键点)

  1. Stack Reconciler 依赖 JS 调用栈的同步递归 遍历虚拟 DOM 树,递归的特性决定了渲染过程「一旦开始就必须执行到底」;
  2. 无法中断的根源是 JS 单线程 + 调用栈不可中断 —— 递归过程中主线程被完全占用,阻塞渲染和用户交互;
  3. 直接后果是 长任务导致页面卡顿,且无法区分任务优先级,无法优先响应用户交互等关键操作(这也是 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. 先处理当前节点(小任务 1);
  2. 如果有子节点(child),下一个处理子节点(小任务 2);
  3. 如果没有子节点,但有兄弟节点(sibling),下一个处理兄弟节点(小任务 3);
  4. 如果既没有子节点也没有兄弟节点,回到父节点(return),再找父节点的兄弟节点(小任务 4);
  5. 直到回到根节点,所有小任务执行完毕。

这个过程就像「走迷宫」:先往深走(子节点),走到底就往旁边走(兄弟节点),旁边也走完就退回去(父节点),再往旁边走——每走一步,就是执行一个小任务。

三、代码模拟:直观看到任务是怎么拆分的

下面用简化代码模拟 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;

总结(核心关键点)

  1. 拆分基础:Fiber 节点是「可独立处理的最小单元」,链表指针(child/sibling/return)让节点能被逐个遍历,无需依赖递归栈;
  2. 拆分方式:把「整棵组件树的渲染更新」拆成「处理单个 Fiber 节点」的原子小任务,按「深度优先 + 链表遍历」的顺序逐个执行;
  3. 可中断核心:工作循环每次只执行一个小任务,执行前检查时间,不足则中断并记录断点,恢复时从断点继续——这也是拆分的最终目的:让长任务变可中断。

简单来说,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,递归处理时调用栈的变化是:

  1. 调用 reconcile(App) → 栈:[reconcile(App)]
  2. 遍历 App 的子节点,调用 reconcile(Header) → 栈:[reconcile(App), reconcile(Header)]
  3. 遍历 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 的「最小处理单元」是「单个节点的处理」—— 这是能否拆分的根本:

  1. Stack Reconciler

    • 开发者调用 setState 后,React 启动的是「整棵树的 reconcile 任务」,这个任务是「不可分割的整体」;
    • 即使你只想更新一个 Logo 节点,也必须从 App 根节点开始,递归遍历到 Logo,再逐层返回 —— 整个过程是一个「大任务」,没有更小的可执行单元。
  2. Fiber 架构

    • 启动的是「遍历链表的工作循环」,这个循环的「最小执行单元」是「处理单个 Fiber 节点」;
    • 想更新 Logo 节点?工作循环可以只执行「处理 Logo 节点」这个小任务,时间不够就暂停,完全不影响其他节点的处理状态。

总结(核心关键点)

  1. 独立性差异:Stack 递归中,子节点处理是父节点逻辑的嵌套部分,依赖父节点的调用上下文,无法拆成独立任务;Fiber 节点包含全部处理信息,单个节点处理无需依赖其他节点;
  2. 执行载体差异:Stack 依赖 JS 调用栈,栈的同步特性决定了递归必须执行到底,无法中断/保存状态;Fiber 依赖变量记录当前节点,迭代执行,可随时中断并保存执行位置;
  3. 最小单元差异: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/...
核心记录方式 隐式(栈层级 + 函数上下文) 显式(独立变量 + 节点引用)
能否恢复 ❌ 不能,只能重跑 ✅ 能,精准继续

总结(核心关键点)

  1. 中断点的核心是记录「下一个待处理节点」递归栈靠「调用栈层级 + 函数上下文」隐式记录,中断后上下文销毁,无法还原;Fiber 靠 workInProgress 变量显式记录节点引用,中断后变量不丢失。
  2. 链表指针是恢复的基础child/sibling/return 让每个节点都能找到下一个节点,无需依赖调用栈,从任意中断点都能继续遍历。
  3. 递归栈的本质是「依赖栈状态」,链表的本质是「依赖节点引用」:栈状态不可保存,节点引用可保存 —— 这是「能否中断恢复」的根本区别。

简单来说:递归栈是「靠记忆记位置」,中断后忘了就只能重来了;Fiber 链表是「靠笔记记位置」,中断后看笔记就能精准继续。这就是链表能记录中断点、递归栈做不到的核心原因。

2. Fiber 分片/中断的执行机制 -- Scheduler

Fiber 的执行分为两个核心阶段,只有第一个阶段可中断:

阶段 名称 核心操作 是否可中断
阶段1 Reconciliation(协调/渲染) 对比新旧 Fiber 树、标记 DOM 变更(副作用) ✅ 可中断、可暂停、可重启
阶段2 Commit(提交) 将标记的副作用应用到真实 DOM ❌ 不可中断(DOM 操作必须一次性完成,避免页面不完整)

分片/中断的具体执行流程

  1. 任务调度:当组件触发更新(如 setState),Scheduler 会根据任务优先级(比如用户输入 > 数据请求)将任务加入调度队列;
  2. 启动迭代遍历:不再递归遍历组件树,而是基于 Fiber 链表做迭代式深度优先遍历:
    • 先处理当前 Fiber 节点(比如计算 props、对比子节点);
    • 处理完后,检查“剩余空闲时间”:如果还有时间,就取下一个 Fiber 节点(child → sibling → return)继续处理;如果没有时间,就暂停遍历;
  3. 中断逻辑:暂停时,React 会记录当前“工作中的 Fiber 节点”(workInProgress),然后调用 requestIdleCallback(或 React 模拟的空闲回调),把控制权还给浏览器,让浏览器执行布局、绘制等操作;
  4. 恢复逻辑:当浏览器进入下一次空闲期,Scheduler 会唤醒暂停的任务,从之前记录的 workInProgress 节点继续遍历,直到所有 Fiber 节点处理完毕;
  5. 提交阶段:协调阶段完成后,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 的「工作循环」提供“时间切片”能力:

  1. Fiber 把「处理单个节点」作为小任务,传给 Scheduler;
  2. Scheduler 按优先级调度这个小任务,执行前检查空闲时间;
  3. 时间不足时,Scheduler 暂停执行,通知 Fiber 记录当前 workInProgress 节点;
  4. 下一次空闲时,Scheduler 恢复执行,Fiber 从 workInProgress 继续处理节点。

总结(核心关键点)

  1. 优先级实现:用「过期时间」量化优先级,结合「最小堆」保证高优先级任务先执行;
  2. 空闲时间计算:基于 rAF + postMessage 模拟高精度空闲回调,替代原生 requestIdleCallback,并预留 5ms 安全阈值;
  3. 执行/暂停控制:工作循环逐次执行任务的“小单元”,执行中持续检查剩余时间,不足则中断,下次空闲时从断点恢复。

简单来说,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;

计算过程:

  1. 已消耗时间 = 1010.0 - 1000.0 = 10ms;
  2. 原始剩余时间 = 16.6 - 10 = 6.6ms;
  3. 最终剩余时间 = 6.6 - 5 = 1.6ms(取正值);

结论:Scheduler 只会给 React 任务分配 1.6ms 的执行时间,超时就中断。

再举一个超时场景:

  • 已消耗时间 = 15ms;
  • 原始剩余时间 = 16.6 - 15 = 1.6ms;
  • 最终剩余时间 = 1.6 - 5 = -3.4ms → 取 0;

结论:无空闲时间,暂停任务。

总结(核心关键点)

  1. 计算基准:以 60fps 下每帧 16.6ms 为总时长,剩余时间 = 总时长 - 已消耗时间 - 5ms 安全阈值;
  2. 时间精度:依赖 rAF 的高精度时间戳和 performance.now(),避免系统时间偏差;
  3. 边界处理:剩余时间最小为 0,保证不会出现负数(超时则暂停任务);
  4. 核心目的:预留足够时间给浏览器完成渲染,避免 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)刷题。

总结(核心关键点)

  1. rAF 只记时间,不执行任务它的作用是给剩余时间计算提供基准,不是任务执行入口;
  2. postMessage 才是任务执行的核心时机只有在绘制完成后的空闲时间(postMessage 回调),才会处理 Fiber 任务;
  3. 暂停后不是立即触发 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 给浏览器渲染

总结(核心关键点)

  1. requestIdleCallback 是浏览器原生的「空闲时间调度 API」,作用是在帧的空闲期执行低优先级任务;
  2. React 不用它的核心原因:兼容性差(覆盖不全)、触发不稳定(帧率低时不执行)、无优先级调度,无法满足 Fiber 架构“可中断、高优先级优先”的核心需求;
  3. React 转而用 rAF + postMessage 自研 Scheduler,本质是「重新实现了一个更稳定、更可控、带优先级的 requestIdleCallback」。

简单来说:requestIdleCallback 是个“想法很好但落地拉胯”的原生 API,React 嫌它不好用,就自己造了个更强大的版本——这就是 Scheduler 的本质。

昨天以前首页
❌
❌