useEffect cleanup 在组件卸载时执行
理解 useEffect 的清理机制:组件卸载时自动执行
在 React 中,useEffect 是处理副作用的核心 Hook。很多时候,我们的副作用需要在组件从 DOM 中移除时进行“清理”,比如取消网络请求、清除定时器或移除事件监听器。这就是 useEffect 清理函数(cleanup) 的作用。
当依赖项变化导致组件重新渲染时,或当组件卸载时,React 会执行上一次 effect 返回的清理函数。本文将聚焦于组件卸载这一关键场景,帮助你彻底掌握 cleanup 的执行时机与最佳实践。
1. 什么是 useEffect 的清理函数?
清理函数是你在 useEffect 回调函数内部 return 的一个函数。它并不是立即执行,而是由 React 在合适的时机调用:
- 当组件卸载时
- 在下一次 effect 执行之前(如果依赖项变化导致 effect 重新运行)
useEffect(() => {
// 副作用逻辑:例如订阅事件
const handleResize = () => { /* ... */ };
window.addEventListener('resize', handleResize);
// 返回清理函数
return () => {
window.removeEventListener('resize', handleResize);
console.log('清理:移除 resize 监听器');
};
}, []); // 空依赖数组 => 只在挂载时运行 effect,卸载时执行清理
2. 组件卸载时清理的执行流程
当 React 决定将一个组件从 UI 中移除时(条件渲染、路由切换等原因),它会执行以下与副作用相关的步骤:
- 调用组件上一次渲染中
useEffect返回的清理函数。 - 组件对应的 DOM 节点被移除。
重要提示:清理函数会在组件卸载时保持闭包特性,它可以安全地访问该 effect 定义时的 props 和 state。
示例:定时器清理
import { useState, useEffect } from 'react';
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// 返回清理函数,用于组件卸载时清除定时器
return () => {
clearInterval(intervalId);
console.log('清除定时器,count 的最终值为:', count);
};
}, []); // 空依赖,组件卸载时才清理
return <p>计数:{count}</p>;
}
// 在父组件中条件渲染 Timer:
// {showTimer && <Timer />}
// 当 showTimer 变为 false 时,Timer 卸载,清理函数执行
如果不在卸载时清除定时器,即使组件消失,setInterval 仍会继续运行,不仅可能造成内存泄漏,还会尝试更新已卸载组件的状态(React 开发模式下会有警告)。
3. 清理函数与依赖项的关系
许多初学者会误解:是否只有空依赖数组 [] 才能保证清理在卸载时执行?不是的。 无论依赖数组是什么,只要组件卸载,该 effect 最后一次运行的清理函数必定被执行。
但依赖项会影响清理的版本(闭包中的值):
- 如果依赖数组包含某些变量,每次这些变量变化时,会先运行上一个 effect 的清理函数,然后运行新的 effect。
- 当组件最终卸载时,它会运行当次渲染对应的那个 effect 所返回的清理函数。
useEffect(() => {
const id = setTimeout(() => {
console.log('延迟操作,当前的 userId:', userId);
}, 1000);
return () => {
clearTimeout(id);
console.log('清理 timeout,此时的 userId:', userId);
};
}, [userId]); // userId 变化时,先清理前一个 timeout,再设置新的
在上面的代码中,组件卸载时会执行 clearTimeout,并打印当时闭包中的 userId。即使 userId 早已改变,卸载时执行的清理函数依旧是最后一次 effect 的闭包版本。
4. 常见需要清理的副作用场景
| 场景 | 需要清理的内容 |
|---|---|
| 订阅数据源 | 取消订阅 (如 WebSocket, EventSource) |
| 定时器 | clearTimeout / clearInterval |
| 事件监听器 | removeEventListener |
| 手动修改 DOM | 还原 DOM 状态(如移除插入的节点) |
| 全局变量/第三方库 | 还原全局设置,销毁实例 |
实例:WebSocket 连接清理
useEffect(() => {
const socket = new WebSocket('wss://example.com/chat');
socket.onmessage = (event) => {
// 处理消息
};
return () => {
socket.close();
console.log('WebSocket 连接已关闭');
};
}, []);
5. 常见陷阱与注意事项
❌ 忘记返回清理函数
useEffect(() => {
const interval = setInterval(() => { ... }, 1000);
// 没有 return 清理函数 → 组件卸载后定时器仍在运行
}, []);
❌ 在清理函数中引用过时的状态
useEffect(() => {
const handleClick = () => {
console.log('点击时 count:', count);
};
document.addEventListener('click', handleClick);
return () => {
// 清理时移除的是同一个 handleClick 引用,不用担心
document.removeEventListener('click', handleClick);
};
}, []); // 空依赖导致 count 永远输出初始值
上方代码中,由于依赖数组为空,handleClick 闭包中的 count 会永远引用初始值。如果你需要最新的 count,要么将 count 加入依赖数组,要么使用 useRef 或函数式更新。但清理函数本身的引用是稳定的,能够正确移除监听器。
✅ 使用函数式更新避免依赖过时状态
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1); // 不依赖外部 count
}, 1000);
return () => clearInterval(interval);
}, []); // 空依赖安全
6. 开发环境下的双重调用与清理
在 React 18 的严格模式(<StrictMode>)中,开发环境下组件会被故意挂载→卸载→再次挂载,以帮助提前暴露清理问题。因此你可能会观察到 effect 运行两次,清理函数执行一次。这属于正常现象,只要你的清理逻辑正确,应用在生产环境中不会有问题。
总结
useEffect的清理函数在组件卸载时一定会执行,用于释放资源。- 清理函数在组件卸载时拥有对应 effect 闭包内的变量快照,设计时需要留意依赖项。
- 无论依赖数组是否为空,只要组件卸载,最后一次 effect 的清理函数就会被调用。
- 保证每个产生持久化副作用的 effect 都返回对应的清理函数,是避免内存泄漏的关键习惯。
掌握清理函数的规律,你就能写出稳定、可维护的 React 组件。