何谓 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 那么严重,所以看个人吧。

总结

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

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

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

目录

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

下一篇 TODO

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

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

Angular 18+ 高级教程 – Memory leak, unsubscribe, onDestroy的更多相关文章

  1. Pandas之:Pandas高级教程以铁达尼号真实数据为例

    Pandas之:Pandas高级教程以铁达尼号真实数据为例 目录 简介 读写文件 DF的选择 选择列数据 选择行数据 同时选择行和列 使用plots作图 使用现有的列创建新的列 进行统计 DF重组 简 ...

  2. ios cocopods 安装使用及高级教程

    CocoaPods简介 每种语言发展到一个阶段,就会出现相应的依赖管理工具,例如Java语言的Maven,nodejs的npm.随着iOS开发者的增多,业界也出现了为iOS程序提供依赖管理的工具,它的 ...

  3. Android 内存优化 (防Memory Leak)

      在之前的 Android 内存管理 &Memory Leak & OOM 分析 中,说到了Android的内存管理相关的原理,也能了解到Android Memory Leak 和 ...

  4. tomcat报错:This is very likely to create a memory leak问题解决

    tomcat memory leak解决方案 这种问题在开发中经常会碰到的,看看前辈的总结经验 Tomcat内存溢出的原因  在生产环境中tomcat内存设置不好很容易出现内存溢出.造成内存溢出是不一 ...

  5. 安卓android WebView Memory Leak WebView内存泄漏

    Android WebView Memory Leak WebView内存泄漏 在这次开发过程中,需要用到webview展示一些界面,但是加载的页面如果有很多图片就会发现内存占用暴涨,并且在退出该界面 ...

  6. JavaScript :memory leak [转]

    Memory leak patterns in JavaScript Handling circular references in JavaScript applications Abhijeet ...

  7. 内存泄漏(Memory Leak)

    什么情况下会导致内存泄露(Memory Leak)? Android 的虚拟机是基于寄存器的Dalvik,它的最大堆大小一般是16M,有的机器为24M.因此我们所能利用 的内存空间是有限的.如果我们的 ...

  8. tomcat关闭时Log4j2报错 Log4j Log4j2-TF-4-Scheduled-1 memory leak

    出错信息: 23-Sep-2017 17:43:18.964 警告 [main] org.apache.catalina.loader.WebappClassLoaderBase.clearRefer ...

  9. Siki_Unity_2-9_C#高级教程(未完)

    Unity 2-9 C#高级教程 任务1:字符串和正则表达式任务1-1&1-2:字符串类string System.String类(string为别名) 注:string创建的字符串是不可变的 ...

  10. executor.Executor: Managed memory leak detected; size = 37247642 bytes, TID = 5

    https://stackoverflow.com/questions/34359211/debugging-managed-memory-leak-detected-in-spark-1-6-0 h ...

随机推荐

  1. mysql 删除数据表报错 表删除时 Cannot delete or update a parent row: a foreign key constraint fails 异常处理

    mysql 删除数据表报错 表删除时 Cannot delete or update a parent row: a foreign key constraint fails 异常处理 MySQL报错 ...

  2. Django 不通过外键实现多表关联查询

    Django不通过外键实现多表关联查询 by:授客 QQ:1033553122 测试环境 Win 10   Python 3.5.4   Django-2.0.13.tar.gz 需求 不通过外键,使 ...

  3. VUE系列之性能优化--懒加载

    一.懒加载的基本概念 懒加载是一种按需加载技术,即在用户需要时才加载相应的资源,而不是在页面初始加载时一次性加载所有资源.这样可以减少页面初始加载的资源量,提高页面加载速度和用户体验. 二.Vue 中 ...

  4. 洛谷P1029 [NOIP2001 普及组] 最大公约数和最小公倍数问题

    [NOIP2001 普及组] 最大公约数和最小公倍数问题 题目描述 洛谷题目链接:https://www.luogu.com.cn/problem/P1029 输入两个正整数 x, y,求出满足下列条 ...

  5. Jmeter函数助手33-split

    split函数用于根据分隔符拆分传递给它的字符串,并返回原始字符串. String to split:填入需要转换的字符串 函数名称.用于存储在测试计划中其他的方式使用的值:存储结果的变量名 Stri ...

  6. 【Vue2】Component 组件

    Main.JS入口函数,Vue的用法 //导入vue模块,得到Vue构造函数 import Vue from 'vue' // 导入根组件App.vue import App from './App. ...

  7. 【微信小程序】01 入门

    官方开发文档: https://developers.weixin.qq.com/miniprogram/dev/devtools/devtools.html 需要去微信公众平台注册开发账号: mp. ...

  8. TensorFlow中的int32_ref、float32_ref类型

    在用TensorFlow_1.14.0中发现数据类型的显示带有 _ref : x1=tf.Variable([1, 2, 3])x2=tf.Variable([1.0, 2.0, 3.0]) 也就是说 ...

  9. 【转载】 linux中umask命令介绍

    版权声明:本文为CSDN博主「立二拆四i」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明.原文链接:https://blog.csdn.net/weixin_4408 ...

  10. 一文搞懂DevOps、DataOps、MLOps、AIOps:所有“Ops”的比较

    引言 近年来,"Ops"一词在 IT 运维领域的使用迅速增加.IT 运维正在向自动化过程转变,以改善客户交付.传统的应用程序开发采用 DevOps 实施持续集成(CI)和持续部署( ...