阅读视图

发现新文章,点击刷新页面。

React Fiber 架构全方位梳理

背景

作为 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 的本质。

Hooks 使用注意事项及来龙去脉

背景 前几篇文章已经聊了Hooks 的意义以及如何实现的,那么现在我们来看下 我们平时 使用 hook的一些注意事项,也就是我们说的 “心智负担”,但是当我们理解它背后的实现原理后,也许 这种负担会大

回归初心,React为啥当初要设计Hook?

背景

用了这么久 React,已经默认用函数写组件了,都快忘了怎么写类组件了,经历过写类组件的时代,现在用函数组件,一开始确实有上手时间成本,但是现在用过之后也是觉得真香,到底香在哪?到底React为啥推崇Hook呢?我们一起探究一下吧

对「类组件痛点」的直接回应

让我们回到 类组件开发时代,看看到底类组件的诟病是什么?

类组件痛点1: this 指向混乱

类组件this丢失问题

类组件的方法默认不会绑定 this,导致我们必须手动处理 this 指向,稍不注意就会出错。这是为什么

你想弄明白为什么类组件的方法默认不绑定 this,以及为什么这会导致指向混乱的问题,核心原因要从 JavaScript 中 this 的绑定规则React 类组件的设计方式 两个层面来解释,我会用通俗的语言+实例讲清楚。

一、先搞懂:JavaScript 中 this 指向的核心规则

this 是 JavaScript 中一个「动态绑定」的关键字——它的指向不是在定义函数时确定的,而是在调用函数时确定的。这是理解所有 this 问题的根本。

关键规则:不同调用方式,this 指向不同

我们用一个普通的 JavaScript 类来举例(React 类组件本质就是 JavaScript 类):

class Person {
  constructor(name) {
    this.name = name; // 这里的 this 指向 Person 的实例
  }

  sayHi() {
    console.log(`你好,我是${this.name}`); // this 指向谁?看调用方式
  }
}

const p = new Person('张三');

// 方式1:通过实例调用 → this 指向实例 p
p.sayHi(); // 输出:你好,我是张三

// 方式2:把方法单独抽出来调用 → this 丢失(非严格模式下指向 window,严格模式下是 undefined)
const sayHi = p.sayHi;
sayHi(); // 非严格模式:你好,我是undefined;严格模式:Uncaught TypeError: Cannot read property 'name' of undefined

核心结论:

  • 当你通过 实例.方法() 调用时,this 绑定到这个实例;
  • 当你把方法「单独提取」后调用(比如赋值给变量、作为回调函数),this 就会丢失原本的绑定,指向全局对象(浏览器中是 window)或 undefined(严格模式)。

二、React 类组件中 this 丢失的具体场景

React 类组件的方法丢失 this,本质就是上面的「方式2」——React 在处理事件回调时,会把你的方法「单独提取」后调用,导致 this 不再指向组件实例。

场景还原:React 类组件的点击事件
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  handleClick() {
    // 这里的 this 本该指向 Counter 实例,但实际是 undefined(React 默认开启严格模式)
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    // 问题出在这里:你把 this.handleClick 作为 onClick 的回调 → 相当于把方法抽离了
    return <button onClick={this.handleClick}>+1</button>;
  }
}

为什么会这样?

  1. render 函数执行时,你传给 onClick 的是 this.handleClick——这只是把方法的「引用」传了过去,并没有立即调用
  2. 当用户点击按钮时,React 内部会调用这个方法(类似 const callback = this.handleClick; callback());
  3. 此时调用的是「孤立的方法」,不是通过 实例.handleClick() 调用,所以 this 丢失,指向 undefined(React 严格模式下)。

三、为什么 React 不默认帮我们绑定 this

你可能会问:React 为什么不直接把类组件的方法都自动绑定到实例上?核心原因是:

  1. 遵循 JavaScript 原生规则:React 是基于 JavaScript 构建的库,不会刻意修改 JS 原生的 this 绑定逻辑,否则会增加学习成本和潜在的兼容性问题;
  2. 性能与灵活性:如果默认绑定所有方法,会为每个组件实例创建额外的函数引用,增加内存开销;而让开发者手动处理,能根据实际需求选择绑定方式(比如只绑定需要的方法)。

我用大白话来解释下,其实就是 :放手让开发者去设置this,我不掺合了

