如何在Angular应用程序中导致内存泄漏?

性能是Web应用程序成功的关键。因此,开发人员需要知道内存泄漏是如何发生的以及如何处理。

当开发人员正在处理的应用程序达到一定大小时,此知识尤其重要。如果您对内存泄漏没有给予足够的关注,那么开发人员可能会进入“消除内存泄漏的团队”(我必须是这样的团队的一员)。 内存泄漏可能由于各种原因而发生。但是,我相信在使用Angular时,您可能会遇到与最常见的内存泄漏原因相匹配的模式。有一种方法可以处理此类内存泄漏。当然,最好的事情不是解决问题,而是避免问题。





什么是内存管理?


JavaScript使用自动内存管理系统。内存生命周期通常包含三个步骤:

  1. 分配必要的内存。
  2. 使用分配的内存,执行读取和写入操作。
  3. 不再需要之后释放内存。

MDN上表示自动内存管理-这是造成混乱的潜在原因。这会给开发人员一种错误的感觉,即他们不必担心内存管理。

如果您根本不关心内存管理,则意味着在您的应用程序增长到一定大小后,您很可能会遇到内存泄漏。

通常,内存泄漏可以认为是分配给应用程序的内存,不再需要但不释放。换句话说,这些是无法进行垃圾回收操作的对象。

垃圾收集如何工作?


在非常合乎逻辑的垃圾收集过程中,所有被视为“垃圾”的东西都被清除了。垃圾收集器清理了应用程序不再需要的内存。为了找出应用程序仍需要哪些内存区域,垃圾回收器使用“标记和清除”算法(标记算法)。顾名思义,该算法包括两个阶段-标记阶段和扫描阶段。

▍标志阶段


对象和指向它们的链接以树的形式呈现。在下图中,树的根是一个节点root在JavaScript中,这是一个object window每个对象都有一个特殊的标志。让我们命名这个标志marked在标记阶段,首先,将所有标记marked设置为value false


首先,将标记对象的标志设置为false,

然后遍历对象树。marked从节点可到达的所有对象标记root均设置为true那些无法到达的对象的标志保留在value中false

如果无法从根对象访问对象,则认为该对象不可访问。


可达对象被标记为marked = true,不可达对象被标记marked = false

结果,所有marked不可达对象的标志都保留在value中false内存尚未释放,但是在完成标记阶段之后,一切准备就绪即可进行清洁阶段。

▍清洁阶段


在该算法的此阶段,将精确清除内存。此处,所有不可达的对象(其标志marked保留在value中的那些对象false)都会被垃圾收集器破坏。


垃圾回收后的对象树。所有标记标志设置为false的对象都会被垃圾收集器销毁,

垃圾收集会在JavaScript程序运行时定期执行。在此过程中,将释放可以释放的内存。

可能在这里出现以下问题:“如果垃圾收集器删除了所有标记为不可访问的对象-如何造成内存泄漏?”。

这里的重点是,如果应用程序不需要该对象,则垃圾回收器将不会对其进行处理,但是您仍然可以从对象树的根节点访问该对象。

该算法无法知道应用程序是否将使用它可以访问的某些内存。只有程序员才具有这种知识。

角度内存泄漏


大多数情况下,当重复重新渲染组件时,随着时间的流逝会发生内存泄漏。例如-通过路由,或使用指令的结果*ngIf假设某些高级用户整天使用该应用程序而不更新浏览器中的应用程序页面。

为了重现此场景,我们将创建两个组件的构造。这些将是AppComponent的组成部分SubComponent

@Component({
  selector: 'app-root',
  template: `<app-sub *ngIf="hide"></app-sub>`
})
export class AppComponent {
  hide = false;

  constructor() {
    setInterval(() => this.hide = !this.hide, 50);
  }
}

组件模板AppComponent使用component app-sub。这里最有趣的是,我们的组件使用的功能setIntervalhide每50毫秒切换一次标志。这导致每50 ms重新渲染一个组件app-sub。即,执行类的新实例的创建SubComponent。此代码模仿了整天都在使用Web应用程序而不刷新浏览器中的页面的用户的行为。

在中SubComponent,我们实现了不同的方案,在这些方案的使用中,随着时间的流逝,应用程序使用的内存量开始出现变化。注意组件AppComponent始终保持不变。在每种情况下,我们都会通过分析浏览器进程的内存消耗来找出我们正在处理的是否是内存泄漏。

如果该进程的内存消耗随着时间的推移而增加,则意味着我们面临着内存泄漏。如果一个进程或多或少地使用了一定数量的内存,则这意味着没有内存泄漏,或者该泄漏(尽管存在)没有以明显的方式表现出来。

▍场景1:巨大的for循环


我们的第一个场景由运行100,000次的循环表示。在循环中,将随机值添加到数组中。我们不要忘记该组件每50毫秒重新渲染一次。看一下代码,然后考虑我们是否造成了内存泄漏。

@Component({
  selector:'app-sub',
  // ...
})
export class SubComponent {
  arr = [];

  constructor() {
    for (let i = 0; i < 100000; ++i) {
      this.arr.push(Math.random());
    }
  }
}

尽管此类代码不应发送到生产环境,但不会造成内存泄漏。即,存储器消耗不超过限制为15MB的值的范围。结果,没有内存泄漏。下面我们将讨论为什么会这样。

▍方案2:BehaviorSubject订阅


在这种情况下,我们订阅BehaviorSubject并将一个值分配给一个常量。此代码中是否存在内存泄漏?和以前一样,不要忘记该组件每50毫秒渲染一次。

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  
  constructor() {
    this.subject.subscribe(value => {
        const foo = value;
    });
  }
}

