Angular 变更检测深度讲解:OnPush 策略什么时候用、踩过哪些坑
一、先搞懂 Angular 变更检测到底在干什么
在聊 OnPush 之前,得先说清楚默认策略下 Angular 在做什么------否则你根本不知道 OnPush 到底"省"了什么。
默认策略(Default):一颗树,全量扫
Angular 的默认变更检测策略是 CheckAlways ------意思是每次触发变更检测时,从根组件到每一个叶子组件,整棵树全部检查一遍。
什么会触发变更检测?
| 触发源 | 具体场景 |
|---|---|
| 浏览器事件 | click、input、keyup 等 |
| 定时器 | setTimeout、setInterval |
| HTTP 请求完成 | HttpClient 返回响应 |
| Promise resolve | 任何 then 回调执行完 |
| 手动触发 | ChangeDetectorRef.detectChanges()、tick() |
Angular 通过 Zone.js 猴子补丁了所有这些异步 API,所以你什么都不用做,框架帮你兜底------每次异步操作结束后自动跑一轮变更检测。
问题在于:哪怕你只改了一个按钮的文案,Angular 也会把整棵组件树都 diff 一遍。组件少的时候无所谓,组件上千个的时候,每一轮检测就是一次性能税。
那 Angular 到底在"检测"什么?
很多人有个误解,以为 Angular 是在做 脏检查(dirty checking) ------对比新旧值看变没变。
不是的。 Angular 在 编译期 就通过 ngc 把模板编译成了 检测函数(updateRenderer) 。运行时,变更检测做的事情是:
- 执行每个组件的
ngDoCheck生命周期钩子 - 重新执行模板里的插值表达式 和方法调用,把结果写进 DOM
- 递归对子组件做同样的事
也就是说,Angular 根本不"对比"------它直接重新计算、重新赋值。如果值没变,DOM 也不会变(浏览器层面有优化)。但 JS 层面的计算是实打实跑了的。
这就是 OnPush 要解决的问题:跳过那些"肯定没变"的组件,连计算都省了。
二、OnPush 策略:原理与工作机制
核心思想
ChangeDetectionStrategy.OnPush 做的事情只有一句话:
只有当组件的 @Input() 引用发生变化时,才把这个组件标记为"需要检查",否则直接跳过它和它的所有子组件。
标记为"需要检查"的四种情况
一个 OnPush 组件会在以下四种情况下被 Angular 标记为 dirty(需要检测):
| # | 触发条件 | 说明 |
|---|---|---|
| 1 | @Input() 引用变化 | === 比较为 false 才算 |
| 2 | 组件自身触发了事件 | 模板里 (click)="onClick()" 这种 |
| 3 | Observable 通过 async 管道推送新值 | async pipe 内部会调 markForCheck() |
| 4 | 手动调用 ChangeDetectorRef.markForCheck() |
显式告诉框架"我变了" |
注意第 1 条------这是 OnPush 最容易踩坑的地方,也是整篇文章最关键的部分。我们后面会反复回到这里。
代码长什么样
less
@Component({
selector: 'app-user-card',
template: `
<h3>{{ user.name }}</h3>
<p>Age: {{ user.age }}</p>
<button (click)="refresh()">Refresh</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
@Input() user!: User;
refresh() {
// 这个 click 事件会触发本组件的变更检测
// 即使 user 没变,点击后模板也会更新
}
}
用一张图理解跳过逻辑
scss
Root (Default)
├── Header (Default) → 每次都检查
├── UserList (OnPush)
│ ├── UserCard (OnPush) → @Input 没变?跳过 ✅
│ ├── UserCard (OnPush) → @Input 变了?检查 🔍
│ └── UserCard (OnPush) → @Input 没变?跳过 ✅
└── Footer (Default) → 每次都检查
关键:一旦父组件被跳过,它的所有 OnPush 子组件也直接跳过,不会逐个判断。 这是 OnPush 性能收益的来源,也是"坑"的来源。
三、什么时候该用 OnPush
✅ 适合用 OnPush 的场景
1. 纯展示型组件(Presentational Components)
这是最典型、最安全的场景。组件只接收数据、渲染 UI,没有内部状态变更。
css
// ✅ 完美的 OnPush 候选
@Component({
selector: 'app-product-item',
template: `
<div class="card">
<img [src]="product.imageUrl" />
<span>{{ product.name }}</span>
<span class="price">{{ product.price | currency }}</span>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductItemComponent {
@Input() product!: Product;
}
判断标准 :如果你的组件模板里没有 (event) 绑定,也没有调用会修改状态的方法,直接上 OnPush。
2. 列表中的大量重复组件
这是 OnPush 收益最明显的场景。假设一个列表渲染 500 条数据,只有 3 条变了:
typescript
// 父组件
@Component({
template: `
<app-list-item
*ngFor="let item of items; trackBy: trackById"
[item]="item"
></app-list-item>
`
})
export class ListComponent {
items: ListItem[] = [];
trackById(index: number, item: ListItem) {
return item.id;
}
updateOneItem() {
// ✅ 正确做法:替换数组引用
this.items = this.items.map(item =>
item.id === 42 ? { ...item, count: item.count + 1 } : item
);
}
}
用 OnPush + trackBy + 不可变更新,500 个子组件里只有 1 个会被检测。性能差距是数量级的。
3. 深层嵌套的组件树
组件层级越深,OnPush 的"短路"效果越显著。一个 10 层的组件树,顶层 OnPush 组件没变,下面 9 层全部跳过。
4. 使用 Observable + async 管道的数据流
kotlin
@Component({
template: `
<app-user-card [user]="user$ | async" />
<app-notifications [list]="notifications$ | async" />
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent {
user$ = this.store.select(selectUser);
notifications$ = this.store.select(selectNotifications);
constructor(private store: Store) {}
}
async 管道在 Observable 推送新值时,会自动调用 markForCheck(),完美契合 OnPush 的第三种触发条件。这是 NgRx/Component Store 推荐架构 的核心模式。
❌ 不适合用 OnPush 的场景
| 场景 | 原因 |
|---|---|
| 组件内部频繁修改自己的状态 | 每次都要手动 markForCheck(),反而更麻烦 |
大量使用 setInterval / 第三方库回调 |
不在 Angular Zone 内的回调不会触发检测 |
组件逻辑重度依赖 ngDoCheck 做深度比较 |
OnPush 下 ngDoCheck 本身被跳过了 |
| 快速原型 / 新手项目 | 调试成本 > 性能收益 |
四、踩过的坑(血泪总结)
这一部分是全文最有价值的内容。每一个坑都是真实项目里 debug 到头秃换来的。
🔴 坑 1:可变对象修改,视图不更新(最高频)
现象 :改了 @Input 对象的属性,UI 没反应。
kotlin
// 父组件
@Component({
template: `<app-user-card [user]="currentUser"></app-user-card>`
})
export class ParentComponent {
currentUser = { name: 'Alice', age: 25 };
changeName() {
this.currentUser.name = 'Bob'; // ❌ 只改了属性,引用没变
}
}
less
// 子组件
@Component({
selector: 'app-user-card',
template: `<h3>{{ user.name }}</h3>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
@Input() user!: { name: string; age: number };
}
原因 :OnPush 用 === 比较 @Input 引用。currentUser.name = 'Bob' 之后,currentUser 还是同一个对象,引用没变,Angular 直接跳过了 UserCardComponent。
解决方案有三:
kotlin
// ✅ 方案 A:不可变更新(推荐)
this.currentUser = { ...this.currentUser, name: 'Bob' };
// ✅ 方案 B:手动标记
this.currentUser.name = 'Bob';
this.cdr.markForCheck(); // 需要注入 ChangeDetectorRef
// ✅ 方案 C:用 Observable + async 管道(最优雅)
user$ = new BehaviorSubject({ name: 'Alice', age: 25 });
updateUser() {
this.user$.next({ ...this.user$.value, name: 'Bob' });
}
最佳实践 :在 OnPush 项目中,默认用不可变数据。这是 RxJS、NgRx、Immer 等工具存在的核心理由。
🔴 坑 2:数组 push 后视图不更新
和坑 1 本质一样,但更隐蔽:
kotlin
@Component({
template: `
<ul>
<li *ngFor="let item of items">{{ item }}</li>
</ul>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent {
@Input() items: string[] = [];
addItem() {
this.items.push('new item'); // ❌ 数组引用没变
}
}
修复:
javascript
// ✅ 返回新数组
addItem() {
this.items = [...this.items, 'new item'];
}
或者更函数式:
kotlin
this.items = this.items.concat('new item');
踩坑心得:每次修改数据后问自己一句------"引用变了吗?" 如果答案是"没变",OnPush 就不会检测到。
🔴 坑 3:setTimeout / Promise 回调里更新,视图不刷新
现象 :在 setTimeout 或 Promise.then 里改了数据,但视图没更新。
typescript
@Component({
template: `<p>{{ count }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = 0;
ngOnInit() {
setTimeout(() => {
this.count++; // ❌ 视图不更新
}, 1000);
}
}
等等,Zone.js 不是会捕获 setTimeout 吗?
是的,Zone.js 确实会触发一轮变更检测。但问题是:这轮检测是从根组件开始的,而 OnPush 组件只有在被标记为 dirty 时才会被检查。
setTimeout 回调里改了 count,但没有任何 @Input 变化、没有事件、没有 async pipe、没有 markForCheck()------所以这个 OnPush 组件不会被标记为 dirty,被跳过了。
修复:
typescript
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
setTimeout(() => {
this.count++;
this.cdr.markForCheck(); // ✅ 手动标记
}, 1000);
}
更优雅的方案 ------用 NgZone.run() 或 Observable:
typescript
// ✅ 方案 A:NgZone
constructor(private ngZone: NgZone) {}
ngOnInit() {
this.ngZone.run(() => {
setTimeout(() => this.count++, 1000);
});
}
// ✅ 方案 B:Observable(推荐)
count$ = interval(1000).pipe(
scan(count => count + 1, 0)
);
// 模板里用 async pipe
🔴 坑 4:第三方库回调不在 Zone 里
这是最隐蔽的坑。比如你用了 ECharts、Mapbox、Socket.IO:
kotlin
@Component({
template: `<div #chart></div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChartComponent implements AfterViewInit {
@ViewChild('chart') chartEl!: ElementRef;
chartData: any;
ngAfterViewInit() {
const chart = echarts.init(this.chartEl.nativeElement);
socket.on('data', (data) => {
this.chartData = data; // ❌ 视图不更新
chart.setOption(data); // 图表更新了,但 Angular 模板没更新
});
}
}
原因 :socket.on 的回调不在 Angular 的 Zone 里执行,Angular 根本不知道它发生了。
修复:
typescript
constructor(
private cdr: ChangeDetectorRef,
private ngZone: NgZone
) {}
ngAfterViewInit() {
socket.on('data', (data) => {
this.ngZone.run(() => {
this.chartData = data; // ✅ 在 Zone 里执行
this.cdr.markForCheck(); // ✅ 标记检查
});
});
}
或者更好的做法------用 Observable 桥接:
dart
// ✅ 用 Observable 包装 socket,配合 async pipe
data$ = new Observable(observer => {
socket.on('data', data => observer.next(data));
});
// 模板里用 async pipe,自动 markForCheck
🔴 坑 5:ngDoCheck 里做深度比较,但 OnPush 下它根本不执行
有人会想:"我可以在 ngDoCheck 里手动比较对象内容,发现变了就 markForCheck()"。
kotlin
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent implements DoCheck {
@Input() config!: Config;
private lastConfig: Config;
ngDoCheck() {
// ❌ 问题:OnPush 下,如果 @Input 引用没变,组件被跳过
// ngDoCheck 根本不会被调用!
if (!this.shallowEqual(this.config, this.lastConfig)) {
this.lastConfig = { ...this.config };
this.cdr.markForCheck();
}
}
}
这是一个"鸡生蛋蛋生鸡"的问题 :你想在 ngDoCheck 里检测变化,但 OnPush 在 ngDoCheck 之前就决定了要不要跳过你。
正确做法 :要么用 Default 策略,要么在父组件保证每次传新的引用,要么用 KeyValueDiffers / IterableDiffers 在父层做 diff 后传新引用下来。
🔴 坑 6:OnPush 组件的子组件也被"连坐"跳过
kotlin
// 父组件(OnPush)
@Component({
template: `
<app-child-a [data]="dataA"></app-child-a>
<app-child-b></app-child-b>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ParentComponent {
dataA = { value: 1 };
}
scss
// ChildB 组件(也是 OnPush,没有 @Input)
@Component({
template: `<p>{{ getTime() }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildBComponent {
getTime() {
return new Date().toLocaleTimeString();
}
}
现象 :ChildB 里的时间永远不会更新,即使你在 ChildB 内部调了某个方法改了状态。
原因:父组件被跳过 → 所有子组件一起跳过,不管子组件有没有自己的状态变化。
修复:
typescript
// ChildB 内部需要更新时,手动标记
constructor(private cdr: ChangeDetectorRef) {}
updateTime() {
// 做一些操作
this.cdr.markForCheck(); // ✅ 告诉 Angular "我需要被检查"
}
或者------让 ChildB 的事件从模板触发(触发条件 #2):
scss
<button (click)="refresh()">Refresh</button>
点击按钮会触发 ChildB 的变更检测,模板会重新渲染。
🔴 坑 7:ChangeDetectorRef.detectChanges() vs markForCheck()
这两个 API 经常被混用,但它们行为完全不同:
| API | 行为 | 使用场景 |
|---|---|---|
detectChanges() |
立即对当前组件及其子组件执行一次变更检测 | 需要马上看到更新结果 |
markForCheck() |
标记组件为 dirty,等下一轮变更检测时再检查 | 数据已更新,等框架调度 |
javascript
// ❌ 错误用法:在 OnPush 里用 detectChanges 可能触发多次检测
setInterval(() => {
this.count++;
this.cdr.detectChanges(); // 每次都立即检测,破坏了 OnPush 的优化意义
}, 100);
// ✅ 正确用法
setInterval(() => {
this.count++;
this.cdr.markForCheck(); // 标记一下,等下次事件循环统一检测
}, 100);
但有一个例外 :如果你在 NgZone.runOutsideAngular() 里执行代码,需要手动调 detectChanges() 立即触发检测:
javascript
this.ngZone.runOutsideAngular(() => {
setInterval(() => {
this.ngZone.run(() => {
this.count++;
this.cdr.detectChanges(); // ✅ 必须立即检测,因为没有 Zone 触发
});
}, 100);
});
🔴 坑 8:忘记取消订阅导致内存泄漏
这个坑不直接和 OnPush 相关,但 OnPush 项目大量使用 Observable + async pipe,容易让人忘记手动订阅的场景也需要清理:
kotlin
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.dataService.getData()
.pipe(takeUntil(this.destroy$))
.subscribe(data => {
this.data = data;
this.cdr.markForCheck();
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
推荐模式 :用 async pipe 彻底避免手动订阅:
kotlin
@Component({
template: `<div *ngIf="data$ | async as data">{{ data.name }}</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent {
data$ = this.dataService.getData();
}
五、OnPush 性能优化实战模式
模式 1:不可变数据 + OnPush(最推荐)
javascript
// 用 Immer 简化不可变更新
import { produce } from 'immer';
updateUser() {
this.user = produce(this.user, draft => {
draft.profile.settings.theme = 'dark';
});
// produce 返回新引用,OnPush 能检测到
}
模式 2:NgRx / Component Store 纯响应式
kotlin
@Component({
template: `
<app-todo-list [todos]="todos$ | async" />
<app-todo-stats [stats]="stats$ | async" />
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodosPageComponent {
todos$ = this.store.select(selectAllTodos);
stats$ = this.store.select(selectTodoStats);
constructor(private store: Store) {}
}
所有子组件都是 OnPush,所有数据通过 Observable + async pipe 流入。 这是 Angular 性能最优的架构模式,没有之一。
模式 3:TrackBy 配合 OnPush 列表
xml
<!-- ✅ 永远加 trackBy -->
<div *ngFor="let item of items; trackBy: trackById">
<app-item [item]="item" />
</div>
php
trackById(index: number, item: Item): number {
return item.id; // 返回唯一标识,Angular 只移动/更新变化的 DOM 节点
}
模式 4:纯管道(Pure Pipe)配合 OnPush
Angular 的纯管道只在输入值引用变化时重新计算:
php
@Pipe({ name: 'filter', pure: true })
export class FilterPipe implements PipeTransform {
transform(items: Item[], query: string): Item[] {
// 这个函数在 items 引用没变时不会重新执行
return items.filter(item => item.name.includes(query));
}
}
xml
<!-- items 引用不变 → 管道不重新计算 → 性能更好 -->
<div *ngFor="let item of items | filter:query">{{ item.name }}</div>
六、调试 OnPush 问题的工具箱
1. 在开发环境开启 Angular DevTools
Chrome 插件 Angular DevTools 可以可视化组件树,看到每个组件的变更检测次数。OnPush 组件正常工作时,变更检测次数应该远少于 Default 组件。
2. 在模板里放一个调试计数器
xml
<!-- 每次变更检测都会 +1,帮你确认组件是否被检测 -->
<div class="debug">Checks: {{ ++checkCount }}</div>
ini
checkCount = 0;
3. 用 ngDoCheck + console.log 确认是否被调用
javascript
ngDoCheck() {
console.log('MyComponent - ngDoCheck called');
}
如果没看到 log,说明组件被跳过了。
4. 检查 Zone 是否捕获到你的异步操作
typescript
import { NgZone } from '@angular/core';
constructor(private ngZone: NgZone) {}
someCallback() {
console.log('Inside Angular Zone:', NgZone.isInAngularZone());
}
七、什么时候不该用 OnPush
说完了好处和坑,最后给一个决策框架:
rust
你的组件......
├── 是纯展示组件? → ✅ 用 OnPush
├── 在大型列表中重复渲染? → ✅ 用 OnPush
├── 深度嵌套,父层数据变化少? → ✅ 用 OnPush
├── 大量使用 Observable + async pipe? → ✅ 用 OnPush
├── 频繁内部状态变更? → ❌ 用 Default
├── 重度依赖第三方库回调? → ⚠️ 可以 OnPush,但要小心 Zone 问题
└── 团队不熟悉不可变模式? → ❌ 先统一认知,再引入 OnPush
我的建议 :新项目从 Day 1 就采用 OnPush + Observable + async pipe 的架构。老项目不要一次性全改------从叶子节点(最底层的展示组件)开始,自下而上逐步迁移。
八、总结
| 要点 | 记住这个 |
|---|---|
| OnPush 的跳过条件 | @Input 引用变、事件触发、async pipe、手动 markForCheck |
| 最常见坑 | 改了对象属性/数组内容,引用没变,视图不更新 |
| 最根本的解决思路 | 不可变数据 + Observable + async pipe |
markForCheck() vs detectChanges() |
前者等调度,后者立即执行 |
| OnPush 子组件被跳过 | 父被跳过,子一定被跳过 |
| 性能收益最大场景 | 大列表 + OnPush + trackBy |
OnPush 不是银弹,但它是 Angular 性能优化的基础设施。理解它的原理,踩过那些坑,你就能写出既快又稳的 Angular 应用。