React 「不主动替你做绑定,只遵循 JS 原生规则,把控制权完全交给你」。

用更通俗的话讲:

✅ React 的态度:「我不掺合 this 的绑定逻辑,你按 JavaScript 原生的规矩来就行——想让 this 指向实例,你就自己绑;想让 this 指向别的(比如子组件、全局),你也可以自己改。我只负责把你写的方法『原样调用』,不替你做任何额外的绑定操作。」

对比一下如果 React 主动掺合的情况:

❌ 要是 React 自动把所有方法的 this 绑到组件实例,相当于「我替你做了决定」——你想改 this 指向都改不了,还得额外学 React 这套「特殊规则」,反而更麻烦。

最终核心总结
  1. React 对类组件 this 的态度:不干预、不修改、只遵循原生 JS 规则
  2. 把「this 该指向哪」的决定权,完全交给开发者;
  3. 这么做既避免了学习成本翻倍,也兼顾了性能(按需绑定)和灵活性(可自定义 this 指向)。

你这个「放手让开发者去设置,我不掺合」的总结,精准抓住了 React 设计的核心——尊重原生 JS,把控制权还给开发者

四、类组件中解决 this 丢失的 3 种常用方式

知道了原因,再看解决方案就很清晰了,本质都是「强制把 this 绑定到组件实例」:

方式 1:在构造函数中手动 bind(官方早期推荐)
constructor(props) {
  super(props);
  this.state = { count: 0 };
  // 核心:把 handleClick 的 this 强制绑定到当前实例
  this.handleClick = this.handleClick.bind(this);
}
方式 2:使用箭头函数定义方法(最简洁)
// 箭头函数没有自己的 this,会继承外层作用域的 this(即组件实例)
handleClick = () => {
  this.setState({ count: this.state.count + 1 });
};
方式 3:在调用时用箭头函数包裹(不推荐,每次渲染创建新函数)
// 每次 render 都会创建一个新的箭头函数,可能导致子组件不必要的重渲染
<button onClick={() => this.handleClick()}>+1</button>;

总结

  1. 根本原因:JavaScript 中 this 是「调用时绑定」的,React 类组件把方法作为事件回调时,方法会被孤立调用,导致 this 丢失(不再指向组件实例);
  2. 核心矛盾:React 遵循 JS 原生规则,没有默认绑定 this,而开发者容易忽略「调用方式」对 this 的影响;
  3. 解决方案:通过 bind、箭头函数等方式,强制把方法的 this 绑定到组件实例上。

函数组件: 彻底抛弃this,就不会有this指向问题了

核心原因

  1. 核心差异:类组件的状态/方法是「实例的属性」,必须通过 this 这个「中介」访问,中介指错了就出错;函数组件的状态/方法是「函数内的局部变量/函数」,直接访问即可,无需中介;
  2. 为什么无混乱:函数组件的局部变量/函数受「作用域」保护,只属于当前函数执行,不管怎么传递方法(比如作为事件回调),内部访问的变量都是确定的,不会像 this 那样动态变化;
  3. 最终结果:函数组件从根源上抛弃了 this,自然就避开了所有和 this 指向相关的坑,逻辑更简单、更安全。

简单来说,函数组件的写法「更符合普通人写 JS 的直觉」——定义的变量就在当前作用域,直接用就行,不用额外考虑「谁是 this」「this 指向谁」。

实质:用静态绑定 代替 动态绑定

这个总结精准到核心了!只需要再把「静态词法作用域」和「动态 this 绑定」的对应关系说透,就能彻底理解两者的本质区别。

先肯定你的结论:完全正确!

  • 类组件的 this调用时动态绑定 → 指向谁,取决于「怎么调用方法」,而非「方法在哪定义」;
  • 函数组件的变量/方法:定义时静态绑定(词法作用域) → 能访问哪些变量,取决于「代码写在哪」,而非「怎么调用函数」。

用最直白的例子+对比,讲透这个核心区别

1. 类组件:动态 this(调用时确定)
class Counter extends React.Component {
  state = { count: 0 };
  
  handleClick() {
    console.log(this.state.count); // this 指向「调用时的对象」
  }

