React 挂载阶段的副作用清理:源码解析卸载 Fiber 节点时对所有的 ref 与 timer 的自动化清理

React 挂载阶段的副作用清理:源码解析卸载 Fiber 节点时对所有的 ref 与 timer 的自动化清理

各位好!欢迎来到今天的“React 内部架构深度挖掘研讨会”。

今天我们不聊怎么写 useEffect 的依赖数组,也不聊 memo 的渲染性能优化。今天我们要聊点更“灰暗”、更“沉重”,但也更“必要”的话题——告别

在 React 的世界里,组件是有寿命的。它们出生(挂载),它们成长(更新),然后它们死去(卸载)。这听起来很残酷,但在计算机世界里,这是必须的。如果不让组件死去,我们的内存早就被撑爆了,就像一个永远不关水龙头的浴缸。

当组件死去的时候,会发生什么?React 会怎么收拾它的“烂摊子”?

特别是当组件在它短暂的一生中,偷偷摸摸地藏了一些“私房钱”(DOM 引用)或者“定时炸弹”(setTimeout)时,React 是如何确保在它离开时,把这些东西统统清理干净的?

这就涉及到了 React 源码中一个非常核心但又常被忽视的阶段——卸载阶段

今天,我们将化身 React 的“清道夫”,深入源码,看看 React 是如何在卸载 Fiber 节点时,优雅地处理 reftimer 的。


第一部分:告别前的“私房钱”——DOM Refs

1.1 什么是 Ref?它是你的“外挂”

在 React 开发中,我们经常用到 ref。它就像是组件的一根触角,伸到了 React 的虚拟 DOM 之外,直接抓住了真实的 DOM 节点。

比如:

function TextInput() {
  const inputRef = useRef(null);

  return <input ref={inputRef} />;
}

在这个瞬间,inputRef.current 就指向了那个 <input> 标签。

1.2 闭包的诅咒

这时候,React 的源码机制就介入了。当组件卸载时,inputRef.current 指向的那个 DOM 节点会被从真实 DOM 树中剪掉。

但是,问题来了。假设你的组件里有个 useEffect,它依赖这个 inputRef

function TextInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 这是一个“自杀式”的定时器,组件卸载时清理
    const timer = setTimeout(() => {
      console.log(inputRef.current.value); // 这里可能是个空壳
    }, 1000);

    return () => {
      clearTimeout(timer);
    };
  }, []); // 依赖为空,意味着这个 effect 只运行一次

  return <input ref={inputRef} />;
}

如果你在组件卸载后,这个 useEffect 的清理函数还没来得及执行(或者执行了但闭包里的 inputRef 还没被置空),那么 inputRef.current 就会变成一个悬垂指针

这就像你把旧房子拆了,但你的钥匙还在口袋里,而且你还记得钥匙插在哪个已经不存在的锁孔里。这在编程里是大忌,会导致内存泄漏,或者更糟,报错。

1.3 源码探秘:commitDetachRef

React 怎么防止这种事情发生?答案是:在组件卸载的那一刻,React 会自动把所有指向该组件 DOM 节点的 ref 设为 null

这个动作发生在 commit 阶段,具体来说是在 commitBeforeMutationEffects 阶段。

在 React 的源码(ReactFiberCommitWork.js)中,有这么一段逻辑:

// 伪代码演示
function commitDetachRef(fiber) {
  const ref = fiber.ref;
  if (ref !== null) {
    if (typeof ref === 'function') {
      // 如果 ref 是函数,调用它,传入 null
      ref(null);
    } else {
      // 如果 ref 是对象({ current: node }),把 current 设为 null
      ref.current = null;
    }
  }
}

这是多么体贴的举动啊!

当你点击按钮,组件被卸载,React 走到这个节点:

  1. 它检查这个 Fiber 节点有没有 ref 属性。
  2. 如果有,它调用你的 ref 函数,或者把你的 ref.current 设为 null
  3. 这就断开了组件与真实 DOM 的最后一丝联系,防止了闭包陷阱。

