React项目跑久了越来越卡,控制台频繁弹出setState警告,这往往意味着组件卸载后仍有一部分逻辑在后台运行——典型的内存泄漏。造成泄漏的元凶通常不是什么高深的技术,而是最基础的两样东西:没清理的定时器和没取消的事件监听。这篇文章把这两类问题的成因、正确写法和排查手段一次讲清楚。

为什么定时器和事件监听会导致内存泄漏
先理解泄漏的根源。JavaScript的垃圾回收机制基于可达性:只要一个对象还能被引用到,它就不会被回收。当你在一个组件里调用setInterval或者对window执行addEventListener时,浏览器会把这个回调函数挂到全局环境中。即使组件本身已经卸载、DOM节点已经销毁,这个回调依然被定时器模块或事件系统持有引用,成为一条从全局根对象出发的可达链。
后果比想象中严重。回调函数通过闭包捕获了组件内部的state、props甚至整个组件实例,导致这一大片内存都无法释放。更麻烦的是行为层面的问题:定时器到了点会继续执行,如果回调里调用了setState,React在旧版本中会直接报警告,提示你在已卸载的组件上更新状态;在事件监听的场景下,滚动、resize这类高频事件会让泄漏的回调被反复触发,白白消耗CPU,页面自然越用越卡。
举个直观的例子:一个轮播图组件每3秒切换一次,用户在列表页来回切换十几次,页面上就会残留十几个还在跳动的定时器,它们同时操作早已不存在的DOM。这就是为什么有些页面开着开着内存占用就飙上去了。
useEffect的正确清理写法
React为这类副作用提供了标准的出口:useEffect的清理函数。返回的函数会在组件卸载时执行,也可以在下一次effect重新执行前执行。先看定时器的错误示范和正确写法对比。
// 错误写法:卸载后定时器还在跑
useEffect(() => {
setInterval(() => {
setCount(c => c + 1);
}, 1000);
}, []);
// 正确写法:保存id,清理函数中清除
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);两个细节容易被忽略。第一,setInterval和setTimeout返回的是id而不是对象,必须把这个id存下来才能清理,直接写return () => clearInterval()什么都不传是无效的。第二,如果一个effect里创建了多个定时器,要逐个清理,或者用数组统一管理:
useEffect(() => {
const timers = [];
timers.push(setTimeout(() => doSomething(), 1000));
timers.push(setTimeout(() => doOther(), 3000));
return () => timers.forEach(clearTimeout);
}, []);事件监听的清理遵循同样的思路。注意removeEventListener必须传入与注册时同一个函数引用,如果写成两个匿名函数,移除会静默失败:
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// 清理时传入同一个handleResize引用
return () => window.removeEventListener('resize', handleResize);
}, []);此外还要留意自定义事件的场景,比如EventBus、WebSocket的消息订阅、第三方库的回调注册。凡是形如on/off、subscribe/unsubscribe的成对API,都必须在清理函数里补上解除的那一半,漏掉任何一个都会造成泄漏。
异步请求与AbortController:容易被忽视的泄漏点
除了定时器和事件监听,异步请求是第三大泄漏源。组件卸载后请求才返回,回调里继续setState,虽然新版React不再报警告,但组件实例依然被闭包持有,内存无法释放。标准解法是使用AbortController:
useEffect(() => {
const controller = new AbortController();
fetch('/api/user/list', { signal: controller.signal })
.then(res => res.json())
.then(data => setList(data))
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, []);调用abort()后,未完成的请求会被真正取消,浏览器释放连接资源,后续的then回调也不会执行。如果用的是axios,它从v0.22开始同样支持传入signal;旧版本则可以用自己提供的CancelToken。对于无法取消的操作,至少可以设置一个标志位,在回调里判断组件是否还存活:
useEffect(() => {
let cancelled = false;
loadData().then(data => {
if (!cancelled) setData(data);
});
return () => { cancelled = true; };
}, []);这种标志位方案不能减少网络开销,但能阻止对已卸载组件的状态更新,避免闭包持有大块数据。综合来看,AbortController是更彻底的方案,应作为首选。
如何用Chrome DevTools定位内存泄漏
写代码时防患于未然固然好,但存量项目里往往已经埋着不少雷,这时就需要工具来定位。打开Chrome DevTools的Memory面板,最常用的是Heap Snapshot(堆快照)对比法:先在操作前拍一次快照,然后反复执行疑似泄漏的操作(比如进出某个页面二十次),再拍第二次快照,对比两份快照中对象数量的增量。
排查时重点关注几个指标。看Detached节点(已脱离文档树但仍被引用的DOM元素),如果数量随操作次数线性增长,基本可以确定存在DOM泄漏;看Retainers(保留链)面板,能直接看到是谁在引用这块内存,顺着链条往往就能找到那个没清理的定时器或监听器。Performance面板的Monitors视图也很有用,开启JS Heap Size实时曲线后,反复操作页面,正常情况下内存应该呈锯齿状回落,如果只涨不降,泄漏就坐实了。
日常开发中建议养成几个习惯:所有副作用都收敛到useEffect中并写好清理函数;启用ESLint的react-hooks插件,它能在编译期提示缺少清理的effect;code review时重点检查addEventListener、setInterval、订阅类API这三类调用是否成对出现。把这些规范落实到团队流程里,内存泄漏问题基本可以从源头杜绝。