第一章:事件监听与反注册——内存泄漏的头号元凶
说实话,我做了这么多年混合开发,遇到最多的内存泄漏问题,十有八九都跟事件监听有关。你想想看,一个页面打开了,绑了一堆事件,页面关闭了,事件却没解绑。这就像你出门不锁门,小偷迟早会来。
今天咱们就从三个最典型的场景入手: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 或者匿名函数,那移除时也得用同一个。否则,你移除的只是一个空壳,真正的监听还在那里。
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);
}
created 或 mounted 里订阅,就在 beforeDestroy 或 unmounted 里退订。
如果是 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;
}
}
setInterval 做轮询,页面切到后台后定时器还在跑。用户切了十几个页面回来,发现手机发烫、电量狂掉。后来我养成了一个习惯:页面不可见时暂停定时器,可见时恢复。用 visibilitychange 事件来控制。
对于 setTimeout,虽然它只执行一次,但如果组件在定时器触发前销毁了,同样会造成泄漏。所以也要清理:
// setTimeout 也要清理
mounted() {
this._timeout = setTimeout(() => {
this.doSomething();
}, 3000);
}
beforeDestroy() {
clearTimeout(this._timeout);
}
1.4 知识体系总览
下面这张图总结了事件监听与反注册的核心逻辑:
1.5 总结与避坑指南
好了,咱们来捋一捋今天的内容:
- addEventListener/removeEventListener:必须成对出现,注意函数引用要一致
- 观察者模式:订阅和退订要对称,最好统一管理
- 定时器:setInterval 和 setTimeout 都要在销毁时清除
我曾经在一个项目里,就因为一个 setInterval 没清理,导致用户在使用 30 分钟后,页面卡得完全动不了。用 Chrome DevTools 的 Memory 面板一查,好家伙,组件实例堆了 200 多个。从那以后,我每次写事件绑定和定时器,都会条件反射地补上清理代码。
嗯,今天就先聊到这儿。下一章咱们会深入 DOM 引用泄漏,看看那些你以为已经销毁的 DOM 节点,是怎么偷偷留在内存里的。