CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-193 · Angular 高级教程

Angular 22+ 教程 27:内存泄漏、unsubscribe 与 takeUntilDestroyed

内存泄漏的根源只有一个:长生命周期的对象持有短生命周期对象的引用。组件销毁了,但全局服务里的订阅还握着它的回调,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
流转信号订阅 + 手动 settoSignal()(随注入器销毁)
Observable 数据源手动订阅/退订rxResource()
派生状态combineLatest + 订阅computed()
定时刷新 UIinterval + 订阅信号 + 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 绑定,把订阅消灭在设计阶段

系列导航

← 算法 027:图:AOE & 关键路径 目录 JHipster 开发 27:国际化与本地化 →
← 返回文章列表