先记住这个答案
观察者忘记取消订阅会造成内存泄漏,本质是回调闭包捕获了上下文对象(如组件实例、大对象),而事件源(全局总线、DOM、定时器)长期存活,持续引用该回调,使闭包及其闭包变量无法被回收。诊断应在内存快照中检测非预期的“事件源→回调→被捕获对象”引用链,并利用性能工具分析堆对象数量与保留大小。
- 回调可被事件源长期持有,取消订阅让对象真正可回收。
- 闭包捕获上下文对象,形成隐藏引用链。
- 用堆快照追踪 Detached 节点定位泄漏源。
从引用链看泄漏机理
现代引擎使用标记-清除 GC,从根集合(全局对象、栈、DOM 引用等)出发遍历可到达对象。当事件源(如 EventEmitter、DOM 节点、定时器)是全局单例或长期存活时,其订阅列表持有回调函数引用。回调函数定义在某个上下文(组件、服务)内时,其词法环境捕获了该上下文的局部变量,例如组件实例的引用,即使上下文自身已脱离使用,也因这条引用链保持可达。
典型场景:在一个模块的 map 中注册一个 handleChange 方法,方法里引用了配置对象和大量临时数据。只要该 map 始终在线,订阅方法及其捕获的所有变量都不可回收。即便模块已销毁、实例已弃用,GC 也无法识别。累积多个此类订阅,逐渐撑大堆内存,最终触发频繁 GC 甚至压缩暂停,表现为性能劣化。
class Bus { constructor() { this.subs = new Map(); } on(topic, cb) { let list = this.subs.get(topic); if (!list) { list = []; this.subs.set(topic, list); } list.push(cb); } emit(topic) { const list = this.subs.get(topic); if (list) list.forEach(cb => cb()); } }
const bus = new Bus();
function registerObject() {
const bigObject = { dataArray: new Array(10000).fill('leak') };
const handler = () => { console.log('got event', bigObject.dataArray.length); };
bus.on('update', handler);
// 忘记调用 bus.off 取消订阅
}
registerObject();
bus.emit('update'); // 触发回调,bigObject 被子持有
console.log('订阅数', bus.subs.get('update').length);查看输出与解释
got event 10000
订阅数 1该代码生成一个全局 bus,注册后函数返回,但 bus.subs 中 handler 闭包捕获 bigObject,使 bigObject 不能回收。
具体泄漏场景:组件未卸载清理
假设有一个远程数据同步模块,每次“开始同步”时注册一个全局监听器,监听远端推送消息并更新本地的缓存 Map。设计时约定在“停止同步”时清理缓存并取消监听,但开发中遗漏。在假设的运行场景中,如果执行一个长时间任务,让同步模块被实例化并反复启动/停止,且最后一次启动后未正确停止,那么预期任务期间堆内存会缓慢增长,任务结束 10 分钟后仍不下降——因为最后一次启动的实例没有停止,其监听器继续被全局事件总线持有,闭包内的缓存 Map 和临时对象都驻留。
改进:每次注册返回清理函数,并在停止流程强制调用;同时增加模块级的弱引用表留作后备。预期改进后,在停止后快照中不再存留新实例,堆增长曲线在停止点回落,表明泄漏被修复。
判定为泄漏的条件与边界
并非所有忘取消订阅都是内存泄漏:如果事件源与订阅者的生命周期相同,且它们所在的作用域结束后一起变得不可达,那只是一次性对象,无需取消。真正危险的是“事件源存活期远长于订阅者”,例如在模块级单例、全局事件总线、setInterval 或 DOM 树保留节点上的监听。检测标准:订阅者已从业务中移除,却仍可从 GC 根通过订阅路径触达。
有效做法:在订阅者生命周期结束位置调用对应移除方法;无法直接移除时,考虑使用限制生命周期的包装器。但要弄清事件源的清理 API:removeEventListener、clearInterval、off 是否真正使引用失效。有些库的 off 只是设标志,仍存事件数组,此时更需移除自身的引用,或改用弱引用结构。
容易答错的地方
- 以为回调为空后自动回收
- 常见误解:回调函数体没有再引用外部变量就能自动释放。但即便回调体不使用外部变量,闭包也可能捕获外层作用域中的实例引用,例如通过箭头函数捕获 this,或通过闭包变量引用对象。若回调是从实例方法提取的普通函数,其词法环境不包含 this,但可能包含其他外部变量;调用时 this 的绑定可能丢失,但对象仍可能因闭包持有的其他引用而存活。刻意将回调设为
null不等于从订阅列表移除,事件源会继续调用旧回调。 - 依赖 WeakMap 保存订阅列表就能全解决
- 错误认知:改用 WeakMap 存储回调就万事大吉。WeakMap 键必须是对象,如果键是订阅者自身,弱引用确实能避免被强持,但事件源内部还持有键或值则会失效。如果键是字符串,WeakMap 无法使用;更严重的是众多事件源仍自带数组强引用,弱引用只能在新订阅机制中自设计,而不是标准组件库可补的。
面试官还会怎么问?
诊断时如何精确找出泄漏的订阅对象?
在 DevTools Heap Snapshot 中,拍摄两次具有代表性的状态快照(前/后),用 Comparison 筛选 Detached 对象;或使用 Memory 面板的 Timeline 记录堆增长,然后点击“All objects”查看保留对象。在“Retainers”面板展开每条路径,若顶层是 EventEmitter.on 或 addEventListener,则确认为订阅泄漏。
强引用列表本身不释放,但为什么有时 GC 仍回收了对象?
只有当订阅列表本身被回收或该列表被标记为不可达时才会释放。若事件源是全局对象,订阅列表常驻,则不会回收。若事件源也是临时的,例如局部创建后不再使用,那么它和订阅列表一起被回收,观察者对象也随之释放。因此生命周期匹配是关键。
有没有不改变订阅 API 就能避免泄漏的补救方案?
可以在实例销毁前反射遍历已注册回调并解除,或利用 FinalizationRegistry 观测目标对象被回收后清理订阅。但这样成本高且不可靠,正确做法仍是设计成组件卸载/模块关闭时显式调用取消方法,或者使用一次性订阅包装函数并强制返回清理句柄。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。