React 卸载阶段的引用清理:源码解析如何递归解除 DOM 节点与 Fiber 节点之间的互相引用

各位同学,大家早上好,欢迎来到今天的“React 源码深度解剖”现场。

我是你们的讲师,一个在 React 代码丛林里摸爬滚打多年的“资深代码屠夫”。今天,我们要聊一个稍微有点伤感,但在工程上至关重要的话题——React 卸载阶段的引用清理

如果说“挂载”是两个人从陌生到相爱的过程,那是充满了激情和创造力的;那么“卸载”,就是两个人分道扬镳,需要把共同拥有的东西(引用)彻底清零,不留一丝痕迹。如果不清零,这就不是分手,这是赖着不走,甚至是纠缠不清。

在 React 的世界里,组件的卸载往往意味着父组件 return null,或者组件本身 return false。这时候,React 需要做两件极其痛苦但又必须做的事情:

  1. 物理拆除:把 DOM 树上的节点拔掉,扔进垃圾回收站。
  2. 精神净化:把 Fiber 树上的引用断开,让垃圾回收器能放心地回收内存。

今天,我们就来扒开 React 的内裤,看看它是如何递归地、无情地解除 DOM 节点与 Fiber 节点之间那段“孽缘”的。


第一部分:先搞清楚,这俩人到底是怎么“纠缠”在一起的?

在开始拆解代码之前,我们得先理解为什么要“纠缠”。React 为了实现高性能的并发渲染,引入了 Fiber 架构。Fiber 不仅仅是虚拟 DOM 的升级版,它更像是一个复杂的调度系统。

在这个系统里,Fiber 节点是灵魂,而 DOM 节点是肉体。

为了在调度器里能迅速找到对应的 DOM 节点进行操作(比如计算布局、更新样式),Fiber 节点保存了一个私有属性:stateNode

// FiberNode.js (伪代码)
class FiberNode {
  constructor(tag) {
    this.tag = tag; // 函数组件、类组件还是宿主节点
    this.stateNode = null; // 关键!这里存着真实的 DOM 节点
    this.return = null; // 父节点
    this.child = null; // 第一个子节点
    this.sibling = null; // 下一个兄弟节点
    // ... 更多属性
  }
}

这就是那个“孽缘”的起点:

  1. Fiber 指向 DOM:当组件挂载时,React 创建了一个 DOM 元素,然后把这个 DOM 元素的引用塞进 FiberNode.stateNode。比如 <div />,它的 Fiber 节点的 stateNode 就是真实的 div 对象。
  2. DOM 指向 Fiber:真实的 DOM 元素(浏览器原生对象)并没有直接指向 Fiber 的属性,但通过 stateNode,我们建立了一个隐式的反向引用链。

为什么要这么麻烦?
因为 React 的调度器是异步的。当调度器决定要更新某个组件时,它得先找到那个组件对应的 DOM 节点,然后操作它。如果每次都要遍历 DOM 树去找 Fiber,那性能就崩了。所以,必须建立这种双向(或者说显式单向)的强引用关系。

好,现在分手了。
当组件卸载时,React 必须做一件事:FiberNode.stateNode 设为 null
如果不设为 null,那个真实的 DOM 节点(比如一个 <div>)就会一直被 Fiber 节点“牵着鼻子走”。虽然 JavaScript 的垃圾回收(GC)机制很智能,但如果对象之间形成了复杂的强引用环,GC 可能会因为找不到出口而犹豫不决,导致内存泄漏。

所以,我们的任务就是:递归遍历整棵树,把所有的 stateNode 砍断,把 DOM 节点拔掉。


第二部分:递归算法的入口——unmountChildFibers

在 React 的源码中,处理卸载的核心逻辑主要分布在 ReactFiberCommitWork.js 文件中。卸载的过程发生在 commit 阶段,也就是 DOM 更新的最后一步。

当父组件决定卸载子组件时,React 会调用 commitDeletion 函数。这个函数就像是一个拆迁办主任,它负责把一个单独的 Fiber 节点(及其子树)从树上“物理”和“逻辑”上移除。

