React 底层实现:当一个 Fiber 节点被标记为 Deletion 时,其内部的 useEffect 清理函数是在哪个相位执行的?

各位同学,大家好。

欢迎来到 React 内部架构的“解剖室”。今天我们要聊一个稍微有点“变态”,但又极其重要的话题:Fiber 节点的死亡(Deletion)与 useEffect 清理函数的送别仪式。

如果你觉得 React 只是一个用来写 UI 的框架,那你大概只用了它 10% 的功能。如果你想成为一名资深的前端架构师,或者只是单纯想搞懂“为什么我的组件销毁后 setTimeout 还在跑”这种反直觉的现象,那么请坐好,系好安全带。我们要开始钻进 React 的底层代码里了。

在这个讲座里,我假设大家已经对 Fiber 树、Render Phase(渲染阶段)和 Commit Phase(提交阶段)有基本的了解。如果你们还搞不清 workInProgresscurrent 的区别,我建议你们先去复习一下我的上一篇文章,或者去喝杯咖啡冷静一下。

今天,我们的目标是那个被标记为 Deletion(删除)的 Fiber 节点。当 React 决定要扔掉这个节点时,它究竟是在哪个时间点、哪个函数里,把你的 useEffect 清理函数给“枪毙”的?

别急,答案可能会让你大吃一惊,甚至让你怀疑人生。


第一部分:Fiber 的“自杀”前夜

首先,我们要明确一点:React 的协调过程,本质上就是一个“修补匠”的工作。

在 Render 阶段,React 会构建一棵 workInProgress 树。这棵树是 React 的“草稿纸”。React 会拿着这棵草稿纸,去对比已经存在的 current 树(也就是我们用户看到的真实 DOM 对应的树)。

对比过程中,React 会做三件事:Mount(挂载)Update(更新),以及 Unmount(卸载/删除)

当 React 发现 workInProgress 上的某个子节点,在 current 树上没有对应的兄弟节点时,它就会把那个 workInProgress 节点标记为 Deletion

这就好比,你的乐队正在排练,原来的吉他手(current 树)走了,新来的吉他手(workInProgress 节点)还没上台。React 怎么办?React 会把那个“新来的吉他手”的道具(Fiber 节点)扔进垃圾桶,并打上一个标签:Deletion

这个标记是一个位掩码(Bitmask)。在 React 源码里,它对应的是 FiberFlags 中的 Deletion 位。

// 源码中的常量定义(简化版)
const Deletion = 0x00000004; // 0b100

// 在协调过程中,如果发现需要删除
function markDeletion(returnFiber, child) {
  child.flags |= Deletion;
  // ... 复杂的指针移动逻辑
}

所以,当你看到控制台打印出一些奇怪的错误,或者组件卸载后定时器还在跑的时候,你就知道:那个 Fiber 节点已经被标记为 Deletion 了。

接下来,这个节点会进入 Commit Phase(提交阶段)。这是 React 最关键的时刻,也是我们今天的主角登场的时刻。


第二部分:Commit 阶段的三次心跳

在 Commit 阶段,React 会把 workInProgress 树的变化应用到 current 树上,也就是应用到真实的 DOM 上。

为了保持 DOM 的稳定性,React 把 Commit 阶段分成了三个小阶段。这三个阶段的名字听起来像是在做某种神秘的仪式:

  1. Commit Before Mutation Effects(提交前副作用阶段):也就是俗称的 commitBeforeMutationEffects
  2. Commit Mutation Effects(提交突变副作用阶段):也就是俗称的 commitMutationEffects
  3. Commit Layout Effects(提交布局副作用阶段):也就是俗称的 commitLayoutEffects

很多同学只知道 commitMutationEffects 是用来操作 DOM 的(比如 appendChild, removeChild),而 commitLayoutEffects 是用来执行 useLayoutEffect 的。但是,那个处于中间的、看似不起眼的 commitBeforeMutationEffects,到底在干什么?

这就是我们要挖的坑。


第三部分:答案揭晓——清理函数的“死亡行军”

回到我们的主题:当一个 Fiber 节点被标记为 Deletion 时,其内部的 useEffect 清理函数是在哪个相位执行的?

答案是:commitBeforeMutationEffects 阶段执行的。

你没听错。不是在 commitMutationEffects,也不是在 commitLayoutEffects。是在提交前

为什么?为什么 React 要在操作 DOM 之前,先跑去执行清理函数?

这就涉及到了 React 的一个核心概念:Effect Listeners(副作用监听器)

1. 什么是 Effect Listeners?