  render() {
    // 场景1:直接传方法引用 → 调用时 this 是 undefined(丢失)
    return <button onClick={this.handleClick}>按钮1</button>;

    // 场景2:通过实例调用 → 调用时 this 是组件实例(正确)
    // return <button onClick={() => this.handleClick()}>按钮2</button>;
  }
}

关键:handleClick 里的 this不是定义时确定的——写代码时你不知道它指向谁,只有点击按钮、方法被调用的那一刻,才知道 this 是啥。

  • 按钮1:调用方式是「孤立调用」→ this = undefined;
  • 按钮2:调用方式是「实例.方法()」→ this = 组件实例。
2. 函数组件:静态词法作用域(定义时确定)
function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    console.log(count); // count 指向「定义时所在的作用域」
  };

  return <button onClick={handleClick}>按钮</button>;
}

关键:handleClick 里的 count定义时就确定了——它属于 Counter 函数执行时的局部作用域,不管 handleClick 被传到哪、怎么调用,它内部访问的 count 永远是当前 Counter 作用域里的变量。

  • 哪怕你把 handleClick 传给子组件、甚至全局调用,它依然能拿到 Counter 里的 count(因为词法作用域「锁死」了变量的查找路径);
  • 全程没有「动态绑定」,只有「静态的作用域查找」,所以永远不会找错变量。

再用「找路」的例子通俗解释

类组件(动态 this) 函数组件(词法作用域)
你告诉朋友:「到我家后,问主人在哪,然后跟主人走」→ 朋友到了之后,可能遇到假主人(this 指向错)、没主人(this=undefined),走丢; 你告诉朋友:「沿着XX路直走,到3号楼2单元」→ 路线是固定的(定义时就确定),不管朋友什么时候来、怎么来,按路线走都能到,不会错;

核心总结

  1. 类组件的坑:this动态绑定,指向由「调用方式」决定,写代码时无法确定,容易出错;
  2. 函数组件的优势:利用 JS 的静态词法作用域,变量的查找路径在「定义时就固定」,不管怎么调用函数,都能精准找到对应的变量;
  3. 最终结果:函数组件从根源上避开了「动态绑定」的不确定性,不用再纠结「this 指向谁」,逻辑更稳定。

精准抓到「动态调用绑定 vs 静态词法作用域」这个核心,说明我们已经完全理解了 Hooks 函数组件避开 this 坑的底层逻辑!

类组件痛点2: 业务相关逻辑碎片化

一个业务逻辑(比如「请求数据并渲染」)往往需要拆分到多个生命周期里,导致相关逻辑被分散在不同函数中,阅读和维护成本极高。

class UserList extends React.Component {
 state = { users: [], loading: true };

  // 1. 组件挂载时请求数据 -->
 componentDidMount() {
   this.fetchUsers();
   this.timer = setInterval(() => console.log('定时器'), 1000);
 }

  // 2. 组件更新时(比如 props 变化)重新请求数据 -->
 componentDidUpdate(prevProps) {
   if (prevProps.id !== this.props.id) {
     this.fetchUsers();
   }
 }

  // 3. 组件卸载时清理定时器 -->
 componentWillUnmount() {
   clearInterval(this.timer);
 }

 // 业务逻辑:请求用户数据
 fetchUsers() {
   fetch('/api/users')
     .then(res => res.json())
     .then(users => this.setState({ users, loading: false }));
 }

 render() { /* 渲染逻辑 */ }
}

你看:「请求数据 + 清理定时器」 这两个相关的逻辑,被拆到了 componentDidMount/componentDidUpdate/componentWillUnmount 三个生命周期里,代码跳来跳去,很难一眼看懂。

也就是说 业务逻辑强制和 react生命周期耦合到一起去了

类组件痛点3: 状态逻辑复用难、陷入嵌套地狱

先明确核心概念

  • 状态逻辑复用:比如「跟踪鼠标位置」「表单校验」「登录状态管理」这些逻辑,多个组件都需要用,想抽出来复用;
  • 嵌套地狱:为了复用逻辑,类组件只能用「高阶组件(HOC)」或「Render Props」,导致组件代码一层套一层,像剥洋葱一样难读。

第一步:看一个真实场景——复用「鼠标位置跟踪」逻辑

假设你有两个组件:MouseShow(显示鼠标位置)、MouseFollowBtn(按钮跟着鼠标动),都需要「跟踪鼠标位置」这个逻辑。