让我们来看看 commitDeletion 的核心逻辑,虽然它很复杂,但剥开洋葱皮,你会发现它其实就是在做两件事:

  1. 清理副作用:执行 useEffect 的清理函数。
  2. 解除引用:递归地断开 DOM 和 Fiber 的关系。
// ReactFiberCommitWork.js (简化版)
function commitDeletion(finishedWork: Fiber): void {
  // 1. 先把副作用链清理一下,虽然主要是挂载阶段用得多,但这里顺手处理一下
  commitBeforeMutationEffects(finishedWork);

  // 2. 找到对应的 DOM 节点
  const current = finishedWork.alternate;
  const nextEffect = finishedWork.firstEffect;

  // 3. 核心逻辑开始:递归处理
  // 我们要遍历 finishedWork 的所有子节点
  // finishedWork 可能是一个函数组件,也可能是宿主组件(div)

  // 获取 DOM 父节点
  // 注意:这里有一个技巧,React 会利用 return 指针找到父节点
  const parent = findParent(finishedWork);

  // 获取我们要删除的 DOM 节点
  const domNode = finishedWork.stateNode;

  // 执行删除!
  removeChild(domNode, parent);

  // 4. 递归清理子节点
  // 这里有个坑:React 的卸载是“先断后删”还是“先删后断”?
  // 答案是:先断引用(逻辑删除),后操作 DOM(物理删除)。
  // 因为如果先删了 DOM,React 就找不到对应的 Fiber 来递归了。

  // 递归调用:处理 finishedWork 的子树
  // 这里其实就是对 finishedWork 的子节点调用 unmountChildFibers
  // 我们后面详细讲这个函数
  recursivelyUnmountChildren(finishedWork);
}

注意到了吗?代码里调用了 recursivelyUnmountChildren。这个名字听起来就很像递归。我们要深入的这个函数,就是我们要讲的主角。


第三部分:递归的精髓——unmountChildFibers 的循环与递归

在 React 的源码中,处理子节点卸载的核心函数叫 unmountChildFibers(在旧版本或某些分支可能是 unmountWorkInProgress)。这个函数展示了 React 如何像一个不知疲倦的园丁,修剪掉枯萎的树枝。

它的逻辑非常精妙,利用了 Fiber 节点的 sibling(兄弟)和 child(子节点)指针。

// ReactFiberCommitWork.js (核心逻辑模拟)
function unmountChildFibers(returnFiber: Fiber, firstChild: Fiber | null): void {
  let deletionSubtreeDepth = 0;
  let nextDeletion = firstChild;

  // 这里的 while 循环,就是递归的“循环”部分
  // 我们要遍历当前 Fiber 节点的所有子节点和兄弟节点
  while (nextDeletion !== null) {
    // 1. 处理当前这个 Fiber 节点
    // 这里的逻辑有点绕,React 需要区分当前是“挂载”还是“卸载”
    // 对于卸载,我们主要关注 stateNode 的清理
    const deletion = nextDeletion;

    // ... (省略一些关于 effect 的处理代码,重点是 stateNode)

    // 2. 递归清理子节点
    // 如果当前节点有子节点,我们得先递归进去处理子节点
    // 这就是“递归”的体现
    if (deletion.child !== null) {
      // 递归调用自己,传入当前子节点
      unmountChildFibers(deletion, deletion.child);
    }

    // 3. 移动指针,处理下一个兄弟节点
    nextDeletion = deletion.sibling;

    // 4. 关键步骤:解除引用!
    // 必须在这里把 stateNode 设为 null,否则 DOM 节点就被困住了
    if (deletion.stateNode !== null) {
        // 这是一个宿主组件(比如 div, span)
        // 我们需要把它从 DOM 树里彻底拿掉
        const node = deletion.stateNode;
        // 清理一些浏览器特有的属性,比如事件监听器(虽然主要是挂载时绑定的)
        node[detachEventListeners](); 
    }

    // 5. 清理副作用链
    // 如果有 useEffect 的 cleanup 函数,在这里执行
    if (deletion.effectTag & EffectTag.Deletion) {
        commitPassiveUnmountEffects(deletion);
    }
  }
}