专家提示: 这也是为什么我们在开发中,有时候需要手动清理 ref。比如你用 ref 做了一个全屏遮罩,组件卸载时,React 会自动把遮罩的 display 改回 none(通过 ref.current),但你可能还需要手动调用一下 遮罩组件实例.close(),因为 React 只管 DOM,不管业务逻辑。


第二部分:告别前的“定时炸弹”——Timers

如果说 ref 是内存里的指针,那么 timer 就是时间轴上的刺客。setTimeoutsetIntervalrequestAnimationFrame,它们一旦启动,就不管组件死活了,直到时间到它们才会执行。

React 拥有控制权吗?拥有!React 是调度器,调度器就是上帝。

2.1 Passive Effects:被动清理

React 为什么要特意把 useEffect 的清理叫作 Passive Cleanup?因为它的执行时机非常特殊,它发生在浏览器绘制之后

当组件卸载时,React 不会在渲染阶段(Render Phase)就立马把 timer 清掉。Render 阶段只是计算“我们要变成什么样”,而 Commit 阶段才是“我们要变成什么样”并落地。

在卸载流程中,React 会执行 commitPassiveUnmountEffects。这是处理 useEffect 清理函数的核心舞台。

2.2 源码探秘:commitPassiveUnmountEffects

让我们看看这段代码的逻辑流。在 React 源码中,commitPassiveUnmountEffects 会遍历所有需要卸载的 Fiber 节点,并检查它们是否有 Passive 类型的 effect。

// ReactFiberCommitWork.js 的逻辑简化版
function commitPassiveUnmountEffects(fiber) {
  if (fiber.effectTag & Passive) {
    // 1. 处理 ref 清理
    commitDetachRef(fiber);

    // 2. 处理副作用清理
    // useEffect 返回的清理函数在这里执行
    commitCleanupEffectList(fiber);
  }

  // 递归处理子节点
  commitPassiveUnmountEffects(fiber.child);
  commitPassiveUnmountEffects(fiber.sibling);
  commitPassiveUnmountEffects(fiber.return);
}

这里有一个非常关键的细节:

commitCleanupEffectList 里,React 会取出 fiber.memoizedState(这里存放着 effect 的队列),然后执行每一个清理函数。

function commitCleanupEffectList(fiber) {
  let firstEffect = fiber.firstEffect; // 获取第一个 effect
  while (firstEffect !== null) {
    const destroy = firstEffect.destroy;
    if (destroy !== undefined) {
      // 执行清理函数!这是最关键的一步。
      // 比如 clearTimeout(timer), removeEventListener 等
      destroy();
    }
    firstEffect = firstEffect.nextEffect;
  }
}

这意味着什么?

这意味着,如果你写了:

useEffect(() => {
  const id = setInterval(() => console.log('活着...'), 1000);
  return () => clearInterval(id); // 你的清理函数
}, []);

React 在组件卸载时,会自动找到这个清理函数,并执行它。

2.3 为什么是“Passive”?为什么不早点清理?

你可能会问:“为什么不在渲染阶段就清掉?渲染阶段不是很快吗?”

这就是 React 设计的精妙之处。

useEffect 的清理函数通常包含一些异步操作或者副作用。比如,你可能在清理函数里调用了 dispatch 更新状态,或者在清理函数里做了很多 DOM 操作。

如果 React 在渲染阶段就执行这些清理逻辑,那么它就破坏了“渲染阶段是纯函数”的原则。渲染阶段必须快、必须无副作用、必须可预测。React 把清理工作推迟到了 commit 阶段,具体来说是 Passive 阶段(在浏览器绘制之后),这样既保证了渲染性能,又保证了副作用能被正确处理。


第三部分:深度剖析——Fiber 树的生死轮回

为了彻底搞懂这个流程,我们必须理解 Fiber 节点的结构。Fiber 是 React 的物理载体。

3.1 Current Fiber vs WorkInProgress Fiber