先写类组件的复用方案:Render Props(最典型的嵌套来源)

首先,把「鼠标跟踪」逻辑封装成一个通用组件(Render Props 模式):

// 通用的鼠标跟踪组件(Render Props 核心:把状态传给 children 函数)
class MouseTracker extends React.Component {
  state = { x: 0, y: 0 };

  // 监听鼠标移动,更新状态
  componentDidMount() {
    window.addEventListener('mousemove', this.handleMouseMove);
  }
  componentWillUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove);
  }
  handleMouseMove = (e) => {
    this.setState({ x: e.clientX, y: e.clientY });
  };

  // 核心:把状态传给子组件(通过 children 函数)
  render() {
    return this.props.children(this.state);
  }
}

然后,用这个组件实现「显示鼠标位置」:

// 第一个组件:显示鼠标位置
function MouseShow() {
  return (
    <div>
      <h2>鼠标位置:</h2>
      {/* 第一层嵌套:MouseTracker */}
      <MouseTracker>
        {/* children 是函数,接收鼠标状态 */}
        {({ x, y }) => (
          <p>X: {x}, Y: {y}</p>
        )}
      </MouseTracker>
    </div>
  );
}

再实现「按钮跟着鼠标动」:

// 第二个组件:按钮跟着鼠标动
function MouseFollowBtn() {
  return (
    <div>
      {/* 第一层嵌套:MouseTracker */}
      <MouseTracker>
        {({ x, y }) => (
          {/* 按钮样式绑定鼠标位置 */}
          <button style={{ position: 'absolute', left: x, top: y }}>
            跟着鼠标跑
          </button>
        )}
      </MouseTracker>
    </div>
  );
}
问题来了:如果要复用多个逻辑,嵌套直接「地狱化」

现在需求升级:这两个组件不仅要「跟踪鼠标」,还要「复用主题样式」(比如深色/浅色模式)。

先封装「主题复用」的 Render Props 组件:

// 通用的主题组件
class ThemeProvider extends React.Component {
  state = { theme: 'dark', color: 'white', bg: 'black' };

  render() {
    return this.props.children(this.state);
  }
}

现在,MouseShow 要同时复用「鼠标+主题」逻辑,代码变成这样:

function MouseShow() {
  return (
    <div>
      <h2>鼠标位置:</h2>
      {/* 第一层嵌套:ThemeProvider */}
      <ThemeProvider>
        {/* 接收主题状态 */}
        {({ theme, color, bg }) => (
          {/* 第二层嵌套:MouseTracker */}
          <MouseTracker>
            {/* 接收鼠标状态 */}
            {({ x, y }) => (
              <p style={{ color, backgroundColor: bg }}>
                【{theme}主题】X: {x}, Y: {y}
              </p>
            )}
          </MouseTracker>
        )}
      </ThemeProvider>
    </div>
  );
}

如果再要加一个「用户登录状态」的复用逻辑,就会出现第三层嵌套

<UserProvider>
  {({ user }) => (
    <ThemeProvider>
      {({ theme, color, bg }) => (
        <MouseTracker>
          {({ x, y }) => (
            <p>【{user.name}】【{theme}】X: {x}, Y: {y}</p>
          )}
        </MouseTracker>
      )}
    </ThemeProvider>
  )}
</UserProvider>

这就是嵌套地狱

  • 代码层层缩进,一眼看不到头;
  • 逻辑越复用,嵌套越深;
  • 想改某个逻辑(比如换主题),要在嵌套里找半天,维护成本极高。
补充:高阶组件(HOC)的复用方式,同样逃不开嵌套

如果用 HOC 实现复用(比如 withMousewithTheme),代码是这样的:

// 用 HOC 包装组件:一层套一层
const MouseShowWithTheme = withTheme(withMouse(MouseShow));

// 渲染时看似没有嵌套,但 HOC 本质是「组件套组件」,调试时 DevTools 里全是 HOC 包装层
// 比如 DevTools 里会显示:WithTheme(WithMouse(MouseShow))

调试时要一层层点开包装组件,才能找到真正的业务组件,同样痛苦。

函数组件说:没有hook,就别指望我了

千万别以为 函数组件是救星,React 早就有函数组件了,但是只是个纯展示的配角

先明确:Hooks 出现前,函数组件的「纯展示」本质

