Angular 变更检测深度讲解:OnPush 策略什么时候用、踩过哪些坑

Angular 变更检测深度讲解:OnPush 策略什么时候用、踩过哪些坑


一、先搞懂 Angular 变更检测到底在干什么

在聊 OnPush 之前,得先说清楚默认策略下 Angular 在做什么------否则你根本不知道 OnPush 到底"省"了什么。

默认策略(Default):一颗树,全量扫

Angular 的默认变更检测策略是 CheckAlways ------意思是每次触发变更检测时,从根组件到每一个叶子组件,整棵树全部检查一遍

什么会触发变更检测?

触发源 具体场景
浏览器事件 clickinputkeyup
定时器 setTimeoutsetInterval
HTTP 请求完成 HttpClient 返回响应
Promise resolve 任何 then 回调执行完
手动触发 ChangeDetectorRef.detectChanges()tick()

Angular 通过 Zone.js​ 猴子补丁了所有这些异步 API,所以你什么都不用做,框架帮你兜底------每次异步操作结束后自动跑一轮变更检测。

问题在于:哪怕你只改了一个按钮的文案,Angular 也会把整棵组件树都 diff 一遍。组件少的时候无所谓,组件上千个的时候,每一轮检测就是一次性能税。

那 Angular 到底在"检测"什么?

很多人有个误解,以为 Angular 是在做 脏检查(dirty checking) ------对比新旧值看变没变。

不是的。 ​ Angular 在 编译期 ​ 就通过 ngc 把模板编译成了 检测函数(updateRenderer) 。运行时,变更检测做的事情是:

  1. 执行每个组件的 ngDoCheck 生命周期钩子
  2. 重新执行模板里的插值表达式方法调用,把结果写进 DOM
  3. 递归对子组件做同样的事

也就是说,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 回调里更新,视图不刷新

现象 :在 setTimeoutPromise.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 里

这是最隐蔽的坑。比如你用了 EChartsMapboxSocket.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 应用。

相关推荐
拾光师1 小时前
Python 解析 JSON 日志:从一行数据到一份报告
后端
小强19881 小时前
RxJS 在 Angular 项目最佳实践:彻底告别内存泄漏,用好 asyncPipe
后端
大白802 小时前
Angular 路由进阶:路由守卫、懒加载、动态路由、路由传参避坑合集
后端
大黄评测2 小时前
SignalStore vs NgRx:企业项目状态管理该怎么选,不要盲目上大库
后端
Zane19942 小时前
类也是对象?一文讲透元类 metaclass 这件"深度魔法"
后端·python
_约书亚_2 小时前
Chapter 2 归纳总结 — 线程管理
后端
Zane19942 小时前
从一个发短信的类到多态调用:封装、继承、多态到底是怎么长出来的
java·后端
阿部多瑞 ABU2 小时前
从0到1:用 Spring Boot 3.4 + Vue3 做一个能“智能排道次“的运动会编排系统(附核心算法)
java·spring boot·后端·算法·spring
vipxieliang3 小时前
ValidX 迁移指南:v1.0.0/v1.0.1 → v1.1.0
后端