前端进阶之旅前端进阶之旅
  • 基础篇HTML/CSS/JS 打底
  • 进阶篇原理与工程化
  • 高频篇面试最常问的那批
  • 精选篇按模块收敛的总结
  • 手写篇常考代码手写实现
  • 面经篇真实面试问题复盘
  • AI 篇NEWAI 时代的前端考点
  • 历年面经NEW按年份追踪真实考点
  • 每日一题每天一道,攒手感
  • 专项自测100 题快速查漏
  • 小程序题库小程序专项刷题
  • 算法题库NEW在线编码即时判题
  • 知识卡片NEW碎片时间过考点
  • 面试题大全常见问题解析
  • AI 答疑NEW随时提问,即时解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • AI 定制路线NEW按你的简历现排
  • AI 知识地图NEW串起全站知识点
  • 原理篇React / Vue 源码拆解
  • HTTP从报文一路讲到 HTTPS
  • 浏览器渲染、事件循环、进程
  • 计算机基础Linux、网络、操作系统
  • 设计模式23 种模式怎么用
  • Node学习指南从环境搭建到服务端
  • NPM工作流script、依赖与发布
  • Docker容器化部署上手
  • Canvas图形与动画实战
  • 前端系统进阶学习大型项目工程化
  • 前端综合文章长期沉淀的实践文
  • 思维导图知识点全景图
  • 学习路线按图索骥不跑偏
  • AI 热点NEWAI 每日动态
  • 公众号动态公众号历史文章
  • 博客动态站长的技术博客
  • 开发者导航常用工具与文档站
  • 基础篇HTML/CSS/JS 打底
  • 进阶篇原理与工程化
  • 高频篇面试最常问的那批
  • 精选篇按模块收敛的总结
  • 手写篇常考代码手写实现
  • 面经篇真实面试问题复盘
  • AI 篇NEWAI 时代的前端考点
  • 历年面经NEW按年份追踪真实考点
  • 每日一题每天一道,攒手感
  • 专项自测100 题快速查漏
  • 小程序题库小程序专项刷题
  • 算法题库NEW在线编码即时判题
  • 知识卡片NEW碎片时间过考点
  • 面试题大全常见问题解析
  • AI 答疑NEW随时提问,即时解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • AI 定制路线NEW按你的简历现排
  • AI 知识地图NEW串起全站知识点
  • 原理篇React / Vue 源码拆解
  • HTTP从报文一路讲到 HTTPS
  • 浏览器渲染、事件循环、进程
  • 计算机基础Linux、网络、操作系统
  • 设计模式23 种模式怎么用
  • Node学习指南从环境搭建到服务端
  • NPM工作流script、依赖与发布
  • Docker容器化部署上手
  • Canvas图形与动画实战
  • 前端系统进阶学习大型项目工程化
  • 前端综合文章长期沉淀的实践文
  • 思维导图知识点全景图
  • 学习路线按图索骥不跑偏
  • AI 热点NEWAI 每日动态
  • 公众号动态公众号历史文章
  • 博客动态站长的技术博客
  • 开发者导航常用工具与文档站
首页程序员面试题库观察者模式 内存泄漏 取消订阅
设计设计模式可维护代码

JavaScript 中观察者模式忘记取消订阅会造成什么内存问题,如何诊断?

观察者模式中忘记取消订阅,往往因闭包捕获长生命周期对象,使回调被全局/长生命周期事件源持有,形成意外可达路径,阻碍垃圾回收。

前端进阶之旅 · 一题精讲更新于 2026.09.05
设计模式#可维护代码
先看核心答案读代码示例
理解线索

可达性与闭包引用链

  1. GC 根全局对象、活动调用栈等
  2. 闭包引用回调函数携词法环境引用
  3. 意外可达事件源→回调→对象使对象存活

真正的边界:只要订阅对象仍被某个存活对象引用,GC 即认为其可达,无法回收。

核心回答

先记住这个答案

观察者忘记取消订阅会造成内存泄漏,本质是回调闭包捕获了上下文对象(如组件实例、大对象),而事件源(全局总线、DOM、定时器)长期存活,持续引用该回调,使闭包及其闭包变量无法被回收。诊断应在内存快照中检测非预期的“事件源→回调→被捕获对象”引用链,并利用性能工具分析堆对象数量与保留大小。

  • 回调可被事件源长期持有,取消订阅让对象真正可回收。
  • 闭包捕获上下文对象,形成隐藏引用链。
  • 用堆快照追踪 Detached 节点定位泄漏源。

从引用链看泄漏机理

现代引擎使用标记-清除 GC,从根集合(全局对象、栈、DOM 引用等)出发遍历可到达对象。当事件源(如 EventEmitter、DOM 节点、定时器)是全局单例或长期存活时,其订阅列表持有回调函数引用。回调函数定义在某个上下文(组件、服务)内时,其词法环境捕获了该上下文的局部变量,例如组件实例的引用,即使上下文自身已脱离使用,也因这条引用链保持可达。

典型场景:在一个模块的 map 中注册一个 handleChange 方法,方法里引用了配置对象和大量临时数据。只要该 map 始终在线,订阅方法及其捕获的所有变量都不可回收。即便模块已销毁、实例已弃用,GC 也无法识别。累积多个此类订阅,逐渐撑大堆内存,最终触发频繁 GC 甚至压缩暂停,表现为性能劣化。

订阅泄漏的最小复现JavaScript
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 观测目标对象被回收后清理订阅。但这样成本高且不可靠,正确做法仍是设计成组件卸载/模块关闭时显式调用取消方法,或者使用一次性订阅包装函数并强制返回清理句柄。

从一道题,走向一组知识

把知识连起来

可维护代码

观察者模式与发布订阅模式在耦合方式上的核心区别是什么?

对比直接依赖的观察者与基于消息代理的解耦,理解事件源生命周期差异后,能预判哪里更易泄漏。

可维护代码

为什么可以用 WeakMap 或 WeakRef 管理观察者订阅来避免内存泄漏?

在明确失忆的成因后,继续了解 WeakMap 和 WeakRef 如何让订阅关系随目标回收而解除,以及其限制。

可维护代码

在 JavaScript 中直接传递函数与定义策略类实现策略模式各有什么取舍?

同属「可维护代码」专题,接着看 JavaScript 函数 策略模式 取舍 在具体场景中的处理方式。

参考资料

  • Memory management

示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。

本题目录
  1. 先记住这个答案
  2. 从引用链看泄漏机理
  3. 具体泄漏场景:组件未卸载清理
  4. 判定为泄漏的条件与边界
  5. 容易答错的地方
  6. 面试官还会怎么问
  7. 把知识连起来
读懂,再试着讲出来

先看核心答案,再读代码。最后展开追问,检查自己有没有遗漏边界。

试着回答追问
浏览全部面试题理解原理,也关注真实的使用场景。回到顶部 ↑