当你写下一个 useEffect 时,React 并不是把它当成一个简单的函数调用。React 会把你的回调函数注册为一个“监听器”。

  • 挂载时:React 把这个监听器挂载到某个地方(通常是浏览器的 setTimeout, setInterval, IntersectionObserver, 或者是某个全局事件监听器)。
  • 更新时:React 会先调用清理函数,移除旧的监听器,然后重新挂载新的监听器。
  • 卸载时:React 必须移除这个监听器。

2. 为什么必须在 DOM 删除之前?

想象一下,你的组件是一个 <div>,里面包含一个 setTimeout

function MyComponent() {
  useEffect(() => {
    const timer = setTimeout(() => {
      console.log("Hello");
    }, 1000);
    return () => clearTimeout(timer); // 清理函数
  }, []);

  return <div>I am dying</div>;
}

如果 React 的执行顺序是:先删除 DOM,后执行清理函数。

  1. DOM 被删除:浏览器把 <div> 从屏幕上抹去了。
  2. 清理函数执行clearTimeout(timer) 被调用。虽然定时器被取消了,但此时组件已经不在 DOM 树上了。

这看起来没问题?是的,对于简单的定时器没问题。但是,如果这个 useEffect 依赖的是 DOM 元素的引用呢?或者它依赖的是某个全局状态呢?或者在清理函数里有副作用逻辑?

更重要的是,Effect Listeners 通常会依赖 DOM 节点

如果一个 useEffect 是监听滚动事件的:

useEffect(() => {
  const handleScroll = () => { ... };
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

如果 React 先删除了 DOM(虽然这个例子里 DOM 是 window,但逻辑通用),再执行清理函数,那逻辑上是可以的。但如果这个监听器是绑定在当前组件的 DOM 节点上的(比如 ref.current.addEventListener),那么在 DOM 节点被删除之前,你必须先把这个监听器解绑。否则,当你删除 DOM 时,浏览器会报错:“你试图访问一个已经被垃圾回收的节点上的监听器”。

所以,为了保证 React 内部逻辑的健壮性,必须在 DOM 变更之前,先清理副作用监听器。


第四部分:源码级的“手术刀”

光说理论太枯燥了,我们来拿手术刀解剖一下 React 的源码。我们要看的是 ReactFiberCommitWork.js 文件。

1. 总指挥:commitBeforeMutationEffects

这是 commitBeforeMutationEffects 函数。它的主要任务是执行清理函数,并移除 Effect Listeners。

// 源码逻辑示意
function commitBeforeMutationEffects() {
  // 1. 首先处理 Effect Listeners 的移除(即 useEffect 的清理)
  commitBeforeMutationEffects_begin();
  commitBeforeMutationEffects_complete();

  // 2. 然后处理 DOM 的 Mutation(即 commitMutationEffects)
  commitMutationEffects();

  // 3. 最后处理 Layout(即 commitLayoutEffects)
  commitLayoutEffects();
}

注意这个顺序。清理 -> DOM 变更 -> Layout

2. 处理 Deletion:commitBeforeMutationEffects_begin

这是最关键的一步。当 React 遍历 Fiber 树时,如果发现某个节点有 Deletion 标志,它会做什么?

function commitBeforeMutationEffects_begin() {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    const flags = fiber.flags;

    // 如果有 Deletion 标志
    if ((flags & Deletion) !== NoFlags) {
      // 1. 挂载阶段和更新阶段都要执行清理函数
      commitBeforeMutationEffectsDetach(fiber);
    }

    // ... 递归处理子节点
  }
}

这里的 commitBeforeMutationEffectsDetach 就是我们要找的“行刑队”。

3. 行刑队:commitBeforeMutationEffectsDetach

这个函数负责遍历节点的 Effect Listeners,并移除它们。

function commitBeforeMutationEffectsDetach(fiber) {
  // 如果是 Effect Listeners (useEffect, useLayoutEffect)
  if (fiber.flags & PerformedWork) {
    // 执行清理逻辑
    // ...
  }

  // 如果有子节点,递归处理子节点的 Deletion
  if (fiber.child !== null) {
    commitBeforeMutationEffects_begin(fiber.child);
  }
}

这里有一个非常微妙的点:递归

React 是深度优先遍历的。当一个父节点被标记为删除时,React 会先处理子节点的删除(即子节点的 useEffect 清理),然后再处理父节点自己的删除。

这符合 DOM 树的层级逻辑。先删叶子,再删树枝。

4. 具体的清理动作

commitBeforeMutationEffectsDetach 的内部,React 会访问 fiber.updateQueue(更新队列)。

// 简化逻辑
function commitBeforeMutationEffectsDetach(fiber) {
  const effectTags = fiber.flags;

  // 如果是 Deletion,我们需要处理 Effect Listeners
  if (effectTags & Deletion) {
    // 获取该 Fiber 节点上的所有 Effect
    const effects = fiber.updateQueue;

    if (effects !== null) {
      // 遍历所有 Effect
      for (let i = 0; i < effects.length; i++) {
        const effect = effects[i];

        // 检查 Effect 类型
        // useEffect 的 Effect Tag 是 Passive (被动副作用)
        if (effect.tag === PassiveEffect) {
          // 执行清理函数!
          // 这就是为什么你的 setTimeout 会被 clearTimeout
          commitPassiveUnmountEffects(fiber, effect);
        }

        // useLayoutEffect 的 Effect Tag 是 Layout (布局副作用)
        if (effect.tag === LayoutEffect) {
          // 执行清理函数
          commitLayoutUnmountEffects(fiber, effect);
        }
      }
    }
  }
}

看到这里,你应该明白了。

当 React 发现一个节点要被删除时,它会遍历这个节点的 Effect Queue。对于每一个 useEffect,它都会调用你写的清理函数。对于每一个 useLayoutEffect,它也会调用清理函数。

注意: useLayoutEffect 的清理函数也是在 commitBeforeMutationEffects 阶段执行的,而不是在 commitLayoutEffects 阶段。commitLayoutEffects 阶段主要是执行 useLayoutEffect挂载/更新函数,而不是清理函数。


第五部分:代码实战演练

让我们通过一段代码来演示这个过程。

假设我们有这样一个组件树:

// 父组件
function Parent() {
  const [show, setShow] = React.useState(true);

  return (
    <div>
      <button onClick={() => setShow(false)}>删除我</button>
      {show && <Child />}
    </div>
  );
}

// 子组件
function Child() {
  useEffect(() => {
    console.log("Child useEffect Mounted");
    return () => {
      console.log("Child useEffect Cleanup Called!"); // 这是我们要找的日志
    };
  }, []);

  return <div>I am the Child</div>;
}

执行流程模拟:

  1. Render Phase: React 发现 show 变为 false<Child /> 不应该出现在 workInProgress 树中。
  2. Commit Phase 开始:
    • React 进入 commitBeforeMutationEffects
    • React 遍历 Fiber 树,找到 <Child /> 的 Fiber 节点。
    • React 发现这个节点被标记为 Deletion
    • React 调用 commitBeforeMutationEffectsDetach
    • React 发现这个节点有一个 PassiveEffectuseEffect)。
    • React 执行你的清理函数:console.log("Child useEffect Cleanup Called!")
    • React 移除了 Effect Listener(例如 setTimeout)。
  3. DOM 变更:
    • React 进入 commitMutationEffects
    • React 真正地从 DOM 树中移除 <div> 节点。
  4. Layout Effects:
    • React 进入 commitLayoutEffects
    • (此时子组件已经死了,不会再执行任何逻辑)。

