Skip to main content

UI 与性能最佳实践

本章节聚焦 如何让 Angular 在复杂业务下依旧保持“丝滑”体验,并通过反例+正例说明哪些写法会拖垮性能,以及如何改造才能逼近甚至达到 Vue / React 的体验。

结论先行:Angular 性能不好,多数时候是“写法问题”而不是框架本身问题。
只要合理使用变更检测策略(OnPush / signals)、拆分组件、控制订阅与 DOM 复杂度,Angular 完全可以做到与 Vue / React 同一水平。

1. 变更检测策略:从 Default 到 OnPush / Signals​

1.1 反例:全部使用 Default 策略 + 大组件​

  • 所有组件都是默认 ChangeDetectionStrategy.Default。
  • 页面状态全部挂在一个大组件上,任何字段变化都会触发整棵子树的检查。
  • 在模板里直接做复杂计算、filter/map,而不是在 TS 中预处理。

结果:

  • 列表稍微大一点(几十~几百条),或者有频繁的定时轮询 / WebSocket 更新时,整页频繁重算、卡顿明显。

1.2 推荐:容器 + 纯展示组件 + OnPush​

  • 按「容器(聪明)组件 + 纯展示(哑)组件」拆分:
    • 容器组件负责数据装配、调用 service。
    • 子组件尽量只通过 @Input() 接收数据、@Output() 发事件,不直接发起请求。
  • 对绝大多数组件使用:
@Component({
// ...
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserListComponent {
@Input() users: ReadonlyArray<User> = [];
}

这样,当 @Input() 引用未变化时,该组件不会重新渲染,大幅降低检查成本。

1.3 使用 Signals/Computed(Angular >=16)​

在新项目中,推荐尽量用 signals + computed 管理局部 UI 状态,替代层层 @Input() 传递与过多 RxJS 订阅:

  • signals 更新是 精确到使用点 的,比默认变更检测粒度更细。
  • 可减少“整颗树无脑检查”的情况。

实践建议:

  • 列表行组件、卡片组件、表单字段组件等 UI 单元,优先使用 OnPush + signals。
  • 只在必须感知全局变化的入口容器上保留 Default / effects。

2. 模板与 @for:避免无谓重绘​

Angular 17+ 引入了全新的控制流语法,本节示例统一使用 @if / @for,不再使用旧的 *ngIf / *ngFor。

2.1 反例:缺少 track + 在模板中做重计算​

@for (user of users; track user) {
{{ userProfile(user) }} <!-- 每次变更检测都会重新执行 -->
}

问题:

  • 未提供稳定的 track 键,Angular 只能按索引比对,数据顺序或内容微调都会导致整行 DOM 重建。
  • 在插值中调用函数,变更检测每跑一次就重新执行函数,若内部有复杂逻辑或创建新对象,会明显拖慢页面。

2.2 推荐:使用 track + 预计算字段​

@for (user of users; track user.id) {
{{ user.displayName }}
}
  • 将与展示相关的文本(如 displayName)预先在 service / 页面类中计算好,避免在模板中做复杂逻辑。
  • track user.id 使用稳定的业务主键,列表只对真正变化的项做更新。

2.3 大列表:使用 CDK 虚拟滚动​

对于数百/上千行的数据表/列表:

  • 反例:普通 *ngFor 一次性渲染全部 DOM,滚动时重绘大量节点。
  • 推荐:使用 @angular/cdk/scrolling 的 cdk-virtual-scroll-viewport 只渲染可视区域附近的行。

这样,“感觉像是 1000 行”,实际上 DOM 中只存在几十行,性能接近原生。

3. RxJS 与订阅管理:避免内存泄漏与重复订阅​

3.1 反例:在组件中手动 subscribe + 忘记销毁​

ngOnInit() {
this.userService.list().subscribe(users => {
this.users = users;
});
}

问题:

  • 组件销毁时如果没有 unsubscribe,长生命周期流会继续推送,导致内存泄漏、无效渲染。

3.2 推荐:async 管道 / takeUntil / signalFromObservable​

方式一:模板 using async 管道 + @if

users$ = this.userService.list();
@if (users$ | async as users) {
<app-user-list [users]="users"></app-user-list>
}

优点:

  • Angular 帮你处理订阅生命周期,组件销毁自动取消。

方式二:在 TS 中使用 takeUntil / untilDestroyed 等模式
方式三:将 observable 转成 signal(Angular >=16),并配合 OnPush 使用。

4. 路由懒加载与代码拆分​

4.1 反例:所有模块/组件一次性打进主包​

  • 所有业务模块在根模块中同步导入。
  • 首屏加载 JS 很大,白屏时间长。

4.2 推荐:基于业务域的懒加载​

  • 使用路由级懒加载(loadChildren / standalone 组件的 loadComponent)按「功能域」拆分 bundle。
  • 非常少用的页面(例如管理后台子模块、报表等)可以进一步延迟加载。

实践经验:

  • 只要不是首屏必须的模块,一律按路由懒加载。
  • 首屏组件尽量“瘦”:只放骨架 + 少量关键信息,重内容放到滚动后再加载。

5. 组件粒度与模板复杂度​

5.1 反例:超大型“上帝组件”​

  • 单个组件负责多个业务区域、大量逻辑与交互。
  • 模板中嵌套多层 *ngIf / *ngFor,包含大量条件渲染与复杂布局。

结果:

  • 任意小变化都触发超大模板重跑变更检测,难以定位性能热点。

5.2 推荐:按“视觉/交互块”拆分组件​

  • 每个组件控制在一个清晰职责:
    • 列表区域、表单区域、统计卡片、筛选面板等。
  • 结合 OnPush / signals,使每个块只在必要时更新。

类比 React/Vue:

  • 在 React 中我们会拆分为多个 memo 组件或独立 hooks。
  • 在 Vue 中也会拆成多个子组件并配合 v-once / computed。
  • 在 Angular 中,对应就是:小而纯的 OnPush 组件 + 明确的输入输出。

6. 与 Vue / React 性能的对比与收敛思路​

常听到的说法:“Angular 比 Vue/React 慢”。
实际上,在以下写法下,绝大多数真实业务场景中三者性能可以非常接近。

Vue / React 的常见做法:

  • Vue:大量使用 computed、watchEffect,模板中不写重计算,列表使用 key、虚拟列表。
  • React:函数组件 + hooks,依赖数组精确声明;React.memo / useMemo / useCallback 控制重渲染;同样用 key 与虚拟滚动。

Angular 的等效做法:

  • 用 signals/computed 替代复杂表达式,控制依赖精度。
  • 使用 OnPush / signal,让组件只在输入引用变化时重渲染。
  • trackBy + CDK 虚拟滚动应对大列表。
  • 避免在模板中写重逻辑或创建新对象(如 {{ items | filter:cond }}、[data]=\"getData()\")。
  • 路由懒加载、首屏精简、资源按需加载。

只要遵守上述实践,Angular 的渲染路径就会和 Vue/React 一样:
“谁变谁更新”,而不是“全页面一起跑”,性能自然不会差。

7. 常见坏习惯与“隐形杀手”​

这一节列出一些在日常编码中最容易被忽略但又很伤性能的习惯,建议在 code review 中重点关注。

7.1 在模板中创建新对象 / 数组 / 内联函数​

反例:

<app-card [options]="{ type: 'primary', dense: true }"></app-card>

@for (item of items; track item.id) {
<app-row [actions]="['edit', 'delete']"></app-row>
}

问题:

  • 每次变更检测都会创建新的对象/数组引用,即便内容相同,也会导致下游 OnPush 子组件认为输入发生了变化,从而重渲染。

建议:

  • 把这些配置提到 TS 中,作为只读常量或字段:
readonly primaryDenseOptions = { type: 'primary', dense: true };
readonly rowActions = ['edit', 'delete'] as const;
<app-card [options]="primaryDenseOptions"></app-card>

@for (item of items; track item.id) {
<app-row [actions]="rowActions"></app-row>
}

7.2 纯管道(pure pipe)中做重计算 / 网络请求​

反例:

  • 在自定义管道中进行昂贵计算、IO 操作甚至发 HTTP 请求,然后在多个地方反复使用。

问题:

  • 即便是 pure pipe,只要输入变更就会重新执行;一旦管道逻辑较重,就成为隐藏的性能瓶颈。

建议:

  • 管道只做轻量、无副作用的格式化(如日期、货币、简单字符串处理)。
  • 复杂计算、聚合、网络请求应放在 service 中预处理成字段或缓存结果。

7.3 在主线程执行重 CPU 任务​

反例:

  • 在组件中直接处理大 JSON、复杂排序/过滤、加解密、图片处理等。

问题:

  • 会阻塞 UI 线程,导致明显卡顿,尤其是在低端移动设备或 TV 上。

建议:

  • 对于重 CPU 任务:
    • 优先尝试让后端完成(下发已处理好的数据)。
    • 或使用 Web Worker / 专门的后台任务服务处理,再将结果回传前端。

7.4 滥用全局事件与没有 runOutsideAngular​

反例:

window.addEventListener('scroll', () => {
// 每次滚动都触发 Angular 变更检测
});

问题:

  • 高频事件(scroll、mousemove、resize 等)若在 Angular zone 内触发,会导致变更检测疯狂运行。

建议:

  • 使用 NgZone.runOutsideAngular 订阅高频事件,再在需要更新 UI 的时候“少量地”回到 Angular:
this.ngZone.runOutsideAngular(() => {
window.addEventListener('scroll', () => {
// 仅在必要时进入 Angular
this.ngZone.run(() => {
this.updateScrollState();
});
});
});

7.5 不必要的双向绑定与表单变更事件​

反例:

  • 在大量输入控件上绑定 (ngModelChange) 或表单 valueChanges,每个键盘输入都触发复杂逻辑或网络请求。

建议:

  • 对查询类输入使用 防抖(debounceTime) 与最小长度判断。
  • 用 signals/computed 在 UI 层做轻量响应,真正的重操作交给 service 控制频率。

8. 小结:一份检查清单​

在评估或优化 Angular 页面性能时,可以按下面 checklist 逐项检查:

  • 变更检测
    • 尽量使用 OnPush 或 signals;Default 只留在必要的入口组件上。
    • 模板中没有调用复杂函数或创建新对象/数组/内联函数。
  • 列表与表格
    • 所有 @for 都配置了合理的 track 主键。
    • 大列表使用了 CDK 虚拟滚动。
  • RxJS / 订阅
    • 订阅要么通过 async 管道,要么有明确的取消策略。
    • 没有层层嵌套 subscribe,而是使用组合运算符或 signals。
  • 路由与资源
    • 业务模块按路由懒加载;首屏 bundle 控制在合理大小。
  • 组件粒度
    • 没有明显的“上帝组件”,UI 已按区域拆分为多个 OnPush 子组件。
  • 其它坏习惯
    • 模板中没有新的对象/数组/函数字面量。
    • 自定义管道仅做轻量格式化,不包含重计算/IO。
    • 高频全局事件(scroll/mousemove 等)在 runOutsideAngular 中处理,仅在必要时进入 Angular。
    • 重 CPU 任务已挪到后端或 Web Worker。

只要这些点做到位,你的 Angular 应用在真实业务中就能达到与 Vue / React 相近的性能体验。