3.2 性能优化:机制的工程回报 性能优化在 Angular 语境下高度聚焦于变更检测:降低检查频率、缩小检查范围、降低单轮成本。本节给出"测量先行"的方法论与四类手段(OnPush 与 trackBy、脱离 Zone、合并提交、虚拟滚动),全部对应 3.1 节的机制结论——不做无依据的玄学调优。 承接与目标 3.1 把机制讲透了,本节把镜头转向结果:同样的一轮检查,怎样花更少的钱拿到同样的更新: 用性能面板与 profiler 定位检查耗时,区分"频率问题"与"单轮成本问题"。 正确实施 OnPush 改造清单,包括数据侧的不可变化配合。 用 runOutsideAngular 隔离高频回调,并说明何时需要手动重入。 评估并应用虚拟滚动处理万级列表。
性能优化在 Angular 语境下高度聚焦于变更检测:降低检查频率、缩小检查范围、降低单轮成本。本节给出"测量先行"的方法论与四类手段(OnPush 与 trackBy、脱离 Zone、合并提交、虚拟滚动),全部对应 3.1 节的机制结论——不做无依据的玄学调优。
3.1 把机制讲透了,本节把镜头转向结果:同样的一轮检查,怎样花更少的钱拿到同样的更新:
优化前回答一个问题:慢在检查太多,还是每轮检查太贵? 两者手段完全不同。判据来自性能面板录制:检查相关调用栈出现次数(频率)与单次持续时间(成本)。频率高的查数据流设计(轮询、高频事件、动画定时器);单次贵的查组件树规模与绑定表达式(重方法、不纯管道、无 trackBy 的大列表)。
⚠️ 反模式:不测量就"全面 OnPush 改造",结果数据层还是原地修改,界面开始静默不更新——性能没改善,先收获一串 bug。OnPush 是数据纪律的前置契约,不是免费开关。
import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; @Component({ selector: 'app-order-row', changeDetection: ChangeDetectionStrategy.OnPush, // 缩小检查范围 template: `<li>{{ order.orderNo }}:{{ order.amount | number }} 元</li>` // 纯管道:值不变则零执行 }) export class OrderRowComponent { @Input() order!: { orderNo: string; amount: number }; } // 数据侧契约:父组件必须换引用 refreshRow(no: string, patch: Partial<Order>) { this.orders = this.orders.map(o => o.orderNo === no ? { ...o, ...patch } : o // 不可变更新 ); } // 列表侧:trackBy 复用行实例 trackByNo(_i: number, o: Order) { return o.orderNo; }
三者合力:OnPush 跳过无关子树,trackBy 复用行组件,纯管道缓存格式化结果。同一列表的每轮检查成本从"全部行 × 全部绑定"降到"变更行 × 变更绑定"。
import { Component, NgZone, inject } from '@angular/core'; @Component({ selector: 'app-progress', template: `<div class="bar" [style.width.%]="pct"></div>` }) export class ProgressComponent { pct = 0; private zone = inject(NgZone); start() { // 动画/进度回调每秒可能跑 60 次,每次都会唤醒检查——移出 Zone this.zone.runOutsideAngular(() => { const t = setInterval(() => { this.pct += 1; if (this.pct >= 100) { clearInterval(t); // 结束时才需要更新界面:显式重入 Zone,触发一次检查 this.zone.run(() => this.pct = 100); } }, 16); }); } }
这个例子里进度条本身仍需更新(绑定在模板里),所以更彻底的写法是把 pct 换成信号——信号更新不依赖 Zone,精准触发本组件检查。两种方案对比正是 3.1 节两条路线的应用选型:存量代码用 runOutsideAngular + 定期重入,新代码直接信号化。
六路轮询合并为单节拍、输入防抖、批量子集更新(cdk 的批次工具)——共同思想:界面刷新次数 ≠ 数据到达次数,中间加一个整形层。不再展开代码,回看 2.10 节看板案例即可。
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { ScrollingModule } from '@angular/cdk/scrolling'; @Component({ selector: 'app-virtual-list', imports: [ScrollingModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <!-- 视口外不渲染:万级数据只检查可见的二十来行 --> <cdk-virtual-scroll-viewport itemSize="48" class="vp"> <div *cdkVirtualFor="let o of orders; trackBy: trackByNo" class="row"> {{ o.orderNo }} - {{ o.amount }} </div> </cdk-virtual-scroll-viewport> `, styles: ['.vp { height: 480px; } .row { height: 48px; }'] }) export class VirtualListComponent { orders: Order[] = Array.from({ length: 10000 }, (_, i) => ({ orderNo: `A-${i}`, amount: (i % 500) * 3, status: '待支付' })); trackByNo(_i: number, o: Order) { return o.orderNo; } }
虚拟滚动的机制本质:把"检查范围"从数据规模解耦——无论一万条还是十万条,进入组件树的永远只有视口内那几十行。

背景:运营列表页 800 行、每行 12 个绑定,滚动选择时掉帧。
操作:第一步录制确认单轮检查贵(频率正常);第二步行组件 OnPush + 父侧不可变更新 + trackBy;仍卡后第三步引入虚拟滚动。
结果:三步后滚动稳定满帧,检查相关耗时降到原来的很小一部分。
解读:三步分别命中"范围、实例、树规模"三个因子,顺序不可颠倒——没有测量就上虚拟滚动,可能掩盖数据层违反不可变纪律的真问题。
变式:若瓶颈是每行的翻译管道(不纯),把它改成纯管道加语言切换时统一刷新,问题从"每轮算 800 次"变成"语言切换时算一次"。
下一节换个视角看机制:测试如何驱动变更检测。