垃圾回收的核心逻辑其实很简单:从一组根对象(全局对象、当前调用栈等)出发,凡是能被引用链到达的对象都视为存活,其余的都判定为垃圾并回收。换句话说,对象什么时候被释放,不取决于它是否还在用,而取决于是否还有引用指向它。React组件里的大多数内存问题,本质上都是某段代码在不知不觉中把引用挂在了存活对象上,导致组件卸载之后,它创建的定时器、监听器、闭包、缓存依然被根可达,垃圾回收器自然无法回收。下面我们逐个拆解这些常见模式。

一、useEffect缺少清理函数:最常见的引用泄漏源
这是React应用中最高频的内存问题。在useEffect里订阅了事件、启动了定时器或建立了WebSocket连接,却没有在返回的清理函数中释放,那么这些资源会一直持有组件外部的引用。即使组件卸载,定时器回调里捕获的state和props依然存活在堆中。
// 有问题的写法:组件卸载后 interval 仍在运行
function ChatPanel({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const timer = setInterval(() => {
fetchMessages(roomId).then(setMessages); // 捕获了 setState 和 roomId
}, 1000);
// 缺少 return () => clearInterval(timer)
}, [roomId]);
return <MessageList data={messages} />;
}
修复方式是补上清理函数。React在组件卸载以及依赖变化重新执行副作用之前,都会调用这个清理函数,把资源释放的时机交还给框架管理:
// 正确的写法
useEffect(() => {
const timer = setInterval(() => {
fetchMessages(roomId).then(setMessages);
}, 1000);
return () => clearInterval(timer);
}, [roomId]);
判断一个副作用是否需要清理,有个简单的检查标准:这个副作用是否创建了组件外部会持续存在的资源?定时器、订阅、观察者(如IntersectionObserver、ResizeObserver)、WebSocket、第三方库实例,答案通常是肯定的。而像修改DOM样式这类操作,如果下次渲染会自然覆盖,一般不需要清理。养成在写useEffect时先想清理逻辑的习惯,能规避大部分泄漏。
二、闭包陷阱:被捕获的大对象悄悄存活
JavaScript闭包会把函数体内引用到的所有外部变量保留在内存中。如果一个大数组或大对象被某个长生命周期的回调捕获,即使你只用到了其中一个字段,整个对象也无法被回收。这类问题在事件处理和定时器中尤其隐蔽。
function ReportView({ hugeDataset }) {
useEffect(() => {
const handler = () => {
// 只用到 dataset 长度,却把整个大数组捕获进了闭包
console.log('rows:', hugeDataset.length);
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [hugeDataset]);
// ...
}
上面的代码虽然正确清理了监听器,但在监听器存活期间,hugeDataset即使组件其他部分已经不再需要它,也必须整体留在堆里。改进方式是在依赖中只传入真正需要的值,让闭包捕获一个数字而不是整个数组:
function ReportView({ hugeDataset }) {
const rowCount = hugeDataset.length; // 先提取原始值
useEffect(() => {
const handler = () => console.log('rows:', rowCount);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [rowCount]);
// ...
}
另一个值得警惕的模式是把回调存在可变的模块级容器里,比如用一个对象做事件总线,注册的处理器没有被移除。即便组件已卸载,总线持有处理器引用,处理器又捕获了组件的state,整条引用链就都活着。使用事件总线时务必成对地on/off,或者干脆换成库自带的生命周期管理。
三、缓存与memo化:性能优化的反面代价
useMemo和useCallback是常用的性能优化手段,但它们本质上就是把值存在组件实例上直到依赖变化,这本身就是在主动延长对象的生命周期。如果memo的内容很大、组件实例很多,或者依赖数组写法不当,缓存反而会变成内存负担。
典型错误是把引用频繁变化的对象作为依赖,导致缓存不断重建却始终保留最新一份大对象。还有一种情况是把数据往模块级Map或useRef里塞作为缓存,但从不清理,列表无限增长。以ref缓存为例:
function useSearchCache() {
const cacheRef = useRef(new Map());
const search = (keyword) => {
if (cacheRef.current.has(keyword)) {
return cacheRef.current.get(keyword);
}
const result = expensiveSearch(keyword);
cacheRef.current.set(keyword, result); // 永不淘汰,Map 无限膨胀
return result;
};
return search;
}
修复思路是给缓存加上限和淘汰策略,比如只保留最近N条,或者组件卸载时清空。更复杂的场景可以引入LRU结构:
function useSearchCache(limit = 50) {
const cacheRef = useRef(new Map());
const search = (keyword) => {
if (cacheRef.current.has(keyword)) {
const v = cacheRef.current.get(keyword);
cacheRef.current.delete(keyword);
cacheRef.current.set(keyword, v); // 移到末尾表示最近使用
return v;
}
const result = expensiveSearch(keyword);
cacheRef.current.set(keyword, result);
if (cacheRef.current.size > limit) {
const oldest = cacheRef.current.keys().next().value;
cacheRef.current.delete(oldest); // 淘汰最久未使用的条目
}
return result;
};
return search;
}
对于useMemo,建议遵循一个原则:只为确实引起渲染开销的计算做memo,依赖数组保持最小粒度,memo的值如果体积大,要评估它存活期间的成本。缓存是用内存换时间,这笔账要算清楚。
四、第三方库与DOM引用:卸载后残留的实例
图表库、地图库、富文本编辑器等第三方组件往往在DOM上挂载重量级实例。如果销毁逻辑没有执行,这些实例连同其内部数据会一直占着内存。常见的错误写法是在useEffect里初始化却不销毁,或者把DOM节点存进模块级变量导致节点无法回收。
function Chart({ data }) {
const containerRef = useRef(null);
useEffect(() => {
const chart = new FancyChart(containerRef.current, { data });
// chart 实例销毁逻辑,若缺失则内部监听器与数据全部泄漏
return () => chart.destroy();
}, [data]);
return <div ref={containerRef} style={{ width: 600, height: 400 }} />;
}
另外要注意,把DOM节点存到组件外部的变量(比如模块顶层的变量或window属性)会让整棵相关子树都无法回收,因为从根出发可以经由这个变量到达该节点及其所有子节点。正确做法是让DOM引用的生命周期与组件一致,放在useRef或局部变量里。对于地图SDK、编辑器这类大家伙,还建议配合Chrome DevTools的Memory面板做卸载前后对比:录制组件挂载再卸载后的堆快照,搜索组件相关类名,如果仍能找到大量实例,基本可以断定销毁链路断了。
五、排查手段与工程化建议
定位内存问题的标准流程是:用Performance面板的Memory勾选项做一段时间的录制,观察JS Heap曲线是否只升不降;再用Heap Snapshot对比两个时间点,按Retained Size排序找到占用大且引用链可疑的对象;最后顺着Retainers面板查看是谁在持有它。Detached DOM节点和已卸载组件的Fiber对象出现在快照里,通常就是泄漏的直接证据。
工程实践上有几条经验值得坚持:一是所有订阅类API必须成对出现,创建和销毁写在同一个useEffect内,靠代码评审保证;二是大对象优先按值传递而非整体捕获,必要时先提取需要的字段;三是给缓存类数据结构设置容量上限;四是集成React 18的StrictMode开发模式,它会强制执行两次挂载卸载,把清理不彻底的问题提前暴露出来。内存健康是一个持续性的工程,把引用生命周期当成和UI状态一样需要管理的东西,问题会少很多。