你是否犯了这10个导致应用变慢的 Angular 性能错误?

发布: (2025年12月18日 GMT+8 17:00)
13 min read
原文: Dev.to

Source: Dev.to

概述

想象一下你的 Angular 应用在高峰期出现卡顿,令用户沮丧,关键指标直线下降。默认的变更检测和 *ngFor 等过时的做法往往是罪魁祸首,但 Angular 17+ 通过 signalszoneless apps@for/@if 控制流以及 Angular 21 的实验性 Signal Forms 提供了更清晰的响应式方案来对抗这些问题。

本指南针对 前 10 大性能错误 提供 Angular 21+ 的解决方案,展示使用 @for/@if 和基于 signal 的 form() 的代码片段,并配合 Angular DevTools 等工具。无论是新手调试慢速问题、专业人士使用 signals 进行规模化,还是利益相关者了解现代 Angular 如何提升用户留存,都是理想的参考。

1️⃣ 默认变更检测

Angular 中的默认变更检测通过 Zone.js 在每个事件全局触发,扫描整个组件树,在层级深的复杂应用中浪费 CPU 周期。

解决方案:

  • signalsOnPush 策略一起使用。
  • Signals 提供细粒度、无 Zone 的更新,只针对已改变的子树,从而大幅削减不必要的检查。

从宽泛的全局扫描转向精确的响应式更新,可显著提升大型应用的性能。

2️⃣ OnPush 与不可变数据

依赖默认策略会在每个周期检查所有组件,即使是未改变的组件,也会导致运行时开销膨胀。

OnPush 会跳过未触碰的子树,除非输入发生变更或事件触发,但它 要求不可变数据——对对象进行突变会悄然失效,因为 Angular 使用引用相等性(Object.is())。

将其与 signals 结合,在输入变化时自动标记脏状态——无需手动调整。

3️⃣ 手动订阅 vs. Async Pipe

在组件中手动使用 .subscribe() 会创建额外的状态变量,触发完整的检测周期,导致内存泄漏和冗余检查。

Fix:

  • 使用 async pipe —— 它会自动 subscribe/unsubscribe,仅在有新值发出时标记 OnPush 组件为脏,并且可以无缝配合 toSignal() 使用,处理响应式流时无需 Zone.js 的猴子补丁。

放弃手动订阅,让 pipe 来承担繁重的工作,以获得更简洁的代码。

4️⃣ 计算信号 vs. 模板观察者

具有嵌套属性链的复杂模板会生成不断重新评估的观察者,在全局运行期间严重拖慢性能。

解决方案:

  • 使用 计算信号 懒惰地派生状态——仅在依赖变化时重新计算——消除多余的观察者,同时缓存结果以提升速度。

对过滤后的列表、总计等进行记忆化,保持模板简洁。

5️⃣ 示例:重构用户列表组件

❌ 之前 – 默认变更检测 & 手动状态

@Component({ changeDetection: ChangeDetectionStrategy.Default })
export class UserList {
  protected users: User[] = [];
  protected loading = false;

  ngOnInit() {
    this.loading = true;

    this.userService.getUsers().pipe(
      take(1),
      tap((users) => this.users = users),
      finalize(() => this.loading.set(false))
    ).subscribe();
  }
}

✅ 之后 – OnPush + Signals + @for/@if