在 React 16.8(Hooks 诞生)之前,函数组件的官方定位就是 「无状态组件(Stateless Functional Component,SFC)」,核心特点:

  1. 没有自己的状态(this.state);
  2. 没有生命周期(componentDidMount/render 等);
  3. 本质就是「输入 props → 输出 JSX」的纯函数——输入不变,输出就不变,没有任何副作用。

举个 Hooks 前的函数组件例子:

// 典型的「纯展示组件」:只接收 props,渲染 UI,无任何状态/副作用
function UserCard(props) {
  const { name, avatar, age } = props;
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>年龄:{age}</p>
    </div>
  );
}

// 使用时:状态全靠父组件传递
class UserList extends React.Component {
  state = {
    users: [{ name: '张三', avatar: 'xxx.png', age: 20 }]
  };

  render() {
    return (
      <div>
        {this.state.users.map(user => (
          <UserCard key={user.name} {...user} />
        ))}
      </div>
    );
  }
}

这个例子里:

  • UserCard 是函数组件,只负责「展示」,没有任何自己的逻辑;
  • 所有状态(users)、数据请求、生命周期逻辑,都必须写在父类组件 UserList 里;
  • 如果 UserCard 想加个「点击头像放大」的交互(需要状态 isZoom),对不起——函数组件做不到,必须把它改成类组件,或者把 isZoom 状态提到父组件里(增加父组件复杂度)。

为什么当时函数组件只能是「纯展示」?

核心原因是 React 的设计规则:

  • 状态、生命周期、副作用这些「动态能力」,当时只开放给类组件;
  • 函数组件被设计成「轻量、高效、无副作用」的最小渲染单元,目的是简化「纯展示场景」的代码(不用写 class/constructor 等冗余代码)。

Hooks说:函数组件你别灰心,我让你从配角变主角

先拆清楚:函数组件 vs Hooks 的分工

1. 光有「函数组件」,解决不了任何问题

在 Hooks 出现之前,React 早就有函数组件了,但那时的函数组件是「纯展示组件」—— 没有状态、没有生命周期,只能接收 props 渲染 UI。

如果想在旧版函数组件中复用状态逻辑,依然只能用「Render Props/HOC」的嵌套方式:

// Hooks 出现前的函数组件:想复用逻辑,还是得嵌套
function MouseShow() {
  return (
    <MouseTracker>
      {({x,y}) => <p>X:{x}, Y:{y}</p>}
    </MouseTracker>
  );
}

你看,哪怕是函数组件,没有 Hooks,依然逃不开嵌套 —— 因为没有「抽离状态逻辑」的工具。

2. Hooks 出现后:函数组件从「纯展示」→「全能选手」-- 痛点1迎刃而解

还是上面的 UserCard,Hooks 后可以直接加状态/副作用,不用依赖父组件:

import { useState } from 'react';

// 函数组件拥有了自己的状态,不再是「纯展示」
function UserCard(props) {
  const { name, avatar, age } = props;
  // 自己的状态:控制头像是否放大
  const [isZoom, setIsZoom] = useState(false);

  return (
    <div className="card">
      <img 
        src={avatar} 
        alt={name}
        style={{ width: isZoom ? '200px' : '100px' }}
        onClick={() => setIsZoom(!isZoom)} // 自己的交互逻辑
      />
      <h3>{name}</h3>
      <p>年龄:{age}</p>
    </div>
  );
}

此时函数组件的定位完全变了:

  • 既能做「纯展示」(简单场景),也能做「有状态、有副作用、有复杂逻辑」的完整组件;
  • 彻底替代了类组件的大部分场景,成为 React 官方推荐的写法。
  • 是不是 彻底解决了 痛点1

3. 相关逻辑「聚在一起」,告别碎片化 -- 痛点2 再见

useEffect 一个 Hook 就能覆盖挂载、更新、卸载三个阶段的逻辑,让「请求数据 + 清理定时器」这样的相关逻辑写在同一个地方。

import { useState, useEffect } from 'react';

