在 React 组件中,如果你使用了 setInterval、setTimeout、addEventListener 等浏览器 API,但未在组件卸载时正确清理,就会导致内存泄漏——已卸载的组件仍占据内存,其回调函数仍可能尝试更新状态,进而引发 React 警告或未知错误。
这类问题在单页应用中尤其常见,因为页面不刷新,组件频繁挂载/卸载,未清理的定时器会不断累积。
问题本质
- 定时器:即使组件已销毁,
setInterval依然周期性执行回调,回调中可能引用已卸载组件的状态或 Props,导致无法被垃圾回收。 - 全局事件监听:
window、document等全局对象上的事件监听器(如scroll、resize、keydown)会持有组件内部函数的引用,阻止组件内存释放。
典型案例
1. 未清理的 setInterval
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
setInterval(() => {
setSeconds(s => s + 1); // 每秒更新一次
}, 1000);
// 注意:这里没有返回清理函数!
}, []);
return <div>已过 {seconds} 秒</div>;
}
问题:当 Timer 组件卸载后,setInterval 仍在运行,且不断尝试调用 setSeconds,React 会在控制台警告“Can't perform a React state update on an unmounted component”。更严重的是,这个定时器永远无法被清除,成了真正的内存泄漏。
2. 未解绑的事件监听
function ScrollMonitor() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll);
// 没有在清理函数中 removeEventListener
}, []);
return <div>当前滚动位置:{scrollY}px</div>;
}
当 ScrollMonitor 组件被移除(如路由跳转)后,handleScroll 依然绑定在 window 上,它引用的 setScrollY 和组件闭包都不会释放。
正确做法:在 useEffect 清理函数中释放资源
React 的 useEffect 允许返回一个清理函数,它会在组件卸载前或下次 effect 执行前调用。这是处理任何需要手动释放的资源的标准位置。
修复 setInterval 泄露
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(id); // 组件卸载时清除定时器
}, []);
return <div>已过 {seconds} 秒</div>;
}
修复事件监听泄露
function ScrollMonitor() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <div>当前滚动位置:{scrollY}px</div>;
}
进阶场景与陷阱
1. 依赖变化的定时器
如果你的定时器依赖于某些变化的 Props 或 State,需要谨慎处理。可以使用 useRef 保存最新值,避免频繁重建定时器:
function DelayedAlert({ message }) {
const messageRef = useRef(message);
messageRef.current = message; // 始终指向最新 props
useEffect(() => {
const id = setTimeout(() => {
alert(messageRef.current);
}, 3000);
return () => clearTimeout(id);
}, []); // effect 只执行一次,但通过 ref 拿到最新 message
}
2. 在严格模式下的双重调用
React 18 的开发环境 Strict Mode 会故意两次调用 useEffect 来帮助发现清理问题。如果你的清理逻辑不完备,可能会导致定时器被意外清除或双倍注册。务必确保 effect 和清理函数是“对称的”:每次 effect 执行前,React 都会先调用上一次的清理函数。因此,上面的代码即便在 Strict Mode 下也工作良好,因为第二次调用时会先清除第一次创建的定时器。
3. 避免在 setInterval 中直接使用闭包变量
如果直接在 setInterval 回调里使用外部 state/props,可能会捕获旧值。推荐使用函数式更新(如 setSeconds(s => s + 1))或 useRef,来避免闭包陷阱。
实际开发中的自查清单
- [ ] 每个
setTimeout/setInterval是否有对应的clearTimeout/clearInterval在清理函数中? - [ ] 每个
addEventListener是否有对应的removeEventListener? - [ ] 是否在
useEffect依赖数组正确设置了依赖项(以便重新绑定/解绑)? - [ ] 是否使用了
useRef来避免不必要的重绑定? - [ ] 如果是全局监听(如 websocket、observer),是否在组件卸载时断开连接?
总结
定时器和事件监听是 React 内存泄漏的重灾区。记住一条铁律:每个 useEffect 中申请的外部资源,都必须在返回的清理函数中释放。这不仅避免了内存泄漏,也防止了卸载后更新状态的报错。养成“谁订阅,谁取消”的习惯,会让你的 React 应用更健壮。