import { ChangeDetectionStrategy, Component, computed, signal, input } from '@angular/core';
import { take, tap, finalize } from 'rxjs/operators';

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (loading()) { Loading... }

    @for (user of filteredUsers(); track user.id) {
      - {{ user.name }}
    }
  `
})
export class UserList {
  /** Signal inputs auto‑track */
  readonly users = input([]);
  readonly filter = input('');

  protected loading = signal(false);

  protected filteredUsers = computed(() =>
    this.users().filter(u => u.name.includes(this.filter()))
  );

  constructor(private userService: UserService) {
    // Start loading immediately before subscription
    this.loading.set(true);

    this.userService.getUsers().pipe(
      take(1),                     // Prevent unnecessary work if source is chatty
      tap((users) => this.users.set(users)),
      finalize(() => this.loading.set(false))
    ).subscribe();
  }
}

结果: DevTools 分析器显示 检查次数减少 70% 以上——仅在过滤器调整或数据加载时更新列表。Signals + OnPush 消除浪费的循环,使应用感觉更流畅。

6️⃣ 动态列表 – @for vs. *ngFor

动态列表在使用过时的 *ngFor 且没有适当的跟踪时,往往在每次更新时会重新创建整个 DOM,导致在大数据集上性能迟缓。

  • Angular 17 引入了 @for 语法,要求提供跟踪表达式(可以把它看作是防止随意循环的内置护栏)。
  • 开发者仍然坚持使用没有 trackBy*ngFor,导致 Angular 在即使是添加一个项目这样的小改动时也会拆除并重建整个列表。基准测试显示,在 100 + 项目 的操作中,若不使用跟踪,耗时会从 毫秒级跳升到秒级

解决方案:

@for (item of items; track item.id) {
  - {{ item.name }}
}
  • 不需要任何导入。社区测试报告在大列表上 ~90 % 更快的差异计算

7️⃣ Bundle Bloat – Tree Shaking & Budgets

  • 导入整个库(例如 Moment.js)而不进行 tree‑shaking,会使 main.js 膨胀至 500 KB 以上,导致初始加载变慢——在移动端尤为痛苦。
  • Angular 的 esbuild(自 v17 起默认使用)会积极剔除死代码,但你必须在 angular.json启用生产预算,才能及早捕捉体积膨胀:
{
  "projects": {
    "your-app": {
      "architect": {
        "build": {
          "configurations": {
            "production": {
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "2mb",
                  "maximumError": "5mb"
                }
              ]
            }
          }
        }
      }
    }
  }
}
  • 使用 Gzip 或 Brotli 可以将 bundle 压缩 80 %,把 2 MB 的文件压缩到更小的体积。
  • 对重量模块进行懒加载,以保持初始 bundle 的精简:
import('./admin/admin.component').then(m => m.AdminComponent);
  • 这可以为企业级应用将加载时间缩短 50‑70 %
  • 运行 ng generate @angular/core:control-flow 迁移循环,然后使用 source‑map‑explorer 分析 bundle,快速发现优化点。

9️⃣ 快速演示 – *ngFor@for

*ngFor="let item of items; trackBy: trackById" 替换为 @for (item of items; track item.id),在一个 1 000‑项 列表上。

  • DevTools profiler 显示重新渲染时间从 500 ms+ 降至 50 ms 以下
  • Angular 现在智能地仅追加/更新已更改的项。

🔟 未清理的 RxJS 订阅

未清理的 RxJS 订阅会在单页应用中导致内存泄漏,因为它们使已销毁的组件仍保留在内存中,随时间推移导致性能下降。

  • 解决方案: 优先使用 async pipe(或 toSignal),它会自动取消订阅,或者使用 takeUntilDestroyed 手动管理订阅(Angular 17+)。

📈 Takeaway

  • Signals + OnPush = 细粒度、无 Zone 的响应式。
  • @for/@if = 内置的高性能控制流。
  • Async pipe / toSignal = 安全、无泄漏的订阅。
  • Computed signals = 懒惰、记忆化的状态。
  • Lazy loading, budgets, and esbuild = 保持包体积精简。

采用这些模式,您的 Angular 应用将更流畅、更具可扩展性,并且为未来做好准备——让用户满意,令利益相关者印象深刻。 🚀

📉 Observable 清理与内存泄漏

Angular 16+ 引入了 takeUntilDestroyed(),在组件销毁时自动取消订阅 observable,省去大多数手动清理工作。Signals 进一步降低对订阅的依赖,Angular v21+ 实验性的 Signal Forms 可以完全去除 observable 的使用。

  • 典型问题 – 忘记取消订阅会迫使手动编写 ngOnDestroy 逻辑,容易出错(尤其是使用 mergeMap 等高阶操作符时)。
  • 现代解决方案
// RxJS pipe with automatic cleanup
this.dataService.getItems()
    .pipe(takeUntilDestroyed())
    .subscribe();

// Convert to a signal (auto‑unsubscribes on destroy)
items = toSignal(this.dataService.getItems());
  • toSignal() 在组件销毁时自动取消订阅。
  • effect() 处理副作用并内置清理机制。

📝 表单与变更检测

大量使用 [(ngModel)] 或传统响应式表单会导致无尽的变更检测循环和用于验证的可观察链。

切换到实验性的 form() API(Angular 21):

import { form, signalControl } from '@angular/forms';

export class MyComponent {
  form = form({
    email: signalControl('')   // type‑safe, signal‑based control
  });
}
  • 好处:推断的类型安全、更简洁的状态管理、无需订阅样板代码,以及在仪表盘或复杂 UI 中更低的内存开销。

🌐 Direct DOM Access

使用 document.querySelector() 绕过了 Angular 的变更检测,并破坏了 SSR/hydration 的安全性。

Recommended approach

import { viewChild, effect, isPlatformBrowser } from '@angular/core';
import { ElementRef } from '@angular/common';

export class MyComponent {
  element = viewChild('myElement');

  constructor() {
    effect(() => {
      if (isPlatformBrowser && this.element()) {
        this.element()!.nativeElement.focus();
      }
    });
  }
}
  • viewChild() 提供类型安全、响应式的访问。
  • isPlatformBrowser() 配合使用,以实现 SSR 安全的查询。

🔀 RxJS 与 Signals 的派生状态比较

对派生状态使用 RxJS 管道进行过度工程化会产生难以调试且占用内存的“流纠结”。

在 UI 状态中优先使用 signals:

import { computed, effect } from '@angular/core';

export class CounterComponent {
  count = signal(0);
  doubleCount = computed(() => this.count() * 2);

  // Side‑effect without a subscription
  logEffect = effect(() => console.log('Count:', this.count()));
}
  • 对于 HTTP 流或复杂的事件管道,使用 RxJS
  • 对于 UI 状态的派生,使用 signals——更快、细粒度更新,无需订阅。

🚀 迁移路径

  1. 包装旧的 observables

    const items = toSignal(this.obs$, { initialValue: null });

    takeUntilDestroyed() 结合使用,作为桥梁。

  2. 采用 Signal 形式 来消除 valueChanges observables。

  3. 使用 Angular DevTools 验证 – 检测内存泄漏并确认组件真正实现销毁清理。

⚡️ Angular 17+ 性能提升

  • @for 语法 替代传统的 *ngFor
  • OnPush + Signals 实现细粒度的变更检测。
  • @defer 用于惰性加载大型组件。
  • Signal Forms 简化表单处理。

这些改进可以将加载时间缩短 50 – 90 %,并显著提升 Core Web Vitals。

升级步骤

# Convert templates to the new control‑flow syntax
ng g @angular/core:control-flow
  • 将组件的变更检测模式切换为 OnPush
  • 采用 signals 进行响应式更新。
  • 使用 @defer 实现惰性加载。
  • 将表单迁移到 Signal Forms

使用 Chrome DevToolsLighthouse 来衡量效果。

📚 进一步阅读

  • Angular 性能最佳实践
  • Signals 指南
  • 无 Zone 更改检测文档

这些资源展示了如何让 Angular 应用更快、更精简、面向未来,同时提升 Core Web Vitals。

📦 为您的业务准备的工具和模板

我已经汇集了一系列可直接使用的着陆页、自动化入门套件和模板,帮助您更快启动。

  • ScaleSail.io – 查看工具包。
  • LinkedIn – 关注我,获取可操作的 Angular 与现代前端洞见。
  • Twitter / X – 获取快速技巧和真实项目案例的最新信息。

您的支持为前端和创业社区提供更多实用指南和工具。让我们一起继续构建! 🚀

Back to Blog

相关文章

阅读更多 »