Angular 18+ 高级教程 – Memory leak, unsubscribe, onDestroy

何谓 Memory Leak?

Angular 是 SPA (Single-page application) 框架,用来开发 SPA。

SPA 最大的特点就是它不刷新页面,不刷新就容易造成 memory leak。

举个例子:

有一个页面 A,我们写了一个 setInterval 执行一些代码 (比如 autoplay 幻灯片)。

当用户离开页面 A 去页面 B 时,传统网站 (非 SPA),会刷新页面,之前那个 interval 就没了。

然而 SPA 网站不同,它不会刷新页面,之前的 interval 会一直持续执行。

我们姑且不论这个现象算不算真正意义上的 memory leak,但这个现象肯定是错误的 (因为这个 interval 是服务 A 页面的,当切换到 B 页面时,这个 interval 继续执行就完全没有意义了)。

本篇,我们就来聊一聊用 Angular 开发 SPA 如何避开上述这种错误的现象。

 

setInterval in Component

App Template 里有一个 HelloWorld 组件

@if (shown()) {
   <app-hello-world />
}

它是动态的,当 shown === false 就会被移除。

App 组件

export class AppComponent {
  shown = signal(true);

  constructor() {
    window.setTimeout(() => {
      this.shown.set(false);
    }, 3000)
  }
}

3 秒钟后移除 HelloWorld 组件。

我们在 HelloWorld 组件里写一个 setInterval

export class HelloWorldComponent {
  constructor() {
    window.setInterval(() => console.log('interval is running'), 1000);
  }
}

效果

可以看到,即便是在 3 秒后,HelloWorld 组件已经被移除的状态下,interval 依然继续执行。

clearInterval in Component

我们需要在组件被移除的同时也一并清除 interval。

export class HelloWorldComponent implements OnDestroy {
  private intervalId: number;
  
  constructor() {
    this.intervalId = window.setInterval(() => console.log('interval is running'), 1000);
  }

  ngOnDestroy() {
    window.clearInterval(this.intervalId);
  }
}

透过组件 DestroyHooks 执行 clearInterval 就可以了。

效果

3 秒后 HelloWorld 组件被移除的同时 interval 也不再继续执行了。

by RxJS and DestroyRef

ngOnDestroy 代码太过分散,不利于管理。通常我们会使用 RxJS + DestroyRef 达到相同的效果。

export class HelloWorldComponent {
  constructor() {
    interval(1000).pipe(takeUntilDestroyed()).subscribe(() => console.log('interval is running'));
  }
}

效果是一模一样的。

注:takeUntilDestroyed 和 inject,effect 函数一样,只能在 injection context 内使用。

 

addEventListener in Component

HelloWorld Template

<p>hello-world works!</p>
<button (click)="0">click me</button>

有一个 click 事件监听。

问:当 HelloWorld 组件被移除后,事件监听是否也自动被清除了?

我们用 Chrome DevTools 查看 (提醒:最好使用 incognito mode,因为有时候 Chrome extensions 会导致它不准确)

先 snapshot 1 次,等 3 秒钟 HelloWorld 组件被移除后再 snapshot 第 2 次。

查看 snapshot 1

一共出现了 3 个 EventListener,其中 2 个是 Angular 的,我们不管它们,HelloWorld 组件的 click 是第三个 EventListner。

查看 snapshot 2

HelloWorld 组件的 click EventListener 没了。

结论:移除组件会自动 removeEventListener。

题外话:如果 snapshot 显示的是 Detached EventListener 

意思是持有这个 EventListener 的 element 已经不在 DOM Tree 里,但它却依然被某个 JavaScript 引用着,这通常是 memory leak 的征兆,要多留意哦。

还有像 Detached ResizeObserverCallback 也是相同的道理

ResizeObserver.observe 的 element 如果已经没有被其它人引用,那它会自动被 unobserve。

如果 element 只是被 remove from DOM Tree,但依然被 JavaScript 引用,那就会出现 Detached 的现象。

by Renderer2

上面我们用的是 Template Binding Syntax,假如换成 Renderer2.listen 是否依然会自动 removeEventListener?

HelloWorld Template

<p>hello-world works!</p>
<button #button>click me</button>

HelloWorld 组件

export class HelloWorldComponent {
   private readonly button = viewChild.required<string, ElementRef<HTMLElement>>('button', { read: ElementRef });

