内存泄漏的根源只有一个:长生命周期的对象持有短生命周期对象的引用。组件销毁了,但全局服务里的订阅还握着它的回调,DOM 节点、闭包、整个组件实例就都留在堆里。Angular 应用的泄漏集中在四类:RxJS 订阅、定时器、DOM/浏览器事件、全局注册的回调。本文逐个击破,并聊聊 Signals 时代为什么这类问题大幅减少。
泄漏是怎么发生的
先建立直觉。一个”每 5 秒轮询”的组件:
export class DashboardComponent implements OnInit, OnDestroy {
private timer?: ReturnType<typeof setInterval>;
ngOnInit(): void {
this.timer = setInterval(() => {
// 闭包持有 this,组件销毁后回调仍每 5 秒执行一次
this.tick();
}, 5_000);
}
ngOnDestroy(): void {
if (this.timer) {
clearInterval(this.timer);
}
}
}
这段代码是正确的——因为写了清理。泄漏版本只是删掉 ngOnDestroy。问题在于:清理逻辑靠人肉纪律保证,而每个订阅、每个监听器都要配一段。字段多了以后,忘一个就漏一个。所以现代方案的核心思路是:把清理收敛到一处生命周期钩子上,或者干脆交给框架。
场景一:RxJS 订阅
HttpClient、Router 事件、服务里的 Observable,订阅不退就是泄漏:
export class FooComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject<void>();
constructor(private readonly http: HttpClient, private readonly route: ActivatedRoute) {}
ngOnInit(): void {
this.http.get('/api/notify').pipe(takeUntil(this.destroy$)).subscribe(...);
this.route.params.pipe(takeUntil(this.destroy$)).subscribe(...);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
takeUntil + destroy$ 是经典写法,但要求每个类都维护 Subject 和 OnDestroy。v22 的标准答案是 takeUntilDestroyed:
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class FooComponent {
private readonly http = inject(HttpClient);
constructor() {
// 注入上下文中调用:自动绑定当前注入器的销毁时机
this.http.get<Notification[]>('/api/notify')
.pipe(takeUntilDestroyed())
.subscribe((list) => this.notifications.set(list));
// interval 这类永不完成的流同样被兜住
interval(5_000)
.pipe(takeUntilDestroyed())
.subscribe(() => this.tick());
}
}
要点:
- 在注入上下文中调用(构造函数、字段初始化器),操作符自动拿到当前注入器,组件销毁时自动退订,不需要 OnDestroy
- 脱离注入上下文时显式传
DestroyRef:
export class PollingService {
private readonly destroyRef = inject(DestroyRef);
start(source$: Observable<void>): void {
source$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...);
}
}
- 社区熟悉的
untilDestroyed操作符语义相同——“直到所属注入器销毁”,按团队习惯二选一即可,新代码建议统一用takeUntilDestroyed
场景二:定时器
setInterval / setTimeout 持有的回调同样是引用链的根。除了上一节的 interval + takeUntilDestroyed,纯信号场景可以用 effect 的清理函数(见下文)。最朴素的守则是:组件里出现的每一个 timer id 都必须有对应的 clear,review 时专查这一项。
场景三:addEventListener 与全局对象
export class ChartComponent implements OnInit, OnDestroy {
ngOnInit(): void {
window.addEventListener('resize', this.onResize);
document.addEventListener('keydown', this.onKeydown);
}
ngOnDestroy(): void {
window.removeEventListener('resize', this.onResize);
document.removeEventListener('keydown', this.onKeydown);
}
private readonly onResize = () => this.layout();
private readonly onKeydown = () => this.close();
}
注意传给 add/remove 的必须是同一个函数引用(用箭头函数字段而不是内联函数),这是”remove 不掉”的经典 bug。Angular 提供的捷径:
- 模板事件绑定
(window:resize)="layout()"——监听器随模板销毁自动移除 @HostListener装饰器——监听宿主元素事件,随指令销毁自动清理
能用这两者就不要碰原生 addEventListener。
场景四:effect 的清理函数
Signals 时代大量”订阅式”代码被 effect 取代,而 effect 与注入器同生共死,组件销毁自动注销,本身不泄漏。真正要处理的是 effect 里创建的外部资源,用 onCleanup:
export class VisibilityAwareComponent {
private readonly el = inject(ElementRef<HTMLDivElement>);
private readonly visible = signal(false);
constructor() {
effect((onCleanup) => {
const observer = new IntersectionObserver(
([entry]) => this.visible.set(entry.isIntersecting),
);
observer.observe(this.el.nativeElement);
// effect 销毁(组件销毁)时断开观察器
onCleanup(() => observer.disconnect());
});
}
}
这个模式值得记住:effect 每次重新执行前也会先跑清理函数,所以”依赖变化导致的重复创建”同样不会累积垃圾。
Signals 与 Zoneless 时代:订阅为什么变少了
v22 的推荐写法从根上消灭了大部分手动订阅:
| 场景 | 旧写法(手动管理) | v22 写法(自动管理) |
|---|---|---|
| HTTP 数据 | http.get().subscribe() + 退订 | httpResource() / resource() |
| 路由参数 | route.params.subscribe() | withComponentInputBinding() → input |
| 流转信号 | 订阅 + 手动 set | toSignal()(随注入器销毁) |
| Observable 数据源 | 手动订阅/退订 | rxResource() |
| 派生状态 | combineLatest + 订阅 | computed() |
| 定时刷新 UI | interval + 订阅 | 信号 + effect(必要时配 interval + takeUntilDestroyed) |
| DOM 全局事件 | add/removeEventListener | 模板 (window:...) 绑定 / effect + onCleanup |
规律很明显:凡是”把外部世界变成状态”的需求,框架都给了随组件生命周期自动管理的容器。剩下的手动订阅集中在 WebSocket、复杂流编排等 RxJS 保留地(见第 22 篇),这些地方老老实实用 takeUntilDestroyed 兜底。
排查泄漏的工具
写代码防患于未然,排查也要有工具箱:
- Chrome DevTools 的 Memory 面板:操作前后各拍一份 Heap Snapshot,用 “Objects allocated between snapshot 1 and 2” 对比,按 Retainers 面板查引用链
- Performance 面板勾选 Memory 录制:JS Heap 曲线呈锯齿上升不回落,基本可以确认泄漏
- 搜索
subscribe(、addEventListener(、setInterval(三个关键词过一遍 code review 清单,每个命中处确认清理路径
小结
- 泄漏 = 长生命周期对象持有短生命周期引用,四大源头:订阅、定时器、DOM 监听、全局回调
- RxJS 订阅统一
takeUntilDestroyed()(注入上下文)或takeUntilDestroyed(destroyRef) - DOM 事件优先模板绑定与
@HostListener - effect 的
onCleanup处理其内部创建的外部资源 - 新代码优先
resource/httpResource/toSignal/input绑定,把订阅消灭在设计阶段