这段代码告诉我们什么?

  1. 先深后浅:代码里 if (deletion.child !== null)nextDeletion = deletion.sibling 之前。这意味着,React 会先钻进最底层的子节点,把最里面的那个 div 的引用清理掉,处理完它的副作用,然后再返回来处理它的哥哥,最后处理它的父亲。
  2. 链表遍历:Fiber 节点实际上是一个双向链表(通过 childsibling)加上一个树状结构(通过 return)。unmountChildFibers 就是在遍历这个链表。

第四部分:物理层面的手术刀——removeChild 与 DOM 操作

逻辑上的引用清理(设为 null)只是第一步,物理上的 DOM 节点还在浏览器里占着内存呢。React 必须调用浏览器的 API 把它从 DOM 树中移除。

React 提供了一个非常通用的 removeChild 函数,它封装了不同浏览器的差异(比如 removeChildremove)。

// ReactFiberCommitWork.js (removeChild 实现)
function removeChild(parentNode: Node, childNode: Node): void {
  // 这里的 parentNode 是 Fiber 节点的父节点的 stateNode
  // childNode 是当前要删除的节点的 stateNode

  // React 做了一些防御性编程
  // 比如检查节点是否已经被移除了,或者父节点是否已经被销毁了

  try {
    // 核心操作:从父节点中移除子节点
    // 这会触发浏览器的回流,这是一个昂贵的操作
    // 所以 React 会在 commit 阶段一次性处理完所有卸载,而不是分散在渲染阶段
    parentNode.removeChild(childNode);
  } catch (error) {
    // 如果在移除过程中出错(比如节点已经不在了),React 会捕获并忽略
    // 这保证了卸载流程的健壮性
    console.error(error);
  }
}

为什么要在 unmountChildFibers 里调用 removeChild

因为 unmountChildFibers 是递归的!
想象一下这个组件树:

<div>
  <h1>标题</h1>
  <p>段落</p>
</div>
  1. unmountChildFibers 被调用来处理 <div>
  2. 它发现 <div> 有一个子节点 <h1>
  3. 它递归调用自己处理 <h1>
  4. 在处理 <h1> 时,它发现 <h1> 没有子节点了。于是它执行 removeChild,把 <h1><div> 里拔出来。
  5. 处理完 <h1>,它回到 <div>,发现 <div> 还有一个兄弟节点 <p>
  6. 它处理 <p>,执行 removeChild,把 <p><div> 里拔出来。
  7. 处理完 <p>nextDeletion 变成 null,循环结束。

这就是递归的力量:你只需要写一个处理节点的逻辑,然后告诉它:“如果有孩子,先去处理孩子;处理完孩子,再处理兄弟。”


第五部分:副作用与清理——commitBeforeMutationEffects

在卸载阶段,React 还有一项重要任务:执行清理函数。

React 的生命周期钩子 useEffect 在组件卸载时,会触发其 cleanup 函数(返回的那个函数)。这通常用于取消网络请求、清除定时器或解绑事件监听器。

React 如何知道什么时候执行这些清理函数?它通过 Fiber 节点上的 effectTag(副作用标签)。

// ReactFiberCommitWork.js
function commitBeforeMutationEffects(fiber: Fiber): void {
  // 这个函数负责在 DOM 操作之前,执行副作用(主要是 useEffect 的清理)

  // React 使用一个 nextEffect 链表来遍历需要处理的节点
  // 这是为了保证处理顺序

  while (fiber !== null) {
    const deletions = fiber.deletions;
    if (deletions !== null) {
      // 如果这个节点有被删除的子节点
      for (let i = 0; i < deletions.length; i++) {
        const child = deletions[i];

        // 这里会触发 cleanup 函数
        // 比如 useEffect(() => { return () => { clearTimeout(timer) } }, [])
        commitBeforeMutationEffectsOnFiber(child);
      }
    }

    // 递归处理兄弟节点
    fiber = fiber.nextEffect;
  }
}

关键函数 commitBeforeMutationEffectsOnFiber

