变更检测
Angular 在数据变化后更新 DOM 的过程称为 变更检测(Change Detection)。理解默认策略有助于性能调优。运行时技巧见 runtime。
核心概念
变更检测要解决的问题:「数据变了,如何知道该更新哪些 DOM?」Angular 采用组件树自顶向下遍历的策略——每次检测周期,从根组件开始,按树的顺序检查每个组件的模板绑定表达式是否变化,若变化则更新对应 DOM。触发一次遍历的时机由 Zone.js(默认)或显式调用(ChangeDetectorRef.detectChanges())决定。
默认机制与 Zone.js
典型应用在 polyfills 中引入 Zone.js:浏览器异步任务(事件、定时器、XHR、Promise 等)结束后触发 Angular 检查组件树。Zone.js 通过打补丁(monkey-patch) 全局的 setTimeout、addEventListener、fetch 等 API,在任务完成时自动调用 ApplicationRef.tick()。
- 开发时可用
NgZone.runOutsideAngular执行与 UI 无关的高频任务,必要时再run()回区。 - Zoneless(无 Zone):Angular 18/19 曾提供实验 API
provideExperimentalZonelessChangeDetection();Angular 20 起重命名为provideZonelessChangeDetection()(Developer Preview),约 20.2 起可按稳定 API 用于生产;Angular 21+ 的 CLI 新项目默认即 zoneless(通常无需再手动添加该 provider)。开启后依赖 Signal /markForCheck等显式通知触发检测,也可省去 zone.js。具体以当前 Zoneless 指南 为准。
// main.ts:在 Angular 20.x 显式开启 zoneless(v21+ 新项目一般已默认)
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});
变更检测策略
import { ChangeDetectionStrategy, Component } from "@angular/core";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {}
| 策略 | 行为 |
|---|---|
| Default | 每次变更检测周期检查该组件(子树按规则参与) |
| OnPush | 仅在 输入引用变化、事件来自本模板、async 管道发出新值、Signal 更新 等时检查 |
列表与展示组件宜使用 OnPush,并配合不可变数据或 Signal。
OnPush 触发条件详解
OnPush 组件被跳过检测,除非发生以下任一情况:
@Input()引用变化:父组件传入了新的对象/数组引用(浅比较,obj.prop = x不会触发,必须obj = { ...obj, prop: x })。- 组件(或其子组件)内触发的 DOM 事件:如
(click)="onClick()",会标记该组件及祖先链需要检测。 async管道收到新值:user$ | async在 Observable 发出新值时自动标记。- 依赖的 Signal 变化:读取的
signal()更新会精确标记使用它的视图。 - 手动调用
ChangeDetectorRef.markForCheck()或detectChanges()。
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ user().name }}</p>`,
})
export class UserCardComponent {
user = input.required<User>();
// 手动触发场景:定时器等不经过模板事件/Input/Signal 的更新
private cdr = inject(ChangeDetectorRef);
constructor() {
setInterval(() => {
this.lastPing = Date.now();
this.cdr.markForCheck(); // OnPush 下需要显式标记
}, 5000);
}
}
Signals 与检测
Signal 更新会标记相关视图节点,有利于缩小检测范围(与 OnPush 协同)。Angular 的新一代渲染管线("signal-based reactivity")逐步做到只更新读取了该 Signal 的具体绑定,而不必重新检查整个组件模板,理论上比传统脏检查更精细。详见 Signal 基础。
最佳实践
- 新组件默认写
OnPush,仅在确有需要(如深层双向绑定、遗留代码迁移期)才退回Default。 - 传给
OnPush子组件的数据保持不可变:更新数组/对象时用扩展运算符或signal.update()产生新引用,而不是原地修改。 - 定时器、WebSocket 回调等脱离 Zone 感知路径的更新,搭配
markForCheck()或迁移到signal.set()。 - 优先用
async管道消费 Observable,而不是手动subscribe后再markForCheck,减少遗漏标记的风险。 - 若逐步迁移到 Signal,可先在叶子组件启用
OnPush+ Signal 输入,验证收益后再扩大范围。
常见坑
| 现象 | 常见原因 | 处理 |
|---|---|---|
OnPush 组件数据变了但视图没更新 | 原地修改了对象/数组,引用未变 | 使用不可变更新或 Signal |
| 定时器里更新的数据不显示 | 定时器在 Zone 外或 markForCheck 未调用 | 显式 markForCheck(),或用 Signal |
| 迁移到 zoneless 后部分组件不更新 | 依赖 Zone 自动触发的旧代码路径未迁移到 Signal | 逐步替换为 Signal/markForCheck |
| 大表单频繁全树检测导致卡顿 | 顶层组件未 OnPush,任意输入都触发全树检查 | 拆分子组件并逐层 OnPush |
与 Vue / React 的变更检测机制对照
| Angular(默认) | Vue | React | |
|---|---|---|---|
| 触发方式 | Zone.js 拦截异步 API | 依赖追踪(Proxy) | 显式 setState/dispatch |
| 检测范围 | 整棵组件树遍历(Zone 模式) | 精确到依赖该数据的组件 | 触发更新的组件及其子树 |
| 精细化方案 | Signal(细粒度) | 天生细粒度(响应式系统) | memo + 状态下沉 |
Angular 默认的 Zone 模式偏向「保守但简单」:不需要开发者手动声明依赖,但检测范围较大;Signal 的引入让 Angular 逐步向 Vue 式的「精确依赖追踪」靠近。
延伸阅读
参考文献
以下链接在编写时均可正常访问:
| 资料 | 说明 |
|---|---|
| 变更检测 | 官方性能建议 |
| OnPush | API |
| Zone.js | 仓库说明 |
| Zoneless 指南 | 开发者预览(v21+ 新项目默认启用) |
| ChangeDetectorRef | API |
相关文章
运行时优化
在 变更检测 策略正确的前提下,减少模板计算与 DOM 规模。涵盖 OnPush、@for track、纯管道、虚拟滚动、懒加载、构建体积分析与常见性能陷阱。
生态集成
Angular 官方维护路由、HTTP、表单、动画等;UI 与状态扩展由社区与官方 Material 团队提供。涵盖官方/半官方库、常用第三方库、与 Vue/React 生态对照、选型建议与常见坑。
Signal 基础
Signals 是 Angular 16 引入的响应式原语:可读写的 signal、派生的 computed、副作用 effect。用于组件内状态与跨组件细粒度更新,涵盖 API、input signal、与变更检测的关系、最佳实践与常见坑。
RxJS 互操作
Angular 内置 RxJS 处理异步流(HTTP、HttpClient、路由事件、FormControl 的 valueChanges 等)。Signal 适合同步/短链路状态;长生命周期流仍常用 Observable。涵盖 async 管道、toSignal/toObservable、常用操作符、最佳实践与常见坑。
资源优化
图片懒加载、按需引入与 Web Vitals 监控。见 HTML 图片、CSS 字体优化。
构建与分包
通过代码分割与构建配置减小首屏 JavaScript 体积。涵盖 React.lazy/Suspense、路由级分割、Tree Shaking、Vite 生产构建、体积分析、最佳实践与常见坑。运行时优化见 runtime。
Series
performance
1 / 2