React 的工作模式是“双缓冲”。

  1. Current Fiber Tree(当前树): 这是用户当前看到的 DOM 树。它是稳定的。
  2. WorkInProgress Fiber Tree(工作树): React 正在构建的新树。它是临时的。

当组件更新时,React 会克隆 Current Fiber,修改它的属性,变成 WorkInProgress Fiber,然后提交给浏览器。

当组件卸载时,React 会把 WorkInProgress 树中的某个节点标记为 Deletion(删除),然后把这个节点从 Current 树中移除。

3.2 EffectTag:组件的“行为日志”

每个 Fiber 节点都有一个 effectTag 属性。这个属性就像一个标签,记录了节点需要做什么。

在卸载过程中,我们关心的标签主要有:

  • Deletion (0x0008): 标记该节点需要被删除。
  • Passive (0x0040): 标记该节点有 useEffect
  • Layout (0x0020): 标记该节点有 useLayoutEffect

3.2.1 卸载 Ref 的逻辑流

commitBeforeMutationEffects 阶段,React 会遍历 Deletion 标记的节点。

// ReactFiberCommitWork.js
function commitBeforeMutationEffects() {
  // 递归遍历
  commitBeforeMutationEffects_begin(root, firstChild);
  commitBeforeMutationEffects_complete(root, firstChild);
}

function commitBeforeMutationEffects_begin(returnFiber, firstChild) {
  while (firstChild !== null) {
    const primaryEffectTag = firstChild.effectTag;

    // 如果有 Deletion 标记,说明这个子节点要被删了
    if ((primaryEffectTag & Deletion) !== NoEffect) {
      // 递归处理子节点(先处理子节点,确保 DOM 先被移除)
      commitBeforeMutationEffects_begin(firstChild, firstChild.child);

      // 关键!在这里处理 Ref 的卸载
      // 注意:Ref 的卸载是在 DOM mutation 之前,但布局效应之后
      if (primaryEffectTag & Ref) {
        commitDetachRef(firstChild);
      }
    }

    // 继续下一个兄弟节点
    firstChild = firstChild.sibling;
  }
}

为什么 Ref 卸载在 DOM 移除之前?
因为 commitDetachRef 需要访问 firstChild.ref。虽然 firstChild 还在 Fiber 树结构中,但它的 stateNode(指向真实 DOM 的指针)可能已经被移除了。React 必须在彻底切断联系之前,先执行清理逻辑。

3.2.2 卸载 Effect 的逻辑流

Ref 清理完了,接下来是 DOM 移除(commitMutationEffects)。

DOM 移除完了,最后才是 useEffect 的清理(commitPassiveUnmountEffects)。

// commitRoot 的流程
function commitRoot(root) {
  const finishedWork = root.finishedWork;

  // 1. 重置 flags
  root.finishedWork = null;
  root.nextPendingRoot = null;

  // 2. 开始 Commit 阶段
  // 这是一个巨大的 switch case,根据 effectTag 决定做什么
  const commitWork = commitWork; // 默认是 commitMutationEffects

  // ...

  // 3. 处理 Ref 和 Layout Effects (DOM 变更前)
  commitBeforeMutationEffects();

  // 4. 处理 DOM 变更 (Mutation)
  commitMutationEffects(root, finishedWork);

  // 5. 处理 Layout Effects (DOM 变更后,但在绘制前)
  commitLayoutEffects(root, finishedWork);

  // 6. 处理 Passive Effects (绘制后,副作用)
  commitPassiveEffects(root, finishedWork);

  // ...
}

执行顺序总结:

  1. Render (计算 Diff)
  2. Commit Before Mutation (执行 Ref 清理, DOM 移除)
  3. Commit Mutation (插入 DOM, 更新 DOM 样式)
  4. Commit Layout (执行 useLayoutEffect 的 setup 和 cleanup)
  5. Commit Passive (执行 useEffect 的 setup 和 cleanup)

第四部分:实战演练——模拟一个组件的死亡

为了让你更直观地理解,我们来手写一个简化版的 React 卸载流程。