function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) {
  // 1. 处理函数组件的卸载
  if (finishedWork.tag === FunctionComponent) {
    // ...
  }

  // 2. 处理宿主组件(DOM 节点)的卸载
  if (finishedWork.tag === HostComponent) {
    // 这一步主要是为了在 DOM 移除前做一些处理
    // 比如 ref 回调函数的调用
    // 比如 layoutEffect 的清理(React 18 引入了 layout effect)
  }

  // 3. 核心:处理 useEffect 的 cleanup
  // React 会检查 finishedWork 的 effectTag 是否包含 Deletion
  if (finishedWork.effectTag & EffectTag.Deletion) {
    // 执行 cleanup
    commitPassiveUnmountEffects(finishedWork);
  }
}

这里有个细节很有意思:
React 分离了 commitBeforeMutationEffectscommitMutationEffects
commitBeforeMutationEffects 里,React 会执行 useEffect 的清理函数。
commitMutationEffects 里,React 才真正去操作 DOM(调用 removeChild)。

为什么要先清理,后拆家?
因为如果先拆家(删 DOM),那么组件内部引用的 DOM 节点就没了,清理函数里如果还想操作 DOM,就会报错或者找不到节点。所以,必须先让清理函数跑完,把该解绑的绑解开,该取消的请求取消了,然后再动刀子。


第六部分:return 指针的逆转——如何找到父节点?

commitDeletion 里,我们看到了 findParent(finishedWork) 这个函数。在卸载阶段,我们如何知道某个 Fiber 节点的父 Fiber 是谁?

正常情况下,Fiber 节点通过 return 指针指向上一个节点。但是,当我们递归卸载子节点时,我们是在 unmountChildFibers 里,此时 finishedWork(当前正在处理的节点)的 return 指针可能已经被重置了(因为父节点已经处理完了)。

所以,React 不能直接用 finishedWork.return 来找 DOM 父节点,因为那个父节点可能已经不在当前的 Fiber 树结构里了(被卸载了)。

React 采用了一个聪明的策略:从当前节点向上回溯,找到最近的一个还在树上的宿主节点。

// ReactFiberCommitWork.js (简化版)
function findParent(fiber: Fiber): Instance {
  // 从当前节点开始,顺着 return 指针往上找
  // 直到找到一个 tag 为 HostComponent 的节点(比如 div, section)
  // 或者找到 null

  let node = fiber;
  let parent = node.return;

  while (parent !== null && parent.tag !== HostComponent) {
    node = parent;
    parent = parent.return;
  }

  return parent ? parent.stateNode : null;
}

为什么要这么麻烦?
因为 Fiber 树在渲染过程中是动态构建的。当卸载发生时,React 会创建一个“工作 Fiber 树”(WorkInProgress Tree),这个树和当前的“Current Fiber 树”是分离的。
findParent 的目的就是要在 WorkInProgress 树里,找到这个子节点的“大房子”(父 DOM 节点),然后把子节点从这个房子里踢出去。


第七部分:内存泄漏的幽灵——为什么我们要这么小心翼翼?

很多同学可能会问:“React 不是垃圾回收语言吗?我删了组件,浏览器自己会回收内存吗?”

答案是:不一定。

React 之所以如此繁琐地处理引用清理,是因为 JavaScript 的垃圾回收机制(GC)是基于“引用计数”和“可达性分析”的。

  1. 强引用:如果 Fiber 节点还持有 DOM 节点的引用(stateNode !== null),那么这个 DOM 节点就是“可达”的。只要 Fiber 节点不被回收,DOM 节点就不会被回收。
  2. 循环引用:虽然 Fiber 和 DOM 是单向引用,但 DOM 元素上可能还挂载了事件监听器,事件监听器可能又引用了外部的一些对象。
  3. 闭包陷阱:如果在组件卸载后,还有一个定时器或者异步请求还在运行,并且回调函数里引用了组件的 state 或 props,那么这个组件实例可能就不会被回收。

React 的做法是:
在卸载阶段,React 主动把 FiberNode.stateNode = null
这就像是给垃圾回收器发了一张“通行证”。一旦 stateNode 变成 null,DOM 节点就变成“不可达”的了。加上 React 还会调用 removeChild 把 DOM 节点从浏览器 DOM 树中移除,这个 DOM 节点就彻底没用了。

代码示例:内存泄漏的对比

function BadComponent() {
  const timer = setInterval(() => {
    console.log("I'm still alive!"); // 即使组件卸载了,这里还在打印
  }, 1000);

  return <div>我是坏孩子</div>;
}

