我排查了一个React内存泄漏——罪魁祸首是这3个被忽略的清理函数

页面开久了会越来越卡,任务管理器里内存曲线一路向上不回头。这种问题很难在开发阶段发现------本地刷新几次根本看不出来,非要挂在后台跑上几十分钟才会暴露。

我用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)------只要是"注册一个持续接收数据的通道",就必须有"注销这个通道"的对应代码。

原则:WebSocketclose()EventSourceclose(),任何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

排查内存泄漏最有效的方法不是死磕代码逐行看,而是:

  1. 打开Memory面板,操作前后各拍一次heap snapshot
  2. 用Comparison视图找持续增长且不回落的对象类型
  3. 顺着对象类型反查是哪个组件在创建它
  4. 检查这个组件的useEffect里,创建操作是否配了对应的清理函数

内存泄漏不会让你的项目立刻崩溃

这也是为什么它比逻辑bug更容易被忽视------它不会让测试用例失败,不会在Code Review里显眼地报错,只会让线上跑得越久的页面越卡,直到某个用户反馈"打开一整天卡得不行",你才会想起来查。

useEffect返回清理函数这个机制,React团队从一开始就设计好了。真正的问题从来不是"不知道怎么清理",而是写代码的时候没有意识到"这里需要清理"

你的项目里,有没有一个useEffect创建了什么东西,却从来没写过对应的清理函数?

相关推荐
IT_陈寒1 小时前
我又被JavaScript的隐式类型转换坑了
前端·人工智能·后端
其美杰布-富贵-李1 小时前
03 ref、reactive 与 computed 响应式数据
前端·javascript·vue.js
OpenTiny社区1 小时前
GenUI SDK v1.3.0 发布|多框架兼容,一键换物料,渲染器 & 演练场全面增强!
前端·ai编程
用户938515635071 小时前
useRef + Web Worker 实战:React 如何优雅地拥抱多线程
前端·javascript·react.js
嘟嘟07172 小时前
useRef + Web Worker:React 中的耗时计算不卡页面
javascript·vue.js
mONESY2 小时前
React Router v7 完全实战指南:从入门到鉴权,一套代码全搞定
javascript
他们叫我秃子2 小时前
前端开发转 Go 全栈(五):终于遇到熟人了,Go 的闭包和高阶函数原来这么像 JavaScript
前端·后端·go
用户61595868000222 小时前
从 0 写一个迷你 wujie:用原生 Web API 实现微前端
前端
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十七):AI时代对组件封装的新理解、agInput 统一入口,动态表单预备
前端·后端·go