页面开久了会越来越卡,任务管理器里内存曲线一路向上不回头。这种问题很难在开发阶段发现------本地刷新几次根本看不出来,非要挂在后台跑上几十分钟才会暴露。
我用Chrome DevTools的Memory面板做了几轮heap snapshot对比,最后揪出3个罪魁祸首,全部是同一个模式:注册了监听,但没有对应的清理函数。
怎么发现的:heap snapshot对比
打开DevTools → Memory → 选Heap snapshot,在页面正常操作几轮后拍一张,触发几次导致怀疑的操作(比如打开关闭一个弹窗、切换几次tab)后再拍一张,用Comparison视图看两次snapshot之间New(新增未释放)的对象。
如果同一类对象(比如Detached HTMLDivElement、某个自定义类的实例)持续增长且不回落,基本可以确定是内存泄漏。我这次看到的是(closure)和Detached节点数量随着"打开弹窗→关闭弹窗"的操作次数线性增长------每次关闭理论上应该清零,但没有。
罪魁祸首1:window上的事件监听没有移除
tsx
// ❌ 加了监听,忘了摘
function ResizablePanel() {
const [width, setWidth] = useState(300);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth * 0.3);
};
window.addEventListener('resize', handleResize);
// 没有return清理函数!
}, []);
return <div style={{ width }}>面板内容</div>;
}
这个组件卸载后,handleResize这个函数依然被window持有引用。而这个函数的闭包里又引用了setWidth------间接引用了组件实例。只要window还活着(它当然一直活着),这个函数、这个闭包、间接关联的组件相关对象,全部无法被垃圾回收。
如果这个组件会被频繁挂载/卸载(比如一个可以打开关闭的侧边栏面板),每次挂载都新增一个resize监听,卸载时不清理------泄漏会随着用户操作次数线性累积。
tsx
// ✅ useEffect返回清理函数
function ResizablePanel() {
const [width, setWidth] = useState(300);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth * 0.3);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div style={{ width }}>面板内容</div>;
}
原则:任何addEventListener都必须有对应的removeEventListener,写在useEffect的清理函数里。这条规则没有例外。
罪魁祸首2:WebSocket/订阅类连接没有关闭
tsx
// ❌ 建立连接,没有断开
function LiveChart({ symbol }) {
const [price, setPrice] = useState(0);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ticker/${symbol}`);
ws.onmessage = (event) => {
setPrice(JSON.parse(event.data).price);
};
// ws没有在任何地方close!
}, [symbol]);
return <div>{symbol}: {price}</div>;
}
这个问题比普通事件监听更隐蔽,也更严重。symbol每变化一次,useEffect重新执行一次,就创建一个新的WebSocket连接------旧的连接因为没有close(),永远不会断开。
用户在不同股票/币种之间切换10次,本地就有10个活跃的WebSocket连接同时在后台跑,持续接收数据、持续触发onmessage回调、持续调用已经"该死但没死"的组件相关闭包。这不只是内存泄漏,还是真实的网络资源浪费和不必要的服务器压力。
tsx
// ✅ 依赖变化或组件卸载时关闭连接
function LiveChart({ symbol }) {
const [price, setPrice] = useState(0);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ticker/${symbol}`);
ws.onmessage = (event) => {
setPrice(JSON.parse(event.data).price);
};
return () => {
ws.close();
};
}, [symbol]);
return <div>{symbol}: {price}</div>;
}
同样的模式适用于EventSource(SSE)、第三方库返回的subscribe()函数(比如RxJS的Observable、Firebase的onSnapshot)------只要是"注册一个持续接收数据的通道",就必须有"注销这个通道"的对应代码。
原则:WebSocket用close(),EventSource用close(),任何subscribe返回的unsubscribe函数必须调用。这类资源不会因为组件卸载而自动断开,JS引擎不知道你的React组件生命周期。
罪魁祸首3:第三方库实例没有调用destroy/dispose
tsx
// ❌ 创建了图表实例,卸载时没有销毁
function SalesChart({ data }) {
const chartRef = useRef(null);
useEffect(() => {
const chart = echarts.init(chartRef.current);
chart.setOption({
series: [{ type: 'line', data }],
});
// chart实例没有dispose!
}, [data]);
return <div ref={chartRef} style={{ height: 400 }} />;
}
这是我这次排查中泄漏量最大的一个。ECharts、Chart.js、地图库(Leaflet、AMap)这类可视化库,通常会在内部维护自己的Canvas上下文、事件系统、内部状态树------这些都是独立于React生命周期之外的对象。
组件卸载后,DOM节点被React移除了,但echarts.init返回的这个chart实例依然存活在内存里,它内部持有的Canvas、离屏渲染缓存、绑定在DOM节点上的事件监听全部不会自动释放。这类实例通常体积比普通JS对象大得多------一个图表实例可能就是几MB,10个页面来回切换,内存就能涨出几十MB。
tsx
// ✅ 卸载时调用库提供的销毁方法
function SalesChart({ data }) {
const chartRef = useRef(null);
useEffect(() => {
const chart = echarts.init(chartRef.current);
chart.setOption({
series: [{ type: 'line', data }],
});
return () => {
chart.dispose();
};
}, [data]);
return <div ref={chartRef} style={{ height: 400 }} />;
}
原则:任何"由第三方库的init/create方法返回的实例",先去查文档有没有destroy/dispose/unmount方法。有就必须在清理函数里调用------这是库作者主动告诉你"这个实例需要手动释放"的信号。
排查思路总结
这3个罪魁祸首的共同模式其实只有一句话:任何"注册"操作,都要找到它对应的"注销"操作。
| 注册操作 | 对应的注销操作 |
|---|---|
addEventListener |
removeEventListener |
new WebSocket() |
.close() |
new EventSource() |
.close() |
观察者.subscribe() |
调用返回的unsubscribe函数 |
第三方库.init() |
查文档的destroy/dispose |
setTimeout/setInterval |
clearTimeout/clearInterval |
排查内存泄漏最有效的方法不是死磕代码逐行看,而是:
- 打开Memory面板,操作前后各拍一次heap snapshot
- 用Comparison视图找持续增长且不回落的对象类型
- 顺着对象类型反查是哪个组件在创建它
- 检查这个组件的
useEffect里,创建操作是否配了对应的清理函数
内存泄漏不会让你的项目立刻崩溃
这也是为什么它比逻辑bug更容易被忽视------它不会让测试用例失败,不会在Code Review里显眼地报错,只会让线上跑得越久的页面越卡,直到某个用户反馈"打开一整天卡得不行",你才会想起来查。
useEffect返回清理函数这个机制,React团队从一开始就设计好了。真正的问题从来不是"不知道怎么清理",而是写代码的时候没有意识到"这里需要清理"。
你的项目里,有没有一个useEffect创建了什么东西,却从来没写过对应的清理函数?