第一章:事件监听与反注册——内存泄漏的头号元凶

说实话,我做了这么多年混合开发,遇到最多的内存泄漏问题,十有八九都跟事件监听有关。你想想看,一个页面打开了,绑了一堆事件,页面关闭了,事件却没解绑。这就像你出门不锁门,小偷迟早会来。

今天咱们就从三个最典型的场景入手:addEventListener 与 removeEventListener观察者模式、还有定时器清理。这些都是我踩过坑的地方,希望能帮你少走弯路。

1.1 addEventListener 与 removeEventListener:成对出现才安全

先看一个最常见的错误写法:

// ❌ 错误示例:只注册不销毁
mounted() {
    window.addEventListener('resize', this.handleResize);
}

beforeDestroy() {
    // 忘记移除!
}

这个代码在页面销毁时,handleResize 方法仍然被全局的 window 对象引用着。只要页面不刷新,这个回调函数和它所在的组件就永远无法被垃圾回收。

我个人习惯的做法是:在注册的同时,就把移除逻辑写好。就像这样:

// ✅ 正确做法:成对出现
mounted() {
    this._onResize = this.handleResize.bind(this);
    window.addEventListener('resize', this._onResize);
}

beforeDestroy() {
    window.removeEventListener('resize', this._onResize);
}
小技巧:我习惯在变量名前加下划线,比如 _onResize,提醒自己这是内部绑定的引用。这样代码审查时一眼就能看出哪些是绑定后的函数。

这里有个坑要注意:removeEventListener 必须传入同一个函数引用。如果你在注册时用了 bind 或者匿名函数,那移除时也得用同一个。否则,你移除的只是一个空壳,真正的监听还在那里。

我曾经踩过的坑:有一次在 React 的 useEffect 里写了 window.addEventListener('scroll', () => {}),然后在 cleanup 里又写了一遍一模一样的匿名函数。结果呢?两个不同的函数引用,一个都没移除掉。页面切了十几次,内存直接飙到 200MB+。

1.2 观察者模式泄漏:订阅了就要退订

观察者模式在混合开发里太常见了。EventBus、Vue 的 $on/$off、RxJS 的 Subject,本质上都是观察者模式。但很多人只记得订阅,忘了退订。

来看一个典型的 Vue EventBus 泄漏:

// ❌ 错误示例:订阅后不退订
created() {
    EventBus.$on('user-login', this.handleLogin);
}

beforeDestroy() {
    // 忘记退订!
}

这个组件虽然销毁了,但 EventBus 仍然持有 handleLogin 的引用。只要 EventBus 还活着,这个组件就永远无法被回收。

正确的做法是:

// ✅ 正确做法:退订
created() {
    EventBus.$on('user-login', this.handleLogin);
}

beforeDestroy() {
    EventBus.$off('user-login', this.handleLogin);
}
核心原则:谁订阅,谁退订。生命周期要对称。在 createdmounted 里订阅,就在 beforeDestroyunmounted 里退订。

如果是 RxJS,道理一样:

// RxJS 示例
const subscription = someObservable.subscribe(data => {
    this.handleData(data);
});

// 销毁时
subscription.unsubscribe();

我个人建议:把所有的订阅统一管理。比如用一个数组收集所有的 subscription,销毁时一次性清理:

// 统一管理订阅
this._subscriptions = [];

this._subscriptions.push(
    EventBus.$on('event1', this.handler1),
    EventBus.$on('event2', this.handler2)
);

// 销毁时
this._subscriptions.forEach(sub => sub());
this._subscriptions = [];

1.3 定时器清理:setInterval 是内存杀手

定时器泄漏是我见过最隐蔽的问题之一。尤其是 setInterval,它会一直执行,直到你手动清除。如果不清除,它引用的回调函数和整个作用域链都会一直存在。

// ❌ 错误示例:定时器不清除
mounted() {
    this.timer = setInterval(() => {
        this.refreshData();
    }, 5000);
}

beforeDestroy() {
    // 忘记清除!
}

这个定时器会一直运行,即使组件已经销毁了。this.refreshData 方法引用了整个组件实例,导致组件无法被回收。

正确的做法:

// ✅ 正确做法:清除定时器
mounted() {
    this._timer = setInterval(() => {
        this.refreshData();
    }, 5000);
}

beforeDestroy() {
    if (this._timer) {
        clearInterval(this._timer);
        this._timer = null;
    }
}
我曾经踩过的坑:有一次在 H5 页面里用 setInterval 做轮询,页面切到后台后定时器还在跑。用户切了十几个页面回来,发现手机发烫、电量狂掉。后来我养成了一个习惯:页面不可见时暂停定时器,可见时恢复。用 visibilitychange 事件来控制。

对于 setTimeout,虽然它只执行一次,但如果组件在定时器触发前销毁了,同样会造成泄漏。所以也要清理:

// setTimeout 也要清理
mounted() {
    this._timeout = setTimeout(() => {
        this.doSomething();
    }, 3000);
}

beforeDestroy() {
    clearTimeout(this._timeout);
}

1.4 知识体系总览

下面这张图总结了事件监听与反注册的核心逻辑:

事件监听与反注册核心逻辑 addEventListener 观察者模式 定时器 泄漏原因 未调用 removeEventListener 函数引用不一致 泄漏原因 订阅后未退订 EventBus 持有引用 泄漏原因 未调用 clearInterval 回调持有组件引用 解决方案 成对出现 保存函数引用 解决方案 生命周期对称 统一管理订阅 解决方案 销毁时清除 可见性控制 核心原则:谁创建,谁销毁;谁订阅,谁退订

1.5 总结与避坑指南

好了,咱们来捋一捋今天的内容:

  • addEventListener/removeEventListener:必须成对出现,注意函数引用要一致
  • 观察者模式:订阅和退订要对称,最好统一管理
  • 定时器:setInterval 和 setTimeout 都要在销毁时清除
一句话记住:所有在组件生命周期里注册的东西,都要在销毁时反注册。这是混合开发内存管理的铁律。

我曾经在一个项目里,就因为一个 setInterval 没清理,导致用户在使用 30 分钟后,页面卡得完全动不了。用 Chrome DevTools 的 Memory 面板一查,好家伙,组件实例堆了 200 多个。从那以后,我每次写事件绑定和定时器,都会条件反射地补上清理代码。

嗯,今天就先聊到这儿。下一章咱们会深入 DOM 引用泄漏,看看那些你以为已经销毁的 DOM 节点,是怎么偷偷留在内存里的。


专注资料整理