控制台输出:

Child useEffect Mounted
Child useEffect Cleanup Called!

关键点: 你会看到 Cleanup Called 的日志, <div> 被从屏幕上移除之前打印出来的。这就是 commitBeforeMutationEffects 阶段的威力。


第六部分:深入探讨——为什么是“Before Mutation”?

你可能会问,为什么这个阶段叫 Before Mutation(突变前)?

因为 Mutation 通常指的是 DOM 的变更。Before Mutation 意味着“在 DOM 变更之前,先做点准备工作”。

React 把 useEffect 的清理函数放在这里,是为了确保在 DOM 发生剧烈变化(删除节点)之前,先切断与 DOM 的所有潜在联系。

这就像是你搬家(DOM 删除)之前,必须先把电话线拔了(清理 Effect Listeners),不然新搬来的家伙可能会接听到旧房子的电话。

此外,这里还有一个非常重要的原因:Effect Listeners 的执行时机

useEffect 的回调函数(而不是清理函数)是在 Mutation 阶段之后执行的。这意味着 useEffect 的回调函数可以访问到最新的 DOM

但是,清理函数必须在 Mutation 之前执行。这是为了防止一个逻辑错误:

  • 如果清理函数在 Mutation 之后执行,而清理函数里试图去操作已经被删除的 DOM 节点,那就会报错。
  • 或者,如果清理函数里包含一些异步操作(比如发送请求),React 必须在 DOM 删除之前知道这个异步操作已经被取消,以便在 Commit 阶段早期就停止相关的调度。

第七部分:与 useLayoutEffect 的爱恨情仇

为了彻底搞懂这个问题,我们必须把 useLayoutEffect 也拉出来遛遛。