假设我们有一个组件 UserProfile,它里面有一个 useEffect 启动了定时器,还有一个 useRef 指向一个文本框。

// 假设的 Fiber 节点结构
const fiber = {
  type: 'UserProfile',
  stateNode: { /* 真实 DOM 节点 */ },
  return: parentFiber,
  child: childFiber,
  sibling: null,
  // 关键属性
  effectTag: Deletion, // 标记为删除
  ref: { current: null }, // 假设 ref 是个对象
  memoizedState: {
    queue: {
      lastEffect: {
        destroy: () => console.log('>>> 清理:Timer 停止了!'),
        nextEffect: null
      }
    }
  }
};

当 React 走到这个节点时,发生了什么?

  1. React 拿到了 fiber
  2. 检查 Ref:
    fiber.ref.current = null; -> 你的 ref 对象被清空了。
  3. 检查 Effect:
    React 拿到了 fiber.memoizedState.queue.lastEffect.destroy
    它调用了这个函数:() => console.log('>>> 清理:Timer 停止了!')
  4. 检查 DOM:
    React 找到了 fiber.stateNode,然后把它从真实的 DOM 树中 removeChild 掉。

结果:
用户的文本框消失了,定时器停止了,你的 ref 也变空了。干干净净。


第五部分:那些被遗忘的角落——其他清理工作

除了 Ref 和 Timer,React 在卸载时还会做很多隐形的清理工作。

5.1 useLayoutEffect 的清理

useLayoutEffect 的清理函数是在 commitLayoutEffects 阶段执行的。

function commitLayoutUnmountEffects(fiber) {
  if (fiber.effectTag & Layout) {
    // 执行 useLayoutEffect 的清理
    commitCleanupEffectList(fiber);

    // 执行 ref 清理
    commitDetachRef(fiber);
  }
}

注意顺序:Layout 效果的清理在 Ref 清理之前。为什么?

因为 useLayoutEffect 的清理函数通常需要操作 DOM(比如滚动到顶部,或者强制重排)。如果先清空了 Ref(断开了 DOM 引用),再执行清理函数,可能会导致清理函数无法操作 DOM。所以 React 优先保证清理函数能拿到 DOM。

5.2 组件实例的清理

如果组件类使用了 componentWillUnmount(虽然不推荐),React 也会在卸载阶段调用它。

对于函数组件,React 会在 fiber.memoizedState 中找到 useRef 创建的 fiber 实例引用,并将其设为 null。这主要是为了防止循环引用导致的内存泄漏。


第六部分:为什么我们需要这种自动化清理?(深度思考)

很多初学者会问:“我自己在 useEffect 里写 return () => ... 不就行了吗?为什么还要懂 React 内部怎么清理 Ref 和 Timer?”

这是一个非常好的问题。理解底层机制能帮你写出更健壮的代码。

6.1 闭包陷阱的防御

React 自动清理 Ref,是防御性编程的极致体现。

想象一下,如果你在一个 useEffect 里保存了 ref.current 的值:

useEffect(() => {
  let val = inputRef.current.value; // 保存了当时的值
  const timer = setTimeout(() => {
    console.log(val); // 即使组件卸载了,这个闭包里的 val 还是旧的
  }, 2000);
  return () => clearTimeout(timer);
}, []);

如果 React 不自动清理 ref,那么即使组件销毁了,这个闭包里的 inputRef.current 可能还指向一个已经被删除的 DOM 节点。当你 2 秒后打印 val 时,浏览器可能会报错:“Accessing a dead object”(访问了一个死亡对象)。

React 的自动清理,确保了在清理函数执行时,DOM 节点还是活着的,Ref 也是有效的。

6.2 性能优化的边界

React 的清理逻辑是自动的,但也意味着它有成本。

每次组件卸载,React 都要遍历 Fiber 树,检查 effectTag,执行清理函数。这是一个 O(N) 的操作,其中 N 是卸载的组件数量。