如果组件卸载时没清理 timer,这个组件实例就会一直留在内存里。

function GoodComponent() {
  useEffect(() => {
    const timer = setInterval(() => {
      console.log("I'm alive.");
    }, 1000);

    // 关键点:卸载时清理!
    return () => {
      clearInterval(timer); // React 会在这里执行这个函数
    };
  }, []);

  return <div>我是好孩子</div>;
}

React 源码里的 commitBeforeMutationEffectsOnFiber 正是在执行这个 return 函数的过程。它保证了组件“死”得干干净净。


第八部分:深入源码细节——处理 alternate 节点

在 React 的源码中,处理 Fiber 卸载时,经常会出现 alternate(备选)节点的概念。

React 在渲染时,会维护两棵树:

  1. Current Tree:当前显示在屏幕上的树。
  2. WorkInProgress Tree:正在构建、准备更新或卸载的新树。

当组件卸载时,React 通常是在 WorkInProgress 树上进行操作,然后把 WorkInProgress 树标记为 Completed,最后替换掉 Current 树。

commitDeletion 中,我们需要处理的是 WorkInProgress 树里的节点。但是,这个节点可能对应 Current 树里的某个节点。

function commitDeletion(finishedWork: Fiber): void {
  // finishedWork 是 WorkInProgress 树里的节点
  const current = finishedWork.alternate; // 找到对应的 Current 节点

  // 我们需要把 finishedWork 的副作用链连接到 current 上
  // 以便后续的调度器能正确处理

  // ... (省略连接 effect 链的代码)

  // 然后递归卸载子节点
  recursivelyUnmountChildren(finishedWork);
}

为什么要处理 alternate
因为 finishedWork 可能是一个函数组件,它没有 stateNode。它的 stateNode 实际上在 current 节点上。
所以,在清理副作用时,React 可能会同时处理 finishedWorkcurrent,确保两棵树的状态一致。

但在处理 DOM 节点的物理移除时,React 主要关注的是 finishedWork(因为它是正在被删除的节点)。如果 finishedWork 是一个宿主组件,它一定有 stateNode


第九部分:递归的边界与性能考量

递归虽然好写,但在 React 这种大规模应用中,如果递归太深(比如一个组件里有几千个嵌套的 div),可能会导致调用栈溢出。

不过,React 的 Fiber 架构本质上就是为了解决递归过深的问题。unmountChildFibers 虽然看起来是递归函数,但它是在 commit 阶段执行的,而 commit 阶段本身就被设计为同步的、低频的。

React 的优化策略:

  1. 批量处理:React 不会每删除一个节点就触发一次 GC 回收,而是批量删除,一次性清理引用。
  2. Fiber 链表:利用链表遍历代替函数递归(虽然代码里用了函数递归,但在底层逻辑上,它维护了一个 nextEffect 链表,这是链表遍历)。

第十部分:总结与升华

好了,各位同学,我们的“React 卸载”讲座接近尾声了。

让我们回顾一下这场复杂的“分手大戏”:

  1. 起因:组件卸载,父组件 return null
  2. 准备:React 调度器在 commit 阶段介入,找到要删除的 Fiber 节点。
  3. 第一步(精神净化)commitBeforeMutationEffects 执行。它递归遍历子节点,调用 unmountChildFibers。在这个函数里,它先递归处理子节点(深搜),然后断开 stateNode 引用,最后执行 useEffect 的清理函数。
  4. 第二步(物理拆除)commitMutationEffects 执行。它找到父 DOM 节点,调用 removeChild 把节点从 DOM 树中物理移除。
  5. 收尾:清理完毕,Fiber 树结构解体,内存释放。

React 源码的这种设计,体现了工程学的极致追求:性能、安全、健壮

它不只是在删除代码,它是在维护一个复杂的生态系统。每一个 stateNode = null,每一个 parentNode.removeChild,都是为了让 React 应用在长时间运行后,依然保持轻盈、敏捷,没有内存泄漏的包袱。

下次当你写 useEffect 的时候,记得那个 return 函数。那是你的组件在离开这个世界前,留下的最后一声叹息。而 React,负责把这个叹息变成最优雅的告别。

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

发表回复

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