useLayoutEffectuseEffect 非常像,唯一的区别是执行时机。

  • useEffect: 在浏览器绘制之后执行(Mutation 之后)。
  • useLayoutEffect: 在浏览器绘制之前执行(Mutation 之前,Layout 阶段)。

那么,useLayoutEffect 的清理函数在什么时候执行?

答案依然是:commitBeforeMutationEffects 阶段。

所以,useLayoutEffect 的清理函数和 useEffect 的清理函数,是在同一个时间点执行的。

但是,它们的挂载/更新函数是在不同的时间点执行的。

  • useLayoutEffect 的清理函数 -> commitBeforeMutationEffects(删除前)
  • useLayoutEffect 的挂载/更新函数 -> commitLayoutEffects(删除后,但绘制前)
  • useEffect 的清理函数 -> commitBeforeMutationEffects(删除前)
  • useEffect 的挂载/更新函数 -> commitMutationEffects(删除后,但绘制后)

代码示例:

function TestComponent() {
  useEffect(() => {
    console.log("useEffect Cleanup");
    return () => console.log("useEffect Cleanup");
  }, []);

  useLayoutEffect(() => {
    console.log("useLayoutEffect Cleanup");
    return () => console.log("useLayoutEffect Cleanup");
  }, []);

  return <div>Test</div>;
}

// 当 TestComponent 被删除时:
// 1. commitBeforeMutationEffects 阶段
//    -> useEffect Cleanup
//    -> useLayoutEffect Cleanup
// 2. commitMutationEffects 阶段 (DOM 被移除)
// 3. commitLayoutEffects 阶段
//    -> (这里不会执行任何逻辑,因为已经挂载了,只是删除,不执行挂载函数)
// 4. commitMutationEffects 阶段 (绘制)
//    -> useEffect Callback

注意看,useLayoutEffect 的清理函数比 useEffect 的清理函数要早执行。这符合 useLayoutEffect 的同步特性。


第八部分:一些容易踩的坑

理解了这个机制,可以帮助你避免很多坑。

1. 组件卸载后,不要在 useEffect 里访问 ref.current

因为 commitBeforeMutationEffects 执行完毕后,DOM 就会被移除。虽然 ref.current 通常指向 DOM 节点,但在某些极端情况下(比如自定义的 ref 逻辑),在清理函数里访问 ref 可能会导致问题。

2. 不要在 useEffect 清理函数里做繁重的计算

因为 commitBeforeMutationEffects 是在主线程上同步执行的。虽然它不会阻塞渲染,但它会占用主线程时间。如果你在清理函数里写了一个复杂的循环,可能会导致页面卡顿。

3. 理解 useEffect 的“异步”本质

很多初学者误以为 useEffect 是同步的。实际上,useEffect 的清理函数是在下一次渲染的 Commit 阶段早期执行的。这解释了为什么有时候组件已经消失了,但你的回调函数还在执行。

4. 调试技巧

如果你想调试清理函数的执行时机,你可以在清理函数里加一个断点。你会发现,断点会出现在 commitBeforeMutationEffects 阶段,而不是在 commitMutationEffectscommitLayoutEffects 阶段。


第九部分:总结与升华

好了,同学们,让我们回到最初的问题。

当一个 Fiber 节点被标记为 Deletion 时,其内部的 useEffect 清理函数是在 commitBeforeMutationEffects 阶段执行的。

这个阶段,就像是一个“临终关怀”阶段。在 React 决定从 DOM 树上拔掉这棵草之前,它先来确认一下:草根下的害虫(副作用监听器)有没有清理干净?有没有定时器还在傻傻地等着运行?有没有事件监听器还抓着 DOM 节点不放?

React 通过这个机制,保证了 DOM 树的纯净和稳定。它不允许你把一个“死”的组件的副作用,遗留到“活”的 DOM 树上。

这就像是一个完美的管家。在你离开房间(组件卸载)之前,他会把灯关掉,锁好门,甚至帮你把垃圾桶倒掉。他绝不会在你走后,还在房间里乱扔垃圾。

所以,下次当你看到 useEffect 的清理函数被调用时,请对 React 感到一丝敬意。它正在默默地为你清理战场,确保你的应用健壮、稳定。

记住这个顺序:

  1. Render Phase(计算)
  2. Commit Before Mutation Effects(清理 useEffect/useLayoutEffect,删除前
  3. Commit Mutation Effects(操作 DOM)
  4. Commit Layout Effects(执行 useLayoutEffect)
  5. Commit Mutation Effects(绘制,执行 useEffect)

这就是 React 的底层逻辑。这就是为什么 React 能成为前端界的霸主。这就是为什么你必须读懂 Fiber。

好了,今天的讲座就到这里。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注