A

Angular 中的内存泄漏调试

2025-06-08

Angular 中的内存泄漏调试

最容易发生内存泄漏的情况,以及如何使用 Chrome DevTools 处理它们。

本文最初Giancarlo Buomprisco在Bits and Pieces上发表

内存泄漏简介

构建大型应用程序需要编写大量代码、复杂的页面、冗长的列表以及数百个(甚至更多)的组件。如果您曾经开发过一些大型 Web 应用程序,您可能已经发现自己曾与内存泄漏斗争了数小时之久。

在本文中,我想向您介绍几种最容易发生内存泄漏的情况,以及如何借助强大的Chrome DevTools来处理这些情况。

前言:Angular 是一个在内存管理方面做得非常出色的框架:事实上,你几乎不需要做什么特别的事情来避免内存泄漏!
尽管如此,我还是发现自己遇到过一些导致内存泄漏的错误情况,最终导致我公司客户的用户体验大打折扣。

不好。

什么是内存泄漏?

通俗地说,当应用程序无法清除未使用的资源时就会发生内存泄漏。

如果应用程序的内存使用量越来越多,而没有填充新的资源(图像、文本、对象等),那么该应用程序可能会受到这种性能下降的影响。

提示:使用Bit ( Github ) 可以轻松地在您的项目之间共享和重用 Angular 组件、建议更新、同步更改并作为一个团队更快地构建。

不要浪费时间重写平庸的代码 - 构建出色的可重用 Angular 组件,使用Bit单独测试它们并在bit.dev上分享它们

带有 Bit 的组件:团队之间轻松共享项目带有 Bit 的组件:团队之间轻松共享项目

为什么内存泄漏如此棘手

内存泄漏最棘手的地方在于,它们很难被发现。与 CPU 使用率问题(会导致 UI 卡顿)不同,内存泄漏(尤其是在小型应用中)是一种更隐蔽的问题

如果不是同时负责 QA,大多数开发人员的工作方式是专注于他们的任务内容,他们很少需要切换页面数十次,创建和重新创建大型列表,或者通常执行内存泄漏自然显现的长期操作。

事实上,您的应用程序可能有数十个尚未被发现的内存泄漏!

如今,用户刷新页面的频率越来越低。作为一名金融从业者,我深有体会:交易员最讨厌刷新页面!办公室里的电脑很少重启,浏览器页面也一样。

这就是为什么保持长期会话的最佳性能至关重要:如果应用程序泄漏内存,用户在某些时候会意识到它变得更慢、更迟钝,并且可能会非常频繁地暂停。

我们不想让用户感到沮丧。对吧!?

调试过程

在本节中,我们将探讨 Angular 应用程序中的一些真实场景,在这些场景中最有可能遇到导致内存泄漏的潜在错误。

这篇文章的讽刺之处在于,我计划故意在我的代码中添加错误(使用我的实验项目Cryptofolio)来产生内存泄漏。

Cryptofolio 显示实时加密货币价格Cryptofolio 显示实时加密货币价格

事实证明,这根本没必要!内存泄漏已经存在了。为了重现这个问题,我只需要用 101 个价格器初始化应用——在各个页面之间来回切换几次,然后——内存就疯了!

注意:我使用的应用程序非常小,因此错误不会导致应用程序崩溃,更重要的是,保留在内存中的对象不会立即在堆快照中轻易找到。

使用性能监视器工具监视内存

我构建的应用程序允许我在两个单独的视图中显示价格:列表和仪表板;这两个是两个不同的页面,因此当您导航到另一个页面时,每个页面中包含的组件都应该被销毁和收集。

首先要做的是打开 Chrome Dev Tools,打开右侧面板,然后点击“更多工具” >“性能监视器”。蓝色图表中显示了我们应用程序的内存情况。

正如您在下图中看到的,每当我切换页面时,内存就会增加近 20mb!

切换页面时 Js 堆大小增加切换页面时 Js 堆大小增加

我不断地来回切换,这就是下面的结果:

性能监视器性能监视器

🔥154MB 内存,CPU 占用率 99%?显然有问题🔥

启动调试过程:内存快照

我在调试时做的第一件事就是分两个阶段记录内存快照:

  • 初始加载时,一旦应用程序稳定并且所有元素都已加载

  • 一旦初始数据被其他数据替换,就会再次触发。确保你的应用实际上没有添加额外的资源非常重要,除非这是一个 bug。例如,你可能正在切换页面或强制显示/隐藏某些元素

以上内容将允许我将它们与开发工具的内存快照进行比较。

提示:确保您还勾选了“事件监听器”:这将有助于了解事件监听器的数量是否正在增加。

