页面开久了会越来越卡,任务管理器里内存曲线一路向上不回头。这种问题很难在开发阶段发现——本地刷新几次根本看不出来,非要挂在后台跑上几十分钟才会暴露。
我用 Chrome DevTools 的 Memory 面板做了几轮 heap snapshot 对比,最后揪出 3 个罪魁祸首,全部是同一个模式:注册了监听,但没有对应的清理函数。
打开 DevTools → Memory → 选 Heap snapshot,在页面正常操作几轮后拍一张,触发几次导致怀疑的操作(比如打开关闭一个弹窗、切换几次 tab)后再拍一张,用 Comparison 视图看两次 snapshot 之间 New(新增未释放)的对象。
如果同一类对象(比如Detached HTMLDivElement、某个自定义类的实例)持续增长且不回落,基本可以确定是内存泄漏。我这次看到的是(closure)和Detached节点数量随着"打开弹窗→关闭弹窗"的操作次数线性增长——每次关闭理论上应该清零,但没有。
// ❌ 加了监听,忘了摘
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监听,卸载时不清理——泄漏会随着用户操作次数线性累积。
// ✅ 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的清理函数里。这条规则没有例外。
// ❌ 建立连接,没有断开
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回调、持续调用已经"该死但没死"的组件相关闭包。这不只是内存泄漏,还是真实的网络资源浪费和不必要的服务器压力。
// ✅ 依赖变化或组件卸载时关闭连接
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 组件生命周期。
// ❌ 创建了图表实例,卸载时没有销毁
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。
// ✅ 卸载时调用库提供的销毁方法
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
|
排查内存泄漏最有效的方法不是死磕代码逐行看,而是:
useEffect里,创建操作是否配了对应的清理函数这也是为什么它比逻辑 bug 更容易被忽视——它不会让测试用例失败,不会在 Code Review 里显眼地报错,只会让线上跑得越久的页面越卡,直到某个用户反馈"打开一整天卡得不行",你才会想起来查。
useEffect返回清理函数这个机制,React 团队从一开始就设计好了。真正的问题从来不是"不知道怎么清理",而是写代码的时候没有意识到"这里需要清理"。
你的项目里,有没有一个useEffect创建了什么东西,却从来没写过对应的清理函数?