   constructor() {
    const renderer = inject(Renderer2);
    afterNextRender(() => {
      renderer.listen(this.button().nativeElement, 'click', () => console.log('click'));
    });
   }
}

效果

一样会自动 removeEventListener。

by DOM manipulation

如果我们直接 DOM 操作,它还会自动 removeEventListener 吗?

HelloWorld 组件

export class HelloWorldComponent {
   private readonly button = viewChild.required<string, ElementRef<HTMLElement>>('button', { read: ElementRef });

   constructor() {
    afterNextRender(() => {
      this.button().nativeElement.addEventListener('click', () => console.log('click'));
    });
   }
}

效果

一样会自动 removeEventListener。

why?

可能你会有点好奇,不是 Angular 背地里替我们 removeEventListener 吗?

为什么 DOM manipulation 也会自动 removeEventListener?

虽然 Angular 的确是在背地里执行了 removeEventListener,但那不是重点 (就我们目前这个例子来说)。

addEventListener 会把 callback 记入在 element object,只要这个 element object 没有被任何人引用,它就会被游览器垃圾回收,回收后 EventListener 就没了。

所以,关键在于 <button> element object 有没有被人引用。

在 HelloWorld 组件还没有被移除之前,button element object 被存放在 HelloWorld 的 LView 里,这就算是一个引用。

当 HelloWorld 组件被移除之后,HelloWorld LView 就没了,此时就没有人在引用 button element object 了,于是它被游览器垃圾回收,EventListener 也就没了。

(document:click) by template binding syntax

HelloWorld Template

<p>hello-world works!</p>
<button (document:click)="0">click me</button>

(document:click) 是特殊语法,它等同于 document.addEventListener。

问:HelloWorld 被移除后,document.click 会 removeEventListener?

答:会

(document:click) by Renderer2

Renderer2.listen 不支持 (document:click) 这个语法

export class HelloWorldComponent {
  private readonly button = viewChild.required<string, ElementRef<HTMLElement>>('button', { read: ElementRef });
  constructor() {
    const renderer = inject(Renderer2);
    afterNextRender(() => { 
      // 这样写是错误的!!!
      renderer.listen(this.button().nativeElement, 'document:click', () => console.log('click'));
    });
  }
}

上面这样写是错误的,这个以前我们有讲解过了,原理不再复述。

我们换成这样

export class HelloWorldComponent {
  constructor() {
    const renderer = inject(Renderer2);
    const document = inject(DOCUMENT);
    afterNextRender(() => { 
      renderer.listen(document, 'click', () => console.log('click'));
    });
  }
}

效果

当 HelloWorld 组件被移除后,EventListener 依然存在!

从这里可以看出 Template Binding Syntax 和 Renderer2.listen 是有区别的。

Template Binding Syntax 会自动 removeEventListener,而 Renderer2.listen 则不会自动 removeEventListener。

相关源码在 listener.ts

当 LView 被删除的时候会调用 lCleanup array 里面的函数,这个动作就是 removeEventListener。

所以 removeEventListender 是在 Renderer2.listen 之外执行的。

(document:click) by DOM manipulation

export class HelloWorldComponent {
  constructor() {
    const document = inject(DOCUMENT);
    afterNextRender(() => { 
      document.addEventListener('click', () => console.log('click'));
    });
  }
}

问:当 HelloWorld 组件被移除时,它会自动 document.removeEventListener 吗?

答:当然不会。

总结

在组件内 addEventListener 是否需要 removeEventListener 取决于两大因素和几个场景。

因素:

  1. element 是 under 组件 (e.g. button) 还是 out of 组件 (e.g. document)

  2. addEventListener 的方式,是 Template Binding Syntax 还是 Renderer2.listen 或 DOM manipulation