要拍摄内存快照,请打开“开发者工具”->“内存”,选择“堆快照”,然后点击“拍摄快照”按钮。配置文件列在左侧,您可以比较它们,以直观地了解哪些对象已保留在内存中。

探索快照🧭

正如您在下图中看到的,我继续拍摄 2 个堆快照,列在左侧。

拍摄初始快照时,工具将显示当前快照的摘要,但您可以通过从对象上方的下拉菜单中选择“比较”来比较两个快照。

如果你像我一样,一直都是 Web 开发者,那么快照显示的列表可能看起来相当陌生、低级且不熟悉,但不要因此而退缩。最重要的是要有耐心,并了解那些可能导致内存泄漏的线索。

拍完快照后,我立即开始滚动浏览这些项目,寻找线索和熟悉的代码片段,其中一项立即引起了我的注意:MapSubscriber

这有点熟悉,不是吗?正如您在右侧的比较表中看到的,Delta显示添加的项目比删除的项目多。

堆快照堆快照

通过单击顶部面板中的某个项目,将立即将下面的面板重定向到其“保留器”或对象保留树。

我开始深入研究 Map 目标对象,直到找到项目,这是我们传递给 map运算符的函数,它指向项目文件之一 asser-pricer.component.ts 中的一行。

让我们看一下该行周围的上下文:它是商店的简单选择,它返回价格并将其映射到字符串。

另外,我使用 shareReplay(1) 将可观察的价格多播到获取趋势值(例如,自上次发布以来价格是上涨还是下跌)。

    this.price$ = this.pricesFacade
      .getPriceForAsset(this.asset)
      .pipe(
        filter<string>(Boolean),
        map(price => {
          return parseFloat(price).toFixed(2);
        }),
        shareReplay(1)
      );
Enter fullscreen mode Exit fullscreen mode

Rx 订阅🦊

让我们思考一下我们刚刚遇到的问题:问题显然是一个未订阅的可观察对象,它将组件保留在内存中。

无论我们被告知多少次清理我们的 Rx 订阅,根据我的经验,这是迄今为止Angular 应用程序中内存泄漏的最常见原因。

许多开发人员可能会想:开放订阅真的会对实际应用程序造成严重破坏吗?

是的,可以

特别是对于大型应用程序,如果泄漏发生在重复组件(列表,表格,无限滚动组件等)中,即使只有一个开放订阅也可能导致您的应用程序在内存中保留组件,直到订阅被清理。

您可能希望组件在销毁时被清理,例如:

  • 当用户导航到另一个页面时

  • 当用户用不同的选择替换/过滤元素时

虽然取消订阅是一个相当容易理解的概念,并且取消订阅本身(特别是使用异步管道)很容易,但在某些情况下,我们必须充分了解所使用的操作符,就像我的情况一样。

ShareReplay,你是什么?🤔

让我们回到正题上。

我们找到了导致内存泄漏的可能原因。我做的第一件事就是调试 shareReplay,以了解为什么订阅没有被取消订阅,这让我找到了它的源代码:

ShareReplay 的源代码ShareReplay 的源代码

取消订阅的长条件相当可疑——为什么?事实证明,尽管我读了很多相关文章和文档,但我还是忽略了这个操作符的一个非常重要的细节。

事实上,如果我们不指定属性 refCount: true ,订阅将永远不会被取消。为了充分理解为什么,我推荐你阅读这篇深入理解 Angular 的文章:ShareReplay 有何变化

为了解决这个问题,我做了以下更改:

    this.price$ = this.pricesFacade.getPriceForAsset(this.asset).pipe(
      filter<string>(Boolean),
      map(price => {
        return parseFloat(price).toFixed(2);
      }),
      shareReplay({
        bufferSize: 1,
        refCount: true
      })
    );
Enter fullscreen mode Exit fullscreen mode

现在我的应用程序的内存泄漏已经消失了!

但是让我们看看其他一些常见的场景——其中一些甚至出现在一些非常流行的 Angular 库中。

事件监听器

另一个常见的内存泄漏原因是 DOM 事件从未被注销。有些人可能认为使用 Angular 的渲染器可以解决这个问题,但这只有在事件定义在模板中时才会出现,就像异步管道一样。

让我们看一个简单而常见的组件示例,该组件在主体上注册一个滚动监听器,但从不取消注册该事件:

    @Component({...})
    export class ScrollComponent {
      constructor(private renderer: Renderer2) {}

      ngOnInit() {
        this.renderer.listen**(document.body, 'scroll', () => {
          this.updatePosition();
        });
      }

      updatePosition() { /* implementation */ }
    }
