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 相近的性能体验。