欢迎来到“React 代码病房”的第一堂课。我是你们的住院医师,今天我们要讨论的不是癌症,而是比癌症更令人头疼、更隐蔽、更让人在深夜里抱着电脑崩溃的东西——内存泄漏。
很多人以为 React 的垃圾回收机制是万能的。天真!太天真了。React 的垃圾回收就像是收废品的大爷,他只负责捡你扔掉的东西。但如果你把一个定时器、一个事件监听器或者一个 WebSocket 连接留在了房间里,那个大爷是不会进去关灯的。它们会像僵尸一样,在你组件已经“死”了之后,依然在后台疯狂地消耗着你的 CPU 和内存。
今天,我们要学的是如何给这些僵尸“下葬”。
第一部分:闭包的幽灵
首先,我们要理解一个核心概念:闭包。
在 React 中,闭包无处不在。当你定义一个函数并在 useEffect 里使用它,或者把一个函数作为 props 传给子组件时,你就创造了一个闭包。
想象一下,你的组件是一个公寓楼(Class Component 或者就是一个函数组件实例)。你住在这个公寓里,你有一些私人的家具(state),你有一个窗户(DOM)。
// 例子:一个简单的计数器
const MyComponent = () => {
const [count, setCount] = React.useState(0);
const handleClick = () => {
console.log(`你点击了 ${count} 次`); // 这里就是一个闭包
setCount(count + 1);
};
return <button onClick={handleClick}>点击我 ({count})</button>;
};
当你点击按钮时,handleClick 函数被调用了。它“记得”了当时的 count 是多少。这很好,很方便。
但是,如果这个组件被卸载了,handleClick 这个函数还在吗?
如果它还在,并且它被挂载到了某个地方(比如 window 上的某个事件监听器),那么这个函数就会带着它对旧 count 的引用,像一个幽灵一样飘在内存里。
最可怕的是什么?最可怕的是,这个幽灵函数可能还会试图去调用 setCount。
第二部分:经典案例——Window 事件监听器
这是新手最容易犯的错误,也是导致“僵尸组件”最多的源头。
错误示范:忘了关灯
让我们看一个典型的错误代码。这代码看起来完美无缺,对吧?
useEffect(() => {
const handleResize = () => {
// 假设我们要根据窗口大小更新状态
console.log("窗口大小变了");
setWidth(window.innerWidth);
};
// 1. 注册监听器
window.addEventListener('resize', handleResize);
}, []); // 空依赖数组,意味着这个 effect 只在组件挂载时运行一次
// 2. 组件卸载...
发生了什么?
组件挂载了,监听器被加上了。用户调整了浏览器窗口大小。handleResize 被触发,调用了 setWidth。一切正常。
然后,用户离开了这个页面(组件卸载了)。
但是! window.removeEventListener('resize', handleResize) 这行代码在哪里?它不存在!
现在,handleResize 这个闭包函数依然紧紧抓着 window 对象不放。哪怕你的组件已经被 React 从 DOM 树里拔出来了,被从内存里回收了,handleResize 依然在监听整个浏览器窗口的每一次 resize 事件。
每次 resize,它就会尝试执行 setWidth。而 MyComponent 已经不在了,setWidth 试图访问一个不存在的组件实例。React 会抛出一个警告:“Can’t perform a React state update on an unmounted component.”
后果:
- 控制台报错:满屏的红色警告,看着心烦意乱。
- 内存泄漏:那个闭包函数
handleResize一直占着内存,因为它引用了window。 - 性能下降:虽然只是个 resize,但如果你的
handleResize里做了复杂的计算,或者触发了多次setState,这会拖慢整个浏览器的渲染。
正确示范:必须关灯
React 为我们提供了一个“退房协议”,那就是 useEffect 的返回函数。
useEffect(() => {
const handleResize = () => {
console.log("窗口大小变了");
setWidth(window.innerWidth);
};
// 1. 注册监听器
window.addEventListener('resize', handleResize);
// 2. 返回一个清理函数(退房协议)
return () => {
console.log("组件要走了,我要关掉监听器了");
window.removeEventListener('resize', handleResize);
};
}, []);
原理:
React 会在组件卸载之前,或者在依赖项变化导致 effect 重新运行之前,调用这个返回的函数。
所以,当组件卸载时,你的清理函数会执行,把灯关掉。监听器被移除,闭包函数被回收。世界清静了。
第三部分:定时器——最顽固的僵尸
如果说事件监听器是鬼,那定时器就是那种怎么赶都赶不走的流氓。
场景:一个倒计时
useEffect(() => {
let timerId = null;
const startTimer = () => {
console.log("开始倒计时");
timerId = setInterval(() => {
console.log("Tick...");
// 假设我们在更新一些状态
}, 1000);
};
startTimer();
return () => {
console.log("清理定时器");
if (timerId) {
clearInterval(timerId);
timerId = null;
}
};
}, []);
这里有一个细节,很多老手都会犯迷糊:一定要把 timerId 保存到变量里。
如果你在 setInterval 里面又调用了 startTimer,或者在清理函数里重新定义了 timerId,清理函数可能就找不到那个定时器了。
记住:闭包会记住变量,但变量名变了,闭包找不到旧变量了。
第四部分:进阶陷阱——未闭合的闭包
这是本文的精华。有时候,你写了清理函数,你以为你赢了,但闭包依然在作祟。
案例:异步数据获取
假设我们要获取用户数据。
const UserProfile = ({ userId }) => {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
useEffect(() => {
let isMounted = true; // 一个标志位,用来判断组件是否还活着
const fetchUser = async () => {
try {
const response = await fetch(`/api/user/${userId}`);
const data = await response.json();
// 关键点在这里!
if (isMounted) {
setUser(data);
setLoading(false);
}
} catch (error) {
console.error(error);
}
};
fetchUser();
return () => {
// 组件卸载时清理
isMounted = false;
};
}, [userId]);
if (loading) return <div>加载中...</div>;
return <div>用户名: {user.name}</div>;
};
这看起来很完美,对吧?isMounted 标志位防止了在组件卸载后更新状态。
但是,如果我们不使用 isMounted,或者逻辑稍微复杂一点,就会出问题。
问题场景:
const BadComponent = () => {
const [count, setCount] = React.useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
// 这里的闭包捕获了初始的 count = 0
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(intervalId);
}, []);
return <div>Count: {count}</div>;
};
等等,这看起来是对的啊!setInterval 每秒加 1,组件卸载时清除它。这没问题。
让我们加个更棘手的。闭包陷阱。
假设我们想在 useEffect 里访问最新的 count,但我们写错了。
const TrickyComponent = () => {
const [count, setCount] = React.useState(0);
useEffect(() => {
// 危险!这个函数捕获了 count,但不是最新的!
const increment = () => {
console.log("闭包里的 count:", count); // 永远是 0
setCount(count + 1); // 试图把 0 变成 1
};
// 每次渲染都重新创建这个函数,但闭包还是旧的 count
const timerId = setInterval(increment, 1000);
return () => clearInterval(timerId);
}, []); // 依赖数组是空的
return <div>Count: {count}</div>;
};
诊断:
组件挂载,count 是 0。increment 函数被创建,它闭包捕获了 count=0。
1秒后,increment 运行,打印 0,尝试 setCount(1)。
2秒后,increment 运行,打印 0,尝试 setCount(1)。
虽然 UI 显示的 count 会变成 1(因为 React 会合并状态更新),但闭包里的变量永远是 0。这看起来像是 bug,但实际上并没有内存泄漏。
真正的内存泄漏场景:
const GhostComponent = () => {
const [data, setData] = React.useState(null);
useEffect(() => {
let isCancelled = false;
const loadData = async () => {
const result = await fetch('/api/data');
const json = await result.json();
// 如果组件卸载了,千万不要更新状态!
if (isCancelled) return;
setData(json);
};
loadData();
return () => {
isCancelled = true; // 标记为已取消
};
}, []);
return <div>Data: {JSON.stringify(data)}</div>;
};
如果我们在 loadData 里没有检查 isCancelled,当组件卸载时,setData 依然会被调用。虽然 React 会在底层处理“unmounted”警告,阻止状态更新,但那个 json 对象、那个 fetch 请求的结果,依然在内存里被保留着,直到垃圾回收器(GC)最终把它们捡走。
更糟糕的是,如果你在 setData 后面还有副作用(比如保存到 localStorage),那些副作用也会执行,导致逻辑错误。
第五部分:第三方库的“绑架”
很多时候,内存泄漏不是你写的,是你用的。
案例:图表库
假设你用了一个很流行的图表库,比如 Chart.js。
const ChartView = () => {
const canvasRef = React.useRef(null);
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
const myChart = new Chart(ctx, {
type: 'line',
data: { /* ... */ }
});
// 错误!忘记销毁图表实例
// myChart.destroy();
}
}, []);
return <canvas ref={canvasRef} />;
};
myChart 这个实例对象被创建出来了。它不仅包含数据,还可能包含对 DOM 节点、动画循环、事件监听器的引用。当你切换页面或卸载组件时,myChart 依然存在。如果你频繁切换这个组件,内存就会像气球一样膨胀起来。
正确做法:
const ChartView = () => {
const canvasRef = React.useRef(null);
const chartInstanceRef = React.useRef(null);
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
chartInstanceRef.current = new Chart(ctx, { /* ... */ });
}
return () => {
// 销毁图表,释放资源
if (chartInstanceRef.current) {
chartInstanceRef.current.destroy();
chartInstanceRef.current = null;
}
};
}, []);
return <canvas ref={canvasRef} />;
};
第六部分:如何像侦探一样诊断内存泄漏
光说不练假把式。既然我们讲了这么多“幽灵”,怎么把它们抓出来呢?我们需要工具。
工具一:Chrome DevTools
- 打开 Chrome,进入你的 React 应用。
- 按 F12 打开开发者工具。
- 点击 Performance 标签,或者 Memory 标签。
“Heap Snapshot” (堆快照) 方法:
这是最常用的方法。
- 点击 Memory 面板左上角的 Take Heap Snapshot。
- 你会得到一个快照 1。
- 在应用里做一些操作,比如进入一个页面,然后退出(触发卸载)。
- 等待几秒钟,让垃圾回收器(GC)跑一下。
- 再次点击 Take Heap Snapshot,得到快照 2。
- 对比快照 1 和快照 2。
寻找线索:
在快照 2 的筛选器里,选择 Summary。
往下拉,找到 Detached DOM nodes(断开的 DOM 节点)或者 Anonymous functions(匿名函数)。
如果有一个节点数量在增加,那就是内存泄漏了!
点击那个节点,看它的 __proto__,找到它的构造函数。如果是 Anonymous function,这就说明有一个闭包函数没有被释放。
“Allocation sampling” (内存采样) 方法:
这个方法更适合动态监控。
- 点击 Start allocation sampling。
- 进行一段操作(比如点击按钮、滚动页面)。
- 点击 Stop。
- 你会看到一张图表,显示了内存随时间的变化。
- 如果图表是一条直线向上走,那就是内存泄漏。
第七部分:终极心法——防御性编程
作为一名资深专家,我总结了一些在 React 中避免内存泄漏的“生存法则”。
1. 始终在 useEffect 中返回清理函数
这是铁律。不管你的 effect 是干什么的(fetch、timer、event、subscribes),只要你注册了什么东西,你就必须在清理函数里注销它。
// 基础模板
useEffect(() => {
// 1. Setup (注册/启动)
return () => {
// 2. Cleanup (注销/停止)
};
}, [dependencies]);
2. 不要在闭包中保存可变状态
如果你需要在清理函数里访问某个状态,或者需要确保每次渲染都使用最新的状态,使用 useRef。
const Component = () => {
const [count, setCount] = React.useState(0);
// 使用 ref 来存储最新的值,闭包会捕获这个 ref 的当前值
const countRef = React.useRef(count);
React.useEffect(() => {
countRef.current = count; // 每次渲染都更新 ref
}, [count]);
React.useEffect(() => {
const interval = setInterval(() => {
// 这里每次都会读取到最新的 count
console.log(countRef.current);
}, 1000);
return () => clearInterval(interval);
}, []); // 依赖数组是空的,但因为我们用了 ref,所以不需要把 count 放进去
};
3. AbortController —— Fetch 的救星
对于网络请求,现代浏览器提供了一个非常棒的工具 AbortController。它专门用来取消请求。
const FetchComponent = () => {
const [data, setData] = React.useState(null);
const abortControllerRef = React.useRef(null);
useEffect(() => {
abortControllerRef.current = new AbortController();
const fetchData = async () => {
try {
const response = await fetch('/api/data', {
signal: abortControllerRef.current.signal
});
const json = await response.json();
setData(json);
} catch (error) {
// 如果是被 AbortController 取消的请求,这是正常现象,不要报错
if (error.name !== 'AbortError') {
console.error(error);
}
}
};
fetchData();
// 清理函数
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return <div>{JSON.stringify(data)}</div>;
};
第八部分:总结(不,我们不说总结)
我们聊了很多。从幽灵般的闭包,到顽固的定时器,再到第三方库的陷阱。
记住,React 的 useEffect 就像是一个守夜人。当你进入房间(组件挂载),你点亮蜡烛(注册监听器、启动定时器)。当你离开房间(组件卸载)时,你必须把蜡烛吹灭。
如果你忘了吹灭蜡烛,黑暗(内存泄漏)就会吞噬一切。
不要害怕闭包,闭包是 JavaScript 的核心特性。不要害怕清理函数,它是 React 带给你的安全网。
下一次,当你点击“卸载”按钮时,闭上眼睛,深呼吸。想象你的组件正在走向一个宁静的坟墓,而那个坟墓里,没有未闭合的函数,没有残留的定时器,只有永恒的、干净的、高效的内存。
好了,今天的讲座到此结束。现在,去检查你的代码吧,把那些幽灵赶出去!