React 卸载阶段物理 DOM 引用清理逻辑

嘿,各位前端界的“代码诗人”们,大家好!

欢迎来到今天的讲座。今天我们不聊什么高大上的架构,也不搞什么玄乎的算法,我们来聊点“阴间”的话题——React 卸载阶段

你知道,React 组件就像是一群有生老病死的人类。它们有童年(挂载 Mount)、有壮年(更新 Update),当然,也逃不过老年(卸载 Unmount)。很多人只关心组件怎么“生”下来,怎么“长”大,怎么“跑”得快,却很少有人关心它们“死”的时候是怎么处理的。

这就像是你养了一只猫,你给它买最好的罐头,教它做算术,但当你决定把它送人,或者它自己离家出走的时候,你有没有想过,它的猫砂盆清干净了吗?它的毛发清理了吗?它是不是还在角落里发疯一样地抓墙,因为它的主人已经走了,但它还以为主人正在给它挠下巴?

在 React 里,这就是物理 DOM 引用清理的问题。今天,我们就来聊聊这些“死去的组件”到底是怎么被清理的,以及为什么如果你不清理,它们就会变成你代码里的“幽灵”。


第一部分:Refs —— 那个不想放手的孩子

首先,我们要聊聊 ref。在 React 的世界里,ref 是个特殊的存在。如果说 props 是从外面传进来的命令,state 是组件肚子里的秘密,那 ref 就是直接触碰 DOM 的手

想象一下,你有一个输入框,你不想用 onChange 去监听输入,你想直接拿到这个 <input> 的原生 DOM 对象,给它设置个 value,或者获取它的焦点。这时候,你就需要一个 ref

const MyComponent = () => {
  const inputRef = React.useRef(null);

  const handleClick = () => {
    // 直接操作真实的 DOM
    inputRef.current.focus();
    console.log("我摸到了真实的 DOM 节点:", inputRef.current);
  };

  return (
    <div>
      <input type="text" ref={inputRef} />
      <button onClick={handleClick}>摸一下 DOM</button>
    </div>
  );
};

这里的 inputRef.current 指向了浏览器里真实的那个 <input> 标签。它是活的,它是物理的。

但是,React 有一个核心原则:数据驱动视图。 当组件卸载时,React 会把组件从虚拟 DOM 树里删掉,然后通知浏览器把对应的真实 DOM 节点从页面上删掉。

这就带来一个很有趣的问题:当组件死了,那个 ref.current 还指着谁?

在卸载阶段,React 会自动把 ref.current 设置为 null 吗?答案是:不一定,也不应该完全依赖它。

React 的卸载机制主要处理的是虚拟 DOM 和真实 DOM 的同步。当组件卸载时,React 会执行 unmountComponentAtNode 或者 Fiber 树的卸载逻辑。在这个过程中,React 确实会断开 Fiber 节点与 DOM 节点的链接。但是,useRef 返回的引用是一个闭包变量,它存在于你的组件函数体(或者说闭包作用域)里。

如果你在组件的 useEffect 或者 useLayoutEffect 里,把 DOM 引用保存在了 ref 里,那么在组件卸载后,只要这个 ref 变量本身没有被垃圾回收(GC),它依然指向那个已经被浏览器删除的 DOM 节点。

这就像你搬家了,但你的旧钥匙还插在门上。虽然门已经没了,但钥匙还在手里,你依然觉得门锁着。

const GhostComponent = () => {
  const domRef = React.useRef(null);

  React.useEffect(() => {
    // 组件挂载后,拿到 DOM
    domRef.current = document.getElementById('some-hidden-div');
    console.log("我抓到了一个 DOM:", domRef.current);

    // ...做点什么

    return () => {
      // 卸载时,我们通常需要清理逻辑
      // 但是 ref.current 指向的 DOM 节点已经被 React 删除了
      // 浏览器可能会把它标记为可回收
      console.log("我正在卸载,我的 DOM:", domRef.current);
      // domRef.current 现在可能是 null,也可能指向一个已被标记为 GC 的节点
    };
  }, []);

  return <div>我是幽灵组件,我正在消失...</div>;
};