虽然 React 已经优化得很好了(只遍历发生变化的节点),但如果你有成千上万个组件同时卸载,React 也会卡顿一下。这就是为什么在 React 18 中,如果父组件卸载了,子组件会立即停止工作,不会等到 React 走完整个卸载流程。这种“快速失败”的设计,也是为了减少不必要的清理计算。


第七部分:源码细节大赏——Effect List 的链表结构

为了更深入,我们得看看 memoizedState 到底长什么样。它不仅仅是一个函数,它是一个链表

当你在组件中写多个 useEffect 时,React 会把它们串成一个链表:

useEffect(() => { ... }, []);
useEffect(() => { ... }, []);
useEffect(() => { ... }, []);

在 Fiber 节点中,memoizedState 可能长这样:

{
  queue: {
    lastEffect: {
      nextEffect: {  // 第二个 effect
        nextEffect: { // 第三个 effect
          nextEffect: null
        }
      },
      destroy: () => console.log('清理 3')
    }
  }
}

commitCleanupEffectList 中,React 会从 lastEffect 开始遍历这个链表,依次执行所有的 destroy 函数。

这意味着,如果你在一个组件里写了 10 个 useEffect,那么当你卸载这个组件时,React 会依次执行这 10 个清理函数。

专家建议:
虽然 React 做了自动化清理,但在清理函数内部,尽量不要做特别重的计算。比如,不要在清理函数里发网络请求(除非你是为了取消请求)。因为 React 可能会在清理函数还没执行完的时候,就开始渲染下一个页面了。


第八部分:手动清理与自动化清理的博弈

虽然 React 会自动清理 reftimer,但有时候我们需要“手动干预”。

比如,你有一个全局的 eventBus

useEffect(() => {
  const unsubscribe = eventBus.on('data', handleData);
  return () => unsubscribe(); // 你必须手动返回这个清理函数
}, []);

React 不会自动帮你取消订阅。因为 React 不知道你的 eventBus 是怎么实现的,也不知道 unsubscribe 函数长什么样。

React 只能帮你清理它“创造”的东西:

  1. 它创造的 DOM 引用。
  2. 它创造的 Timer。
  3. 它创造的 useEffect 清理函数。

而对于你自己注册的监听器、全局变量、第三方库的实例,React 只能指望你在 useEffect 的清理函数里去处理。


第九部分:总结——当组件死去的那一刻

让我们把镜头拉远,回顾一下整个卸载过程。

当用户点击了“返回”按钮,或者路由发生了变化:

  1. React 的 Fiber 调度器 决定卸载 UserProfile 组件。
  2. Render 阶段 结束,React 拿到了标记为 DeletionFiber 节点。
  3. Commit Before Mutation:React 走到那个节点,执行 Ref 清理。它把你的 ref.current 设为 null,切断了你与 DOM 的最后一丝联系。这是为了保护你的代码,防止访问死亡对象。
  4. Commit Mutation:React 从真实 DOM 树中移除了那个 <div>
  5. Commit Layout:React 执行 useLayoutEffect 的清理函数。这时候 DOM 还在,你可以安全地操作它。
  6. Commit Passive:React 执行 useEffect 的清理函数。这时候浏览器可能已经画好下一帧了,但你的清理函数依然被准时召唤,把你的定时器停掉,把你的监听器取消。

这就是 React 的自动化清理机制。它就像一个尽职尽责的管家,在你离开房间之前,它帮你关灯、关水、锁门,并且把钥匙交还给房东。

作为开发者,我们不需要每天去写 clearInterval,因为 React 已经帮我们做了。但是,了解这些机制,能让我们在面对内存泄漏、闭包陷阱时,不再感到迷茫。

记住:

  • Ref 是物理连接,React 会亲手剪断。
  • Timer 是时间连接,React 会亲手掐断。
  • Effect 是逻辑连接,React 会亲手执行清理函数。

希望今天的讲座能让你对 React 的卸载阶段有一个全新的认识。下次当你点击返回键,看着页面飞快地切换时,你可以在心里默默为那些被清理的 Ref 和 Timer 点个赞。

谢谢大家!

发表回复

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