function UserList({ id }) {
 const [users, setUsers] = useState([]);
 const [loading, setLoading] = useState(true);

 // 核心:请求数据 + 清理定时器 写在同一个 useEffect 里
 useEffect(() => {
   // 1. 挂载/更新时请求数据
   const fetchUsers = async () => {
     const res = await fetch(`/api/users?id=${id}`);
     const data = await res.json();
     setUsers(data);
     setLoading(false);
   };
   fetchUsers();

   // 2. 挂载时启动定时器
   const timer = setInterval(() => console.log('定时器'), 1000);

   // 3. 卸载/更新时清理副作用
   return () => clearInterval(timer);
 }, [id]); // 只有 id 变化时,才重新执行

 return <div>{loading ? '加载中' : users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}

对比类组件的写法:所有相关逻辑都在一个 useEffect,不用在多个生命周期函数之间跳来跳去,可读性直接拉满。

4. Hooks 才是「解决嵌套问题的核心」

Hooks(尤其是自定义 Hooks)的核心价值,是「把状态逻辑从组件渲染流程中抽离出来,变成可调用的纯逻辑函数」。

Hooks 的核心思路是:把「状态逻辑」从「组件嵌套」中抽离出来,变成独立的「函数」,组件直接调用函数即可,没有任何嵌套毕竟状态逻辑本来就跟 UI组件无关,为啥非要掺合在一起呢

还是上面的场景,用自定义 Hook 实现:

1. 抽离「鼠标跟踪」的自定义 Hook
import { useState, useEffect } from 'react';

// 自定义 Hook:抽离鼠标跟踪逻辑,返回鼠标位置
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return position;
}
2. 抽离「主题」的自定义 Hook
// 自定义 Hook:抽离主题逻辑,返回主题状态
function useTheme() {
  const [theme, setTheme] = useState({ mode: 'dark', color: 'white', bg: 'black' });
  return theme;
}
3. 组件中直接调用 Hook,没有任何嵌套
function MouseShow() {
  // 调用自定义 Hook:平铺写法,没有嵌套
  const { x, y } = useMousePosition(); // 复用鼠标逻辑
  const { mode, color, bg } = useTheme(); // 复用主题逻辑
  const { user } = useUser(); // 再复用用户逻辑,也只是多一行代码

  return (
    <p style={{ color, backgroundColor: bg }}>
      【{user.name}】【{mode}主题】X: {x}, Y: {y}
    </p>
  );
}
  • useState/useEffect 等内置 Hooks:让函数组件拥有了「状态」和「副作用处理能力」(这是抽离逻辑的基础);
  • 自定义 Hooks:把复用逻辑(比如鼠标跟踪、主题管理)封装成独立函数,让函数组件能「平铺调用」,而非「嵌套组件」。

对比类组件的嵌套地狱:

  • Hooks 是**平铺式复用**:不管复用多少逻辑,都是「调用函数 → 用状态」,代码没有任何缩进嵌套;
  • 逻辑和组件解耦:useMousePosition 可以在任何组件里调用,不用套任何包装组件;
  • 调试简单:DevTools 里直接看到 MouseShow 组件,没有层层包装的 HOC/Render Props 组件。

核心总结(痛点+解决方案)

类组件复用逻辑(HOC/Render Props) Hooks 复用逻辑(自定义 Hook)
必须通过「组件嵌套」实现 直接调用「函数」,无嵌套
逻辑越多,嵌套越深(地狱化) 逻辑越多,只是多几行函数调用
调试时要拆包装组件,成本高 调试直接看业务组件,逻辑清晰

简单来说,类组件的复用是「用组件套组件」,自然会嵌套;而 Hooks 的复用是「用函数抽逻辑」,组件只需要调用函数,从根源上消灭了嵌套地狱。 这也是 Hooks 最核心的价值之一——让状态逻辑复用变得简单、平铺、可维护

最后总结

  1. 函数组件是「容器」:提供了「平铺写代码」的基础形式,但本身没有复用状态逻辑的能力;
  2. Hooks 是「核心能力」
    • 内置 Hooks(useState/useEffect)让函数组件能「持有状态、处理副作用」给函数组件「加手脚」;
    • 自定义 Hooks 让状态逻辑能「脱离组件嵌套,以函数形式被平铺调用」「状态与 UI 解耦」
  3. 最终结论:不是函数的功劳,也不是单纯 Hook 的功劳 —— 是「函数组件作为载体」+「Hooks 作为逻辑复用工具」的组合,才解决了一系列问题

Hooks 是「矛」,函数组件是「握矛的手」—— 少了任何一个,都刺不穿嵌套地狱的盾。

❌