Angular 最佳实践

angular-best-practices
分类编程
作者Agentic Awesome Skills 社区
许可MIT
评分4.40/5
使用6.8K

Angular 最佳实践

Angular 应用的全面性能优化指南。包含用于消除性能瓶颈、优化包体积和提升渲染效率的优先级规则。

使用场景

在以下情况参考此指南:
  • 编写新的 Angular 组件或页面
  • 实现数据获取模式
  • 审查代码中的性能问题
  • 重构现有的 Angular 代码
  • 优化包体积或加载时间
  • 配置 SSR/注水(hydration)

---

按优先级划分的规则类别

| 优先级 | 类别 | 影响 | 重点 |
| -------- | --------------------- | ---------- | ------------------------------- |
| 1 | 变更检测 (Change Detection) | 极高 (CRITICAL) | Signals, OnPush, Zoneless |
| 2 | 异步瀑布流 (Async Waterfalls) | 极高 (CRITICAL) | RxJS 模式, SSR 预加载 |
| 3 | 包优化 (Bundle Optimization) | 极高 (CRITICAL) | 懒加载, Tree shaking |
| 4 | 渲染性能 (Rendering Performance) | 高 (HIGH) | @defer, trackBy, 虚拟滚动 |
| 5 | 服务端渲染 (SSR) | 高 (HIGH) | 注水 (Hydration), 预渲染 |
| 6 | 模板优化 (Template Optimization) | 中 (MEDIUM) | 控制流, Pipes |
| 7 | 状态管理 (State Management) | 中 (MEDIUM) | Signal 模式, Selectors |
| 8 | 内存管理 (Memory Management) | 低-中 (LOW-MEDIUM) | 资源清理, 订阅管理 |

---

1. 变更检测 (CRITICAL)

使用 OnPush 变更检测

typescript
// 正确 - 使用 Signals 的 OnPush
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: <div>{{ count() }}</div>,
})
export class CounterComponent {
  count = signal(0);
}

// 错误 - 默认变更检测
@Component({
template: <div>{{ count }}</div>, // 每个周期都会被检查
})
export class CounterComponent {
count = 0;
}

优先使用 Signals 而非可变属性

typescript
// 正确 - Signals 触发精确更新
@Component({
  template: 
    <h1>{{ title() }}</h1>
    <p>Count: {{ count() }}</p>
  ,
})
export class DashboardComponent {
  title = signal("Dashboard");
  count = signal(0);
}

// 错误 - 可变属性需要 zone.js 检查
@Component({
template:
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
,
})
export class DashboardComponent {
title = "Dashboard";
count = 0;
}

为新项目启用 Zoneless

typescript
// main.ts - Zoneless Angular (v20+)
bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});

优势:

  • 异步 API 不再需要 zone.js 补丁
  • 包体积更小(节省约 15KB)
  • 调试时的堆栈跟踪更清晰
  • 更好的微前端兼容性

---

2. 异步操作与瀑布流 (CRITICAL)

消除顺序数据获取

typescript
// 错误 - 嵌套订阅创建了瀑布流
this.route.params.subscribe((params) => {
  // 1. 等待参数
  this.userService.getUser(params.id).subscribe((user) => {
    // 2. 等待用户数据
    this.postsService.getPosts(user.id).subscribe((posts) => {
      // 3. 等待文章数据
    });
  });
});

// 正确 - 使用 forkJoin 并行执行
forkJoin({
user: this.userService.getUser(id),
posts: this.postsService.getPosts(id),
}).subscr


ibe((data) => {
// 并行获取
});

// 正确 - 使用 switchMap 扁平化依赖调用
this.route.params
.pipe(
map((p) => p.id),
switchMap((id) => this.userService.getUser(id)),
)
.subscribe();

code
### 避免 SSR 中的客户端级联请求 (Waterfalls)
typescript
// 正确 - 对关键数据使用 resolver 或阻塞式注水 (blocking hydration)
export const route: Route = {
path: "profile/:id",
resolve: { data: profileResolver }, // 在导航前由服务器获取
component: ProfileComponent,
};

// 错误 - 组件在初始化时获取数据
class ProfileComponent implements OnInit {
ngOnInit() {
// 仅在 JS 加载且组件渲染后才开始请求
this.http.get("/api/profile").subscribe();
}
}

code
---