所以,清理 ref 不仅仅是把 ref.current = null,更重要的是确保没有任何逻辑还在试图访问那个已经消失的 DOM


第二部分:清理函数—— 组件的“遗嘱”

在 React 18 之前,我们有 componentWillUnmount。这名字听着挺悲壮的,对吧?它就像是临终遗言。但在 React 18 之后,官方更推荐我们使用 useEffect清理函数

为什么要推荐 useEffect?因为副作用无处不在。定时器、网络请求、事件监听器,这些都不在 React 的渲染逻辑里,它们是“副作用”。当组件卸载时,你必须显式地告诉 React:“嘿,把你刚才让我做的那些坏事都停下来。”

这就是清理逻辑的核心:切断联系。

1. 定时器清理 —— 时间停止的魔法

最经典的例子就是 setTimeoutsetInterval

const TimerComponent = () => {
  const [count, setCount] = React.useState(0);
  const timerRef = React.useRef(null);

  React.useEffect(() => {
    // 启动一个定时器,每秒更新一次状态
    timerRef.current = setInterval(() => {
      console.log("Tick... Tick... Tick...");
      setCount(prev => prev + 1);
    }, 1000);

    // 清理函数:组件卸载时调用
    return () => {
      console.log("我要死了,请停止时间!");
      if (timerRef.current) {
        clearInterval(timerRef.current);
        timerRef.current = null;
      }
    };
  }, []); // 空依赖数组,意味着只在挂载和卸载时执行一次

  return <div>计时器次数: {count}</div>;
};

如果不清理会发生什么?
组件被卸载了,DOM 被删了。但是,浏览器里的那个定时器还在!它还在每秒运行一次。它运行一次,就会尝试调用 setCountsetCount 会触发 React 的更新流程。React 发现这个组件已经不在 DOM 树上了,它会试图渲染这个组件。

结果: React 会报错(或者在控制台警告):“Can’t perform a React state update on an unmounted component.”(不能在已卸载的组件上更新状态)。

这就像你的前任(组件)已经搬走了,但他手机里的闹钟还在响,每隔一分钟就发一条短信给你:“嘿,记得吃饭。”你虽然不再爱他了,但你还得处理这些垃圾短信,甚至因为频繁的弹窗导致浏览器卡死。

正确的做法: 在清理函数里,一定要 clearInterval

2. 事件监听器清理 —— 阻止幽灵点击

再比如,给 window 或者 document 绑定事件。