与前面的示例一样,这里没有内存泄漏。

▍场景3:为预订中的类字段分配值


在这里,几乎提供了与先前示例相同的代码。主要区别在于,该值不是分配给常量,而是分配给类字段。现在,您是否认为代码中存在泄漏?

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  randomValue = 0;
  
  constructor() {
    this.subject.subscribe(value => {
        this.randomValue = value;
    });
  }
}

如果您认为这里没有泄漏-绝对正确。

在方案1中,没有订阅。在场景2和3中,我们订阅了在组件中初始化的观察对象的流。订阅组件流程就好像我们很安全。

但是,如果我们在方案中增加服务呢?

使用服务的方案


在以下情况下,我们将回顾以上示例,但是这次我们将订阅服务提供的流DummyService这是服务代码。

@Injectable({
  providedIn: 'root'
})
export class DummyService {

   some$ = new BehaviorSubject<number>(42);
}

我们面前的是一项简单的服务。这只是一种some$以公共类字段的形式提供流(的服务

▍场景4:订阅流并将值分配给本地常量


在这里,我们将重新创建与先前已经描述的相同的方案。但是这一次,我们订阅流some$DummyService,而不是组件的领域。

有内存泄漏吗?同样,在回答此问题时,请记住该组件已在AppComponent其中使用和渲染多次。

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        const foo = value;
    });
  }
}

现在我们终于造成了内存泄漏。但这是一个小小的泄漏。所谓“小泄漏”,是指随着时间的流逝,导致消耗的内存量缓慢增加。这种增加几乎没有引起注意,但是对堆快照的粗略检查表明存在许多未删除的副本Subscriber

▍方案5:订阅服务并为类字段分配值


在这里,我们再次订阅dummyService但是这次我们将结果值分配给class字段,而不是局部常量。

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  randomValue = 0;
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        this.randomValue = value;
    });
  }
}

在这里,我们终于造成了严重的内存泄漏。一分钟内,内存消耗迅速超过1 GB。让我们谈谈为什么会这样。

▍什么时候发生内存泄漏?


您可能已经注意到,在前三种情况下,我们无法创建内存泄漏。这三种情况有一个共同点:所有链接都位于组件本地。

当我们订阅一个可观察对象时,它存储了一个订阅者列表。我们的回调也在此列表中,该回调可以引用我们的组件。


无内存泄漏

当组件被销毁时,即当Angular不再具有链接时,这意味着无法从根节点访问该组件,也无法从根节点访问观察到的对象及其订阅者列表。结果,整个组件对象被垃圾回收。

只要我们订阅了可观察对象(该链接仅在组件内),就不会出现问题。但是,当服务开始起作用时,情况就发生了变化。


内存泄漏

一旦我们订阅了服务或另一个类提供的可观察对象,我们就创建了内存泄漏。这是由于观察到的对象,因为有其订阅者列表。因此,尽管Angular没有对组件的直接引用,但可以从根节点访问回调及其组件。结果,垃圾收集器不会触摸相应的对象。

我会澄清:您可以使用此类构造,但是您需要正确使用它们,而不是像我们一样。

正确的订阅工作


为了避免内存泄漏,通过不再需要订阅时执行此操作来正确取消订阅是很重要的。例如,当一个组件被破坏时。有许多方法可以退订观察到的对象。

向大型公司项目所有者提供建议的经验表明,在这种情况下,最好destroy$将团队创建的实体new Subject<void>()与操作员结合使用takeUntil

@Component({
  selector:'app-sub',
  // ...
})
export class SubComponent implements OnDestroy {

  private destroy$: Subject<void> = new Subject<void>();
  randomNumber = 0;

  constructor(private dummyService: DummyService) {
      dummyService.some$.pipe(
          takeUntil(this.destroy$)
      ).subscribe(value => this.randomNumber = value);
  }

  ngOnDestroy(): void {
      this.destroy$.next();
      this.destroy$.complete();
  }
}

在此,在销毁组件之后,我们使用destroy$and运算符取消订阅takeUntil

我们在组件中实现了生命周期挂钩ngOnDestroy每次销毁组件时,我们都会调用destroy$方法nextcomplete

该呼叫complete非常重要,因为此呼叫会清除中的订阅destroy$

然后,我们使用运算符takeUntil并将其传递给我们的流destroy$这样可确保在销毁组件之后清除订阅(即,我们已取消订阅)。

如何记住清除订阅?


在Hook生命周期中 很容易忘记添加组件destroy$而忘记调用尽管事实上我是为从事项目工作的团队讲授这些的,但我经常还是自己忘记了这一点。 幸运的是,有一个很棒的Linter规则,它是一组规则的一部分,该规则使您可以确保从订阅中正确取消订阅。您可以设置如下规则集:nextcompletengOnDestroy



npm install @angular-extensions/lint-rules --save-dev

然后,它必须连接到tslint.json

{
  "extends": [
    "tslint:recommended",
    "@angular-extensions/lint-rules"
  ]
}

我强烈建议您在项目中使用这套规则。这样可以节省大量的调试时间来查找内存泄漏的来源。

摘要


在Angular中,很容易造成导致内存泄漏的情况。显然,即使在很小的地方更改代码也不应引起内存泄漏,这可能会导致严重的不利后果。

避免内存泄漏的最佳方法是正确管理您的订阅。不幸的是,清理订阅的操作需要开发人员的高度准确性。这很容易忘记。因此,建议您应用规则@angular-extensions/lint-rules来帮助组织订阅的正确操作。

这是带有此材料基础代码存储库。

您在Angular应用程序中是否遇到内存泄漏?


All Articles