Enter fullscreen mode Exit fullscreen mode

确实,每次实例化 ScrollComponent 时都会造成内存泄漏 - 所以让我们修复它:

    @Component({...})
    export class ScrollComponent {
      private listeners = [];

      constructor(private renderer: Renderer2) {}

      ngOnInit() {
        const listener = this.renderer.listen(
          document.body, 
          'scroll', 
          () => {
            this.updatePosition();
          });

        this.listeners.push(listener);
      }

      ngOnDestroy() {
        this.listeners.forEach(listener => listener());
      }

      updatePosition() { /* implementation */ }
    }
Enter fullscreen mode Exit fullscreen mode

取消注册所有事件可防止组件 ScrollComponent 保留在内存中,并且一旦被销毁,它将与其子组件一起被清理。

Websocket 连接

类似地,WebSocket 连接在不使用时必须始终关闭。假设我们有一个组件 PricerComponent,它订阅 WebSocket 并显示传入的加密货币价格。

    @Component({
      selector: 'pricer',
      template: `
        <span>{{ id | titlecase }}:</span>
        <span>{{ ( price$ | async) || 'loading...' }}</span>
      `
    })
    export class PricerComponent  {
      @Input() id: string;

      public price$ = new Subject();
      private static Endpoint = 'wss://ws.coincap.io/prices/';
      private webSocket: WebSocket;

      ngOnInit() {
        this.webSocket = new WebSocket(
          this.getEndpoint(this.id)
        );

        this.webSocket.onmessage = (msg) => {
          const data = JSON.parse(msg.data);
          this.price$.next(data[this.id]);
        };
      }

      private getEndpoint(id: string) {
        return PricerComponent.Endpoint + '?assets=' + id;
      }
    }
Enter fullscreen mode Exit fullscreen mode

让我们解释一下这个片段:

  • 我们接收一个 ID 作为输入,并在每次组件初始化时通过 WebSocket 订阅它

  • WebSocket 连接什么时候清除?永远不会!我们又一次错过了 ngOnDestroy 这个黄金钩子,它无法在组件销毁时处理这个问题。

  • 这会产生多个问题:我们不仅要创建多个 WebSocket 连接,而且每次重新初始化 PricerComponent 时,我们都会将其保留在内存中

让我们拍一张快照并进行分析!

堆快照 WebSocket 示例堆快照 WebSocket 示例

按照上图,我首先将初始负载(具有 1 个定价器)与小会话后拍摄的另一个快照进行比较。

Delta 向我揭示了额外内存的分配位置,因此我开始在(闭包)树中挖掘以寻找回到我的代码的线索。

稍微滚动一下之后,我偶然发现了这个闭包,它让我准确地回到传递给 webSocket.onmessage 的回调!

诚然,调试这种情况相当容易,但在某些情况下,它可能会相当令人望而生畏。我们可以通过**命名函数**来简化调试过程,以便它们出现在内存快照中。

例如,我可以这样写:

    const onPriceReceived = (msg: MessageEvent) => {
      const data = JSON.parse(msg.data);
      this.price$.next(data[id]);
    };

    this.webSocket.onmessage = onPriceReceived;
Enter fullscreen mode Exit fullscreen mode

通过添加以下方法可以轻松解决此错误:

    ngOnDestroy() {
      this.webSocket.close();
    }
Enter fullscreen mode Exit fullscreen mode

您可以在此 Stackblitz 链接处查看完整示例

要点⭐

  • 内存泄漏很难发现和调试——我的建议是时不时地打开性能监视器,看看内存是否稳定

  • Angular 在管理内存方面做得很好;话虽如此,我们需要注意开放订阅(Observables、Subjects、NgRx Store Selections)、DOM 事件、WebSocket 连接等。

  • 学习如何使用好 Chrome 开发者工具!它对于调试性能和内存泄漏至关重要。即使看到这么多底层术语可能会让人感到畏惧,也要尽可能多地阅读和学习。

  • 命名闭包!它有助于提高可调试性,而且在我看来,它使代码更具可读性。

资源

如果您需要任何澄清,或者您认为某些内容不清楚或错误,请发表评论!

希望你喜欢这篇文章!如果喜欢,请在MediumTwitter或我的网站上关注我,获取更多关于软件开发、前端、RxJS、Typescript 等的文章!

鏂囩珷鏉ユ簮锛�https://dev.to/gc_psk/debugging-memory-leaks-in-angular-4m2o
PREV
使用 GraphQL Subscriptions 和 TypeScript 构建聊天应用:第 1 部分
NEXT
像专业人士一样调试 JavaScript