场景:

  1. element under 组件

    如果 element 是组件 (比如 HelloWorld 的 host element) 或者 under 组件 (Hello World Template 里的 elements),

    那无论用什么方式 addEventListener,我们都不需要 removeEventListener。

    因为随着组件被移除,它和其下的 element object 都不会在被 LView Tree 引用。

    因此 element object 会被游览器垃圾回收,它们身上的 EventListener 也同时会被回收掉。

  2. element out of 组件

    element out of 组件意味着,这些带有 EventListner 的 element 并不会随着组件被移除而失去引用。

    比如说 document,或者 parent element (因为 parent 组件依然被 LView Tree 引用)。

    有引用就不会被游览器垃圾回收,EventListener 就依然会存在。

    这时如何 addEventListener 就变得很重要了。

    如果使用 Template Binding Syntax,那 Angular 会在 LView 被移除 (也就是组件被移除的意思) 时自动 removeEventListener。

    如果使用 Renderer2.listen 或者 DOM manipulation 则不会,我们需要自己手动 removeEventListener。

综上所述,如果我们全程都使用 Template Binding Syntax,那完全不用操心,Angular 已经替我们处理好了。

如果我们只对组件内的 element 做 addEventListener,那我们也不必操心,它会随着 element object 失去引用而被游览器垃圾回收。

我们唯一需要自己 removeEventListener 的情况是,当我们以 Render2.listen 或 DOM manipulation 的方式对组件外的 element (e.g. document, parent element) addEventListener 时。

Best Practice

我个人主张:

  1. 尽量使用 Template Binding Syntax。
  2. 只有当 element out of 组件才需要 removeEventListener。

    有些人主张,但凡使用 Render2.listen 或 DOM manipulation 无论 element 是否 out of 组件都统一 removeEventListener。

    但我个人对此持保留态度,因为我觉得 element out of 组件是比较罕见的情况 (至少小于 50%),所以我认为没有必要统一。

    这个就看个人吧。

  3. 使用 DOM manipulation 优先于 Renderer2.listen。

    Renderer2.listen 肯定比 DOM manipulation 强大 (比如它支持 keydown.enter 语法),但它也相对繁琐一点。

    而绝大部分情况 DOM manipulation 是足够用的,所以我不鼓励统一使用 Renderer2.listen。

  4. 尽量使用 RxJS

    RxJS + takeUntilDestroyed 可以比较干净的实现 removeEventListener,它就类似于上一 part 的 clearInterval 一样。

例子一:DOM manipulation

export class HelloWorldComponent {
  constructor() {
    const destroyRef = inject(DestroyRef);
    const document = inject(DOCUMENT);
    afterNextRender(() => {
      const click$ = fromEvent(document, 'click');
      click$.pipe(takeUntilDestroyed(destroyRef)).subscribe(e => console.log(e));
    });
  }
}

例子二:Renderer2.listen

export class HelloWorldComponent {
  constructor() {
    const destroyRef = inject(DestroyRef);
    const document = inject(DOCUMENT);
    const renderer = inject(Renderer2);
    afterNextRender(() => {
      const enter$ = fromRendererEvent(renderer, document, 'keydown.enter');
      enter$.pipe(takeUntilDestroyed(destroyRef)).subscribe(e => console.log(e));
    });
  }
}

function fromRendererEvent<T>(renderer: Renderer2, target: unknown, eventName: string): Observable<T> {
  return fromEventPattern(
    handler => renderer.listen(target, eventName, handler),
    (_handler, removeEventListenerFn) => removeEventListenerFn(),
  );
}

 

HttpClient in Component

HelloWorld 组件

export class HelloWorldComponent implements OnInit {
  private readonly httpClient = inject(HttpClient);
  ngOnInit() {
    this.httpClient.get('https://localhost:44300/api/v1/projects').subscribe(data => console.log(data));
  }
}

3 秒钟后 HelloWorld 组件会被移除。

假如 4 秒钟后 http request 才接收到 response。

问:console.log(data) 会触发吗?还是在 3 秒钟后 http request 会随着 HelloWorld 组件被移除而被 abort 掉?

效果

答:4 秒钟后,console.log(data) 依然会执行。

也就是说,Angular 没有替我们 abort 掉 http request。

Use takeUntilDestroyed to abort http request

我们在 HttpClient 文章中讲解过,只要 unsubscribe subscription,HttpClient 内部就会调用 XMLHttpRequest.abort()。

export class HelloWorldComponent implements OnInit {
  private readonly httpClient = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);

  ngOnInit() {
    this.httpClient
      .get('https://localhost:44300/api/v1/projects')
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((data) => console.log(data));
  }
}

我们可以使用 takeUntilDestroyed 来 unsubscribe。

效果

当 HelloWorld 组件被移除时,http request 同时也被 abort 掉了。