3. 包体积优化 (至关重要)

路由懒加载

typescript // 正确 - 懒加载功能路由 export const routes: Routes = [ { path: "admin", loadChildren: () => import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES), }, { path: "dashboard", loadComponent: () => import("./dashboard/dashboard.component").then( (m) => m.DashboardComponent, ), }, ];

// 错误 - 全部预加载
import { AdminModule } from "./admin/admin.module";
export const routes: Routes = [
{ path: "admin", component: AdminComponent }, // 包含在主 bundle 中
];

code
### 对重量级组件使用 @defer
html
<!-- 正确 - 重量级组件按需加载 -->
@defer (on viewport) {
<app-analytics-chart [data]="data()" />
} @placeholder {
<div class="chart-skeleton"></div>
}

<!-- 错误 - 重量级组件包含在初始 bundle 中 -->
<app-analytics-chart [data]="data()" />

code
### 避免 Barrel 文件的重新导出 (Re-exports)
typescript
// 错误 - 导入整个 barrel 文件,破坏 Tree-shaking
import { Button, Modal, Table } from "@shared/components";

// 正确 - 直接导入
import { Button } from "@shared/components/button/button.component";
import { Modal } from "@shared/components/modal/modal.component";

code
### 动态导入第三方库
typescript
// 正确 - 按需加载重量级库
async loadChart() {
const { Chart } = await import('chart.js');
this.chart = new Chart(this.canvas, config);
}

// 错误 - 将 Chart.js 打包在主 chunk 中
import { Chart } from 'chart.js';

code
---

4. 渲染性能 (高优先级)

在 @for 中始终使用 trackBy

html <!-- 正确 - 高效的 DOM 更新 --> @for (item of items(); track item.id) { <app-item-card [item]="item" /> }

<!-- 错误 - 任何变更都会导致整个列表重新渲染 -->
@for (item of items(); track $index) {
<app-item-card [item]="item" />
}

code
### 对大列表使用虚拟滚动 (Virtual Scrolling)
typescript
import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';

@Component({
imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll],
template:
<cdk-virtual-scroll-viewport itemSize="50" class="viewport">
<div *cdkVirtualFor="let item of items" class="item">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>

})

code
### 优先使用纯管道 (Pure Pipes) 而非方法
typescript
// 正确 - 纯管道,具有记忆化特性
@Pipe({ name: 'filterActive', standalone: true, pure: true })
export class FilterActivePipe implements PipeTransform {
transform(items: Item[]): Item[] {
return items.filter(i => i.active);
}
}

// 模板
@for (item of items() | filterActive; track item.id) { ... }

// 错误 - 每次变更检测都会调用该方法
@for (item of getActiveIt

code
ems(); track item.id) { ... }

使用 computed() 处理派生数据

typescript
// 正确 - 使用 Computed,直到依赖项更改才重新计算(带缓存)
export class ProductStore {
  products = signal<Product[]>([]);
  filter = signal('');

filteredProducts = computed(() => {
const f = this.filter().toLowerCase();
return this.products().filter(p =>
p.name.toLowerCase().includes(f)
);
});
}

// 错误 - 每次访问都会重新计算
get filteredProducts() {
return this.products.filter(p =>
p.name.toLowerCase().includes(this.filter)
);
}

---

5. 服务端渲染 (HIGH)

配置增量注水 (Incremental Hydration)

typescript
// app.config.ts
import {
  provideClientHydration,
  withIncrementalHydration,
} from "@angular/platform-browser";

export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration(), withEventReplay()),
],
};

延迟加载非关键内容

html
<!-- 关键的首屏内容 -->
<app-header />
<app-hero />

<!-- 屏下内容,通过注水触发器延迟加载 -->
@defer (hydrate on viewport) {
<app-product-grid />
} @defer (hydrate on interaction) {
<app-chat-widget />
}

使用 TransferState 处理 SSR 数据

typescript
@Injectable({ providedIn: "root" })
export class DataService {
  private http = inject(HttpClient);
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);

getData(key: string): Observable<Data> {
const stateKey = makeStateKey<Data>(key);

if (isPlatformBrowser(this.platformId)) {
const cached = this.transferState.get(stateKey, null);
if (cached) {
this.transferState.remove(stateKey);
return of(cached);
}
}

return this.http.get<Data>(/api/${key}).pipe(
tap((data) => {
if (isPlatformServer(this.platformId)) {
this.transferState.set(stateKey, data);
}
}),
);
}
}

