Angular 最佳实践
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 变更检测
// 正确 - 使用 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 而非可变属性
// 正确 - 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
// main.ts - Zoneless Angular (v20+)
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});优势:
- 异步 API 不再需要 zone.js 补丁
- 包体积更小(节省约 15KB)
- 调试时的堆栈跟踪更清晰
- 更好的微前端兼容性
---
2. 异步操作与瀑布流 (CRITICAL)
消除顺序数据获取
// 错误 - 嵌套订阅创建了瀑布流
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();
### 避免 SSR 中的客户端级联请求 (Waterfalls)// 正确 - 对关键数据使用 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();
}
}
---
3. 包体积优化 (至关重要)
路由懒加载
// 错误 - 全部预加载
import { AdminModule } from "./admin/admin.module";
export const routes: Routes = [
{ path: "admin", component: AdminComponent }, // 包含在主 bundle 中
];
### 对重量级组件使用 @defer<!-- 正确 - 重量级组件按需加载 -->
@defer (on viewport) {
<app-analytics-chart [data]="data()" />
} @placeholder {
<div class="chart-skeleton"></div>
}
<!-- 错误 - 重量级组件包含在初始 bundle 中 -->
<app-analytics-chart [data]="data()" />
### 避免 Barrel 文件的重新导出 (Re-exports)// 错误 - 导入整个 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";
### 动态导入第三方库// 正确 - 按需加载重量级库
async loadChart() {
const { Chart } = await import('chart.js');
this.chart = new Chart(this.canvas, config);
}
// 错误 - 将 Chart.js 打包在主 chunk 中
import { Chart } from 'chart.js';
---
4. 渲染性能 (高优先级)
在 @for 中始终使用 trackBy
<!-- 错误 - 任何变更都会导致整个列表重新渲染 -->
@for (item of items(); track $index) {
<app-item-card [item]="item" />
}
### 对大列表使用虚拟滚动 (Virtual Scrolling)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>
})
### 优先使用纯管道 (Pure Pipes) 而非方法// 正确 - 纯管道,具有记忆化特性
@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
ems(); track item.id) { ... }使用 computed() 处理派生数据
// 正确 - 使用 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)
// app.config.ts
import {
provideClientHydration,
withIncrementalHydration,
} from "@angular/platform-browser";
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration(), withEventReplay()),
],
};
延迟加载非关键内容
<!-- 关键的首屏内容 -->
<app-header />
<app-hero />
<!-- 屏下内容,通过注水触发器延迟加载 -->
@defer (hydrate on viewport) {
<app-product-grid />
} @defer (hydrate on interaction) {
<app-chat-widget />
}
使用 TransferState 处理 SSR 数据
@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)
使用新的控制流语法
<!-- 正确 - 新控制流(速度更快,包体积更小) -->
@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>
避免复杂的模板表达式
// 正确 - 在组件中预先计算
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 防止不必要的重新渲染
// 正确 - 选择性订阅
@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)
// 正确 -@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
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 而非订阅
// 正确做法 - 无需订阅
@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
---