Should I really need to unsubscribe?

假如我们把上述代码修改成 async await 版本 (真是项目中,为了代码好看,通常会使用 async await)

async ngOnInit() {
  const data = await firstValueFrom(
    this.httpClient.get('https://localhost:44300/api/v1/projects').pipe(takeUntilDestroyed(this.destroyRef))
  );
  console.log(data);
}

效果

当 http request 被 abort 时,firstValueFrom 报错了,原因是 firstValueFrom 要求 Observable 至少要 next 一次。

为此,我们需要这样子写才行

async ngOnInit() {
  const data = await firstValueFrom(
    this.httpClient.get('https://localhost:44300/api/v1/projects').pipe(takeUntilDestroyed(this.destroyRef)),
    { defaultValue: null }
  );
  if (data === null) return;
  console.log(data);
}

显然,虽然严谨,但是很繁琐,代码不美了。

因此,除非组件真的有可能会在 http request 还没有 response 之前就被移除 (这机率不高),否则没有必要 unsubscribe。

 

FormControl in Component

HelloWorld 组件

export class HelloWorldComponent {
  formControl = new FormControl('Hello World');
  constructor() {
    this.formControl.valueChanges.subscribe(() => console.log('xyz'));
  }
}

HelloWorld Template

<p>hello-world works!</p>
<input [formControl]="formControl">

问:this.formControl.valueChanges.subscribe 需要 unsubscribe 吗?

首先 FormControl.valueChanges 的类型是 EventEmitter,源码在 abstract_model.ts

EventEmitter 继承自 RxJS 的 Subject。

EventEmitter.subscribe 内部调用了 RxJS Subject.subscribe

RxJS Subject.subscribe 方法的源码在 Subject.ts

callback 会被存入 observers array 里。

这个 observers array 是 Subject 里的 property

当 Subject.next 时

好,上面这些的重点是:

  1. HelloWorld 有一个 formControl 属性,类型是 FormControl。
  2. FormControl 有一个 valueChanges 属性,它是一个 RxJS Subject 对象。

  3. 当我们调用 valueChanges.subscribe(callback) 时,callback 被保存到 Subject.observers array 里。

当 HelloWorld 组件被移除,HelloWorld 实例就会被垃圾回收,同时它里面的 formControl 对象也会被回收,、

同时它里面的 valueChanges 对象也会被回收,同时它里面的 observers array 也会被回收。

通通都会被回收,所以我们不需要 unsubscribe。

When we need to unsubscribe valueChanges?

从上面一路阅读下来,我想大家应该有抓到精髓了。

组件是否有影响到外部资源是关键。

比如 document.addEventListener。document 是外部资源。

比如 window.setInterval,window 是外部资源。

比如 HttpClient.get 也是外部资源。

相反,button.addEventListener 就不是外部资源。button 是组件 Template 里的 element。

formControl 也不是外部资源,它是在组件内创建的,而且只有组件实例引用它 (formControl property 和 组件 Template [formControl] 指令)。

结论:组件如果没有影响到外部,那它移除的时候就什么也不必做,相反,如果组件有影响到外部,那当组件被移除时,它就必须把那些影响一起带走。

 

ActivatedRoute in Component

export class ContactComponent {
  constructor() {
    const activatedRoute = inject(ActivatedRoute);
    activatedRoute.queryParams.subscribe(queryParams => console.log('id', queryParams['id']));
  }
}

问:activatedRoute.queryParams.subscribe 需要 unsubscribe 吗?

queryParams 其实是一个 BehaviorSubject 来的,相关源码在 router_state.ts

也就是说,只要 ActivatedRoute 对象被垃圾回收,那就行了。

所以问题变成了:ActivatedRoute 对组件来说是外部资源吗?当组件被移除时,ActivatedRoute 会被垃圾回收吗?

答:显然,ActivatedRoute 不会随着组件被移除而被垃圾回收的,ActivatedRoute 是否存在是依据 route 匹配。

但有一种情况,ActivatedRoute 会碰巧和组件一起被移除。

当组件是 first layer 的时候

Contact 组件被用于 route 匹配。当切换到 about 时,contact 的 ActivatedRoute 和 Contact 组件会同时被移除。

这种情况下,Contact 组件内确实可以不需要 unsubscribe 对 ActivatedRoute 的 subscription。