---

6. 模板优化 (MEDIUM)

使用新的控制流语法

html
<!-- 正确 - 新控制流(速度更快,包体积更小) -->
@if (user()) {
<span>{{ user()!.name }}</span>
} @else {
<span>Guest</span>
} @for (item of items(); track item.id) {
<app-item [item]="item" />
} @empty {
<p>No items</p>
}

<!-- 错误 - 旧版结构指令 -->
<span *ngIf="user; else guest">{{ user.name }}</span>
<ng-template #guest><span>Guest</span></ng-template>

避免复杂的模板表达式

typescript
// 正确 - 在组件中预先计算
class Component {
  items = signal<Item[]>([]);
  sortedItems = computed(() =>
    [...this.items()].sort((a, b) => a.name.localeCompare(b.name))
  );
}

// 模板
@for (item of sortedItems(); track item.id) { ... }

// 错误 - 每次渲染都在模板中进行排序
@for (item of items() | sort:'name'; track item.id) { ... }

---

7. 状态管理 (MEDIUM)

使用 Selector 防止不必要的重新渲染

typescript
// 正确 - 选择性订阅
@Component({
  template: <span>{{ userName() }}</span>,
})
class HeaderComponent {
  private store = inject(Store);
  // 仅在 userName 更改时重新渲染
  userName = this.store.selectSignal(selectUserName);
}

// 错误 - 订阅整个状态
@Component({
template: <span>{{ state().user.name }}</span>,
})
class HeaderComponent {
private store = inject(Store);
// 任何状态更改都会触发重新渲染
state = toSignal(this.store);
}

将状态与功能模块共置 (Colocate)

typescript
// 正确 -
功能范围限定的 Store (Feature-scoped store)
typescript
@Injectable() // 注意:不要使用 providedIn: 'root'
export class ProductStore { ... }

@Component({
providers: [ProductStore], // 作用域限定在组件树中
})
export class ProductPageComponent {
store = inject(ProductStore);
}

// 错误做法 - 所有内容都放在全局 Store 中
@Injectable({ providedIn: 'root' })
export class GlobalStore {
// 包含所有应用状态 - 难以进行 Tree-shaking
}

---

8. 内存管理 (低-中优先级)

为订阅使用 takeUntilDestroyed

typescript
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({...})
export class DataComponent {
private destroyRef = inject(DestroyRef);

constructor() {
this.data$.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(data => this.process(data));
}
}

// 错误做法 - 手动管理订阅
export class DataComponent implements OnDestroy {
private subscription!: Subscription;

ngOnInit() {
this.subscription = this.data$.subscribe(...);
}

ngOnDestroy() {
this.subscription.unsubscribe(); // 容易遗忘
}
}

优先使用 Signals 而非订阅

typescript
// 正确做法 - 无需订阅
@Component({
  template: <div>{{ data().name }}</div>,
})
export class Component {
  data = toSignal(this.service.data$, { initialValue: null });
}

// 错误做法 - 手动订阅
@Component({
template: <div>{{ data?.name }}</div>,
})
export class Component implements OnInit, OnDestroy {
data: Data | null = null;
private sub!: Subscription;

ngOnInit() {
this.sub = this.service.data$.subscribe((d) => (this.data = d));
}

ngOnDestroy() {
this.sub.unsubscribe();
}
}

---

快速参考清单

新建组件

  • [ ] changeDetection: ChangeDetectionStrategy.OnPush
  • [ ] standalone: true
  • [ ] 使用 Signals 管理状态 (signal(), input(), output())
  • [ ] 使用 inject() 注入依赖
  • [ ] @for 必须包含 track 表达式

性能审查

  • [ ] 模板中没有方法调用 (请使用 Pipe 或 computed)
  • [ ] 大列表已实现虚拟滚动
  • [ ] 重量级组件已延迟加载
  • [ ] 路由已实现懒加载
  • [ ] 第三方库已动态导入

SSR 检查

  • [ ] 已配置 Hydration (注水)
  • [ ] 关键内容优先渲染
  • [ ] 非关键内容使用 @defer (hydrate on ...)
  • [ ] 服务器获取的数据使用 TransferState

---

相关资源