const WindowListenerComponent = () => {
  React.useEffect(() => {
    const handleResize = () => {
      console.log("窗口大小变了,虽然我已经不在了");
    };

    window.addEventListener('resize', handleResize);
    console.log("我绑定了 resize 事件");

    return () => {
      console.log("我解绑了 resize 事件");
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <div>监听窗口变化</div>;
};

如果不清理会发生什么?
这是内存泄漏的重灾区。如果你有 10 个这样的组件,每个都绑定了事件,当它们都卸载后,如果你点击窗口,浏览器会触发 10 个监听器。每个监听器里可能都有闭包,引用着那些已经销毁的组件数据。这就像是一个葬礼现场,所有的逝者都从棺材里坐起来,异口同声地喊了一声“Resize”。

正确的做法: 卸载时,必须 removeEventListener。而且,注意参数! 你必须传同一个函数引用removeEventListener,否则浏览器不知道你要删哪个。这也是为什么我们通常把回调函数定义在 useEffect 里面,或者用 useCallback 包裹,确保引用稳定。


第三部分:DOM 引用清理的深度剖析 —— 为什么 ref.current 变成了 null?

很多同学会有一个疑问:既然清理函数里 ref.current 已经指向了已删除的 DOM,为什么还要手动设置 ref.current = null

这涉及到 React 的内部实现机制。

当组件卸载时,React 会执行一个复杂的流程,我们称之为 Fiber 树卸载

  1. 断开父子链接: React 会遍历 Fiber 树,将当前组件的 FiberNode.return(父级引用)设置为 null。这就像把一个孩子的手从父母的手里掰开。
  2. 断开 DOM 链接: 每个 Fiber 节点都有一个 stateNode 属性,它指向对应的真实 DOM 节点。卸载时,React 会将 FiberNode.stateNode 设置为 null。这意味着,React 内部已经不知道这个 DOM 节点属于哪个组件了。
  3. 垃圾回收: 浏览器引擎检测到这个 DOM 节点不再被任何 JavaScript 对象引用(除了 React Fiber 的引用,React 已经把它清空了),于是浏览器会把它从内存中释放掉。

那么 useRef 呢?

useRef 返回的对象是在组件函数闭包里创建的。

function MyComponent() {
  const myRef = React.useRef(null); // 这是一个普通的对象,存在栈上(或堆上,取决于实现)

  React.useEffect(() => {
    myRef.current = document.getElementById('foo');
  }, []);

  // 组件函数结束,MyComponent 的栈帧销毁。
  // 但是 myRef 这个变量本身在哪里?
}

在 React 18 的实现中,ref 对象本身是作为 Fiber 节点的属性存在的(具体来说是 memoizedState)。当 Fiber 节点被卸载(stateNode 被设为 null),React 会把 memoizedState 也设为 null。

所以,严格来说,在组件完全卸载后,ref.current 也会变成 null

但是! 这里有一个巨大的陷阱,叫做闭包陷阱

如果你在 useEffect 的清理函数或者回调里,直接使用了组件作用域里的变量(比如 ref.current),当你清理函数执行完毕,组件函数栈已经返回了,但闭包里依然保存着那个 ref 对象的引用。

const BadComponent = () => {
  const domRef = React.useRef(null);

  React.useEffect(() => {
    domRef.current = document.querySelector('.target');

    // 假设我们有一个定时器,每秒检查一下 DOM 是否还在
    const interval = setInterval(() => {
      if (domRef.current) {
        console.log("DOM 还在,当前值:", domRef.current.innerText);
      } else {
        console.log("DOM 不见了!");
      }
    }, 1000);

    return () => {
      clearInterval(interval);
      // 清理函数执行完毕,组件函数 MyComponent 结束
      // 但是闭包里的 domRef 依然存在!
    };
  }, []);

  return <div>我是 BadComponent</div>;
};

在这个例子里,组件卸载了,但 domRef 这个闭包变量还在内存里,依然试图访问 domRef.current。虽然 React 内部已经把 DOM 节点删了,但闭包里的逻辑还在跑。虽然报错可能不是直接来自这里,但如果你在 domRef.current 上做了任何操作,或者试图读取它的属性,可能会引发一些不可预知的行为(比如读取到了已经被浏览器释放的内存地址,导致崩溃)。

结论: 虽然理论上 ref.current 会变 null,但显式地在清理函数里处理它,或者避免在闭包中过度依赖它,是资深工程师的素养。


第四部分:实战演练 —— 一个复杂的“僵尸”场景

让我们来个硬核的实战。假设我们写一个聊天窗口组件。

这个组件需要:

  1. 拿到底部滚动条 DOM 引用 (scrollRef)。
  2. 监听窗口滚动事件。
  3. 使用 setTimeout 做自动回复。
  4. 使用 setInterval 做心跳检测。

错误示范(不清理):

const ChatComponent = () => {
  const scrollRef = React.useRef(null);
  const [messages, setMessages] = React.useState([]);

  React.useEffect(() => {
    // 1. 绑定事件
    window.addEventListener('scroll', handleScroll);

    // 2. 启动定时器
    const timer = setTimeout(() => {
      setMessages(prev => [...prev, { text: "系统:你好", from: "bot" }]);
    }, 2000);

    // 3. 启动心跳
    const heartBeat = setInterval(() => {
      console.log("心跳发送中...");
    }, 5000);

    return () => {
      // 错误示范:我只清理了 scroll 事件,漏掉了 timer 和 heartBeat!
      // 而且,我甚至没有清理 scrollRef
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  const handleScroll = () => {
    // 读取 DOM
    if (scrollRef.current) {
      console.log("当前滚动高度:", scrollRef.current.scrollTop);
    }
  };

  return (
    <div ref={scrollRef} style={{ height: '300px', overflow: 'auto' }}>
      {messages.map(m => <div key={m.id}>{m.text}</div>)}
    </div>
  );
};

后果:
当你把这个组件从页面移除后,点击页面任何地方,浏览器依然会触发 handleScroll。因为 handleScroll 闭包里捕获了 scrollRef.current。虽然 DOM 节点没了,但闭包还在。如果你在 handleScroll 里做了复杂的逻辑,这些逻辑会继续运行,消耗 CPU 和内存。

正确示范(彻底清理):

const ChatComponent = () => {
  const scrollRef = React.useRef(null);
  const [messages, setMessages] = React.useState([]);
  // 保存所有副作用 ID,以便统一清理
  const effectIds = React.useRef({ timer: null, interval: null });

  React.useEffect(() => {
    // 1. 绑定事件
    const handleScroll = () => {
      if (scrollRef.current) {
        console.log("当前滚动高度:", scrollRef.current.scrollTop);
      }
    };
    window.addEventListener('scroll', handleScroll);

    // 2. 启动定时器
    effectIds.current.timer = setTimeout(() => {
      setMessages(prev => [...prev, { text: "系统:你好", from: "bot" }]);
    }, 2000);

    // 3. 启动心跳
    effectIds.current.interval = setInterval(() => {
      console.log("心跳发送中...");
    }, 5000);

    return () => {
      // 4. 彻底清理
      console.log("组件卸载,开始大扫除!");

      // 清理事件
      window.removeEventListener('scroll', handleScroll);

      // 清理定时器
      clearTimeout(effectIds.current.timer);
      clearInterval(effectIds.current.interval);

      // 清理 Ref 引用
      // 虽然 DOM 节点会被浏览器 GC,但我们可以手动置空,防止闭包残留
      scrollRef.current = null; 

      // 清空 ID
      effectIds.current = { timer: null, interval: null };
    };
  }, []);

  return (
    <div ref={scrollRef} style={{ height: '300px', overflow: 'auto' }}>
      {messages.map(m => <div key={m.id}>{m.text}</div>)}
    </div>
  );
};

第五部分:Fiber 架构下的 DOM 移除 —— 从虚拟到现实的跨越

光说不练假把式,我们来看看 React 内部到底是怎么把 DOM 节点删掉的。

React 采用 Fiber 架构。每个组件实例都是一个 FiberNode。这个节点有两个非常重要的属性:

  1. stateNode: 指向真实的 DOM 节点。
  2. return: 指向父 Fiber 节点。

当组件卸载时,React 会遍历这个 Fiber 树,执行一个 unmount 流程。

伪代码逻辑如下:

function commitUnmount(fiber) {
  // 1. 执行组件的卸载生命周期(旧版)或副作用清理函数(新版)
  // 这会触发我们的 return () => { ... } 逻辑
  if (fiber.updateQueue) {
    // 执行清理函数
    executeCleanup(fiber.updateQueue);
  }

  // 2. 断开 DOM 链接
  if (fiber.stateNode) {
    const domNode = fiber.stateNode;

    // 移除事件监听器(React 内部会处理,基于 Fiber 上的事件绑定)
    // removeEventListeners(domNode, fiber);

    // 断开与父节点的连接
    // React 会遍历 DOM 树,把当前 DOM 节点从父节点的子节点列表中移除
    // 这就是为什么我们看不到组件消失,因为它的 DOM 节点真的从 HTML 里没了
    if (fiber.return && fiber.return.stateNode) {
      const parentNode = fiber.return.stateNode;
      // DOM API: removeChild
      parentNode.removeChild(domNode);
    }

    // 3. 清空 Fiber 节点的引用
    fiber.stateNode = null;
  }

  // 4. 递归处理子节点
  if (fiber.child) {
    commitUnmount(fiber.child);
  }
  if (fiber.sibling) {
    commitUnmount(fiber.sibling);
  }
}

这个流程非常关键。注意第 2 步中的 parentNode.removeChild(domNode)。这是浏览器原生的 DOM 操作。

当你看到页面上的组件消失了,React 并没有“变魔术”,它真的调用了浏览器的 API 把那个 <div> 标签从 HTML 文档对象模型(DOM)里剪切掉了。

那么,ref.current 在这个过程中发生了什么?

ref 对象本身是作为 fiber.memoizedState 存在的。

function mountEffectImpl(fiber, queue, create) {
  // ... 创建 effect
  // fiber.updateQueue = { ... effects: [cleanupEffect] }

  // 设置 ref
  const ref = { current: domNode };
  fiber.memoizedState = ref;
}

commitUnmount 执行时,React 会处理 fiber.updateQueue 里的清理函数。至于 fiber.memoizedState(也就是我们的 ref),React 通常会把它设为 null

所以,从 React 内部的 Fiber 栈来看,ref 的生命周期是和组件完全绑定的。组件一死,ref 也就死了。

但是! 为什么我们还要强调“清理逻辑”?因为副作用useEffect)里的逻辑和渲染逻辑render)里的逻辑是解耦的。

渲染逻辑里的 ref.current 主要是用来(比如 ref.current.focus)。当组件卸载,渲染停止,自然就没法读了。

但是副作用里的逻辑(比如定时器、事件监听)是在组件外部运行的。它们不依赖渲染循环,它们只依赖闭包。如果不显式清理,这些副作用就会脱离组件的生命周期,变成“孤魂野鬼”。


第六部分:闭包陷阱与“幽灵”回调

这是 React 开发中最隐蔽、最折磨人的 Bug 之一。

场景: 一个组件在 setTimeout 里更新状态,组件卸载后,setTimeout 触发了。

代码:

const TimerBug = () => {
  const [text, setText] = React.useState('初始文本');

  React.useEffect(() => {
    const id = setTimeout(() => {
      // 这里有一个致命的闭包陷阱
      // 这里的 setText 指向的是 useEffect 注册时的那个闭包函数
      // 而不是当前最新的 setText
      setText('五秒后的文本'); 
    }, 5000);

    return () => clearTimeout(id);
  }, []); // 依赖数组为空

  return <div>{text}</div>;
};

如果你运行这个组件,你会看到:

  1. 页面显示“初始文本”。
  2. 5 秒后,页面报错:“Can’t perform a React state update on an unmounted component.”

为什么会这样?
React 为了优化性能,对于 useEffect 的依赖数组为空的情况,它复用了同一个闭包函数。也就是说,那个 setTimeout 里的 setText 永远是组件第一次挂载时的那个版本。

当 5 秒钟过去,定时器触发。它试图调用 setText。React 检查 Fiber 树,发现这个组件已经不在树上了(current Fiber 指向了空)。React 为了防止内存泄漏,直接抛出错误,阻止了这个更新。

这其实是一种保护机制。

但是,如果你在 setTimeout 里做的是副作用,比如发送网络请求,而不是更新状态,那会发生什么?

const NetworkBug = () => {
  React.useEffect(() => {
    const fetchData = async () => {
      console.log("开始请求数据...");
      await new Promise(r => setTimeout(r, 3000));
      console.log("数据请求完成!");
      // 这里我们没有更新 state,只是打印日志
    };

    fetchData();

    return () => {
      console.log("组件卸载,取消请求...");
      // 如果这里有 AbortController,这里会取消请求
    };
  }, []);

  return <div>组件</div>;
};

如果你不清理,请求会照常完成。这看起来没问题,但实际上,你的网络请求还在跑,你的服务器还在处理请求,但前端已经不需要这个数据了。这就是无效的网络流量服务器资源的浪费

正确的做法:

  1. 使用 AbortController(Fetch API):

    React.useEffect(() => {
      const controller = new AbortController();
    
      fetch('/api/data', { signal: controller.signal })
        .then(res => res.json())
        .then(data => console.log(data))
        .catch(err => {
          // 注意:这里要判断 err.name 是否为 'AbortError'
          if (err.name !== 'AbortError') {
            console.error('请求错误', err);
          }
        });
    
      return () => {
        controller.abort(); // 组件卸载,取消请求
      };
    }, []);
  2. 使用 AbortController (Axios):
    Axios 也有 AbortController 的支持。

  3. 使用 AbortController (React Query / SWR):
    如果你用这些库,它们通常内置了自动取消请求的逻辑。


第七部分:DOM 引用清理的最佳实践清单

作为一名资深专家,我为你总结了一份《React 卸载阶段清理生存指南》:

  1. 永远不要信任 useEffect 的清理函数会自动清理一切:
    useEffect 的清理函数(return () => ...)是副作用。它不会自动清理 useRef,也不会自动清理渲染过程中的副作用。你必须手动管理。

  2. Ref 清理:
    在清理函数里,显式地将 ref.current 设为 null。虽然 React 内部会处理,但这是防御性编程。它确保了即使有闭包残留,也不会访问到已删除的 DOM。

  3. 定时器清理:
    任何 setTimeoutsetIntervalrequestAnimationFrame,一定要在清理函数里用 clearTimeoutclearIntervalcancelAnimationFrame 清理。一定要保存 ID 引用。

  4. 事件监听器清理:
    任何 addEventListener,一定要在清理函数里用 removeEventListener 移除。注意: 移除时必须传入完全相同的回调函数引用(通常是在 useEffect 里定义的函数)。

  5. 网络请求清理:
    使用 AbortController 来取消正在进行的请求。不要让已卸载的组件继续占用网络带宽。

  6. 订阅清理:
    如果你在 useEffect 里订阅了外部服务(比如 WebSocket、Pub/Sub),一定要在清理函数里取消订阅。

  7. 闭包陷阱:
    如果你需要在定时器或回调里访问最新的状态,不要依赖闭包。使用 useRef 来保存最新的状态引用,或者在清理函数里检查组件是否已卸载(虽然 React 不推荐在 setState 之前做这个检查,但在副作用中可以)。

    // 技巧:使用 ref 来保存最新的函数
    const latestCountRef = React.useRef(0);
    latestCountRef.current = count;
    
    const id = setInterval(() => {
      console.log("最新的 count:", latestCountRef.current);
    }, 1000);

第八部分:总结 —— 永远的“再见”

React 的卸载阶段,就像是一场精心编排的告别仪式。

在这个仪式上,React 是那个尽职尽责的主持人。它会把组件从舞台(虚拟 DOM)上请下来,然后走到后台(真实 DOM),把那个具体的 <div><span> 从 HTML 文档里剪掉,扔进垃圾桶(内存回收)。

在这个过程中,ref 是那个一直握着舞台灯光开关的孩子。虽然舞台(DOM)已经没了,但如果你不松手,那个灯光开关(引用)还留在你的手里。

useEffect 的清理函数,则是你的告别辞。你必须大声告诉所有的副作用:“我要走了,你们也都给我停下来!不要再发短信了,不要再打电话了,不要再监听我的窗口滚动了!”

如果你不这么做,React 就会变成一个悲剧作家。它会把你的代码变成一堆“幽灵代码”——那些依然在后台运行的定时器,那些依然在监听窗口点击的监听器,它们在内存的深渊里回荡,等待着永远不会到来的更新。

所以,兄弟们,下一次当你写 useEffect 的时候,别忘了在 return 里面加一行代码。别让你的组件死得不明不白,别让你的 DOM 引用变成幽灵。

记住:干净利落地卸载,才是 React 开发者的最高修养。

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

发表回复

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