但这是碰巧而已,一旦结构稍微变化一下,它的逻辑就不通了。

举例,Conact Template 里有一个 HelloWorld 组件

<p>contact works!</p>

<a routerLink="/contact" [queryParams]="{ id: 11 }">11</a>
<a routerLink="/contact" [queryParams]="{ id: 12 }">12</a>
<a routerLink="/contact" [queryParams]="{ id: 13 }">13</a>

@if (shown()) {
  <app-hello-world />
}

HelloWorld 组件 subscribe 了 ActivatedRoute.queryParam

export class HelloWorldComponent {
  constructor() {
    const activatedRoute = inject(ActivatedRoute);
    activatedRoute.queryParams.subscribe(queryParams => console.log('id', queryParams['id']));  }
}

在这种结构下,当 HelloWorld 组件被移除时,ActivatedRoute 可不会被移除的 (ActivatedRoute 是依据 route 匹配决定是否被移除),

这时 HelloWorld 组件就必须 unsubscribe,否则

当 HelloWorld 组件被移除后,点击 11, 12, 13 修改 queryParams,HelloWorld 组件监听的 queryParams 依然会执行 console。

所以一定要 unsubscribe 才行。

export class HelloWorldComponent {
  constructor() {
    const activatedRoute = inject(ActivatedRoute);
    activatedRoute.queryParams.pipe(takeUntilDestroyed()).subscribe(queryParams => console.log('id', queryParams['id']));
  }
}

结论:虽然在一些情况下确实可以不需要 unsubscribe,但这种情况是比较少见的 (低于 50%),所以个人觉得还是统一 unsubscribe 会比较好管理一点。

 

effect in Component

effect 依赖 Injector,它内部会 inject DestroyRef 做 autoCleanup,所以我们不需要做任何 destroy / unsubscribe 的动作。

 

Subject in Component

当组件被移除时,Subject 有必要 complete 吗?

如果我们翻 Angular Material 源码的话,确实会看到很多组件在 on destroy 时会调用 Subject.complete (包括 BehaviorSubject 也会调用 complete)。

逻辑上来讲这是完全正确的。

组件没了,Subject 再也不会发布了,这时执行 complete 通知所有订阅者,没有任何问题。

唯一纠结的地方是,绝大部分情况下,订阅者并不关心 Subject 是否 complete,反正你有发布我就收,你没发布我就等,我要离开的时候我会 unsubscribe。

还有一点是,Signal 没有 complete 的概念,fromEvent 也没有 complete 概念。

所以就我个人来讲,没有 complete 其实还好,它不会像没有 unsubscribe 那么严重,所以看个人吧。

补上一个具体的例子:

我在 CDK Overlay 文章中讲解过 OverlayRef.outsidePointerEvents 里有一个偷龙转风 pointerdown event 的动作,但是它没有把 pointerdown 公开给我们使用

假如我们需要 pointerdown 的话,可以自己这样监听

overlayRef
  .outsidePointerEvents()
  .pipe(withLatestFrom(fromEvent<PointerEvent>(document.body, 'pointerdown', { capture: true })))
  .subscribe(([clickEvent, pointerDownEvent]) => {
    // ...
  });

那问题来了,我们需要手动去 unsubscribe pointerdown event 监听吗?

答案是不需要,因为 OverlayRef detach 的时候 outsidePointerEvents (它是 Subject) 会被 complete,接着 withLatestFrom 所有的 Observable 会自动被 unsubscribe,其中就有我们的 pointerdown event。

这就是 Subject complete 的好处,在一些情况下可以替代 takeUntil。

 

总结

本篇主要是探讨在什么情况下我们需要释放资源 (比如 removeEventListener, unsubscribe 等等) 来避免 memory leak。

如果我们不想理清楚每一个具体的情况,那最简单的做法就是养成 unsubscribe 的好习惯,毕竟做多不会错,少做则有风险。

当然,如果你不喜欢看到一堆的 takeUntilDestroyed,那就必须小心翼翼处理每一个具体情况了。

 

 

目录

上一篇 Angular 18+ 高级教程 – Coding Style Guide 编码风格

下一篇 Angular 18+ 高级教程 – 国际化 Internationalization i18n

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

posted @ 2024-06-17 21:04  兴杰  阅读(218)  评论(1编辑  收藏  举报