变更检测(Change Detection,CD)回答一个问题:组件状态变了,框架如何知道哪些 DOM 需要更新?Angular 的答案经历了两个时代:zone.js 补丁驱动的”全量检查”,和信号驱动的”精确刷新”。v22 是分水岭:OnPush 成为默认策略、Zoneless 全面稳定。本文把机制、影响与心智模型讲透。
zone.js 时代:全量检查
早期 Angular 依赖 zone.js:它给所有异步 API(事件、定时器、XHR、Promise 等)打补丁,任何异步回调结束后都能通知 Angular。Angular 收到通知后执行一次全局检查:
graph LR
A[任意异步事件<br/>被 zone.js 补丁捕获] --> B[调度一次全局检查]
B --> C[ApplicationRef.tick<br/>从根组件深度优先遍历]
C --> D[重新求值每个模板表达式]
D --> E{值变化?}
E -- 是 --> F[更新 DOM]
E -- 否 --> G[跳过该绑定]
这套机制的优点是”写什么都能刷新”——开发者不需要关心通知,zone.js 全包了。代价也很明显:
- 粒度粗:任何一次点击、任何一个定时器,都可能触发整棵树的重新检查。
- Zone 侵入性强:monkey-patch 全局 API,与部分第三方库冲突,调试堆栈也被包了一层。
- 无法优化到底:框架不知道”这次变更影响了谁”,只能全查。
开发模式下 tick 会对每个表达式执行双次比较,用以发现 ExpressionChangedAfterItHasBeenCheckedError 一类的不一致——这也是 dev 模式明显更慢的原因之一。
信号驱动时代:精确刷新
信号(Signals)让”谁依赖谁”变成框架可知的依赖图。写入信号时,只有依赖该信号的效果(模板绑定、computed、effect)失效:
graph LR
A[signal.set 更新] --> B[依赖图中订阅它的<br/>消费者失效]
B --> C[调度器合并刷新请求]
C --> D[只重新执行受影响的<br/>模板绑定与效应]
D --> E[更新 DOM]
注意核心差异:信号写入通知的是具体的消费者,而不是”全应用可能变了”。这就是 Zoneless 的根基——不再需要 zone.js 假设”任何异步都可能改状态”,因为状态变化必须经过信号这个显式通道。
v22:OnPush 默认与 Eager 回退
v22 起,组件默认变更检测策略为 OnPush;原来的 Default 策略更名为 Eager,可显式回退:
import { ChangeDetectionStrategy, Component } from '@angular/core';
// v22 默认行为,无需显式声明
@Component({ selector: 'app-list' })
export class ListComponent {}
// 显式回退旧的 Eager 行为(仅在迁移过渡期使用)
@Component({
changeDetection: ChangeDetectionStrategy.Eager,
})
export class LegacyChartComponent {}
| 维度 | Eager(原 Default) | OnPush(v22 默认) |
|---|---|---|
| 检查时机 | 所在路径上的祖先被检查时就检查 | 输入变化、信号写入、自身模板事件触发 |
| 性能 | 大组件树浪费明显 | 无关子树直接跳过 |
| 普通字段更新后模板可见性 | tick 时可见 | 不可见(需信号或 markForCheck) |
| 适用 | 迁移过渡、依赖 zone 语义的遗留组件 | v22 全部新代码 |
OnPush 组件会在这些情况下被检查:绑定的输入引用变化(或函数式 input 更新)、组件内信号写入、自身模板上的事件被触发、手动 markForCheck。
Zoneless 心智模型:改信号才会刷新
Zoneless 在 v21 稳定,v22 新工程默认不引入 zone.js。心智模型一句话:改信号才会刷新。
会触发变更检测的动作:
- 信号写入(
set/update/mutate语义的更新); - 模板事件监听器执行(框架注册监听时会通知调度器);
ChangeDetectorRef.markForCheck()(过渡兼容通道);ComponentRef.setInput()(测试与动态场景)。
不会触发的动作:
- 修改普通类字段、直接改嵌套对象属性;
- 第三方库回调里改了 Angular 之外的状态再操作 DOM;
MutationObserver/ WebGL 循环等框架不知道的通道。
典型错误与修复:
export class ClockComponent {
// 反例:普通字段,Zoneless 下模板永远停在初值
nowPlain = new Date();
// 正确:信号驱动
now = signal(new Date());
constructor() {
const timer = setInterval(() => this.now.set(new Date()), 1000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
}
}
<div>{{ now().toLocaleTimeString() }}</div>
深层突变的场景配合 notify()(v20 起):信号值本身没换引用、只改了内部结构时,显式通知消费者:
todos = signal<Todo[]>([]);
addByMutation(todo: Todo) {
this.todos().push(todo); // 原地突变,引用未变
this.todos.notify(); // 显式通知依赖刷新
}
优先级上,update() 生成新引用仍是首选,notify() 留给不便创建新值的热路径。
迁移清单
从 zone.js 应用迁移到 v22 Zoneless,逐项核对:
| 检查项 | 迁移动作 |
|---|---|
@Input() + ngOnChanges | 改为 input() + computed / effect |
async 管道订阅流 | 改为 toSignal / resource() |
| 事件回调里改普通字段 | 改为写信号 |
markForCheck 手动调用 | 多数可删除;确有第三方回调场景可暂时保留(仍有效) |
| 路由/CDK/Material 版本 | 升级到 v21+ 版本(主流库已适配 Zoneless) |
组件显式 OnPush 声明 | 可移除(已是默认);Eager 声明只留给遗留组件 |
polyfills 中的 zone.js | 移除,确认无第三方库依赖 zone |
排查”界面不刷新”的口诀:先看状态是不是信号;再看写入是否经过 set/update/notify;最后确认模板真的读取了该信号。
常见坑
- effect 里读写同一信号:v19 起 effect 内写信号默认允许,但读 A 写 A 会死循环;派生关系优先用
computed。 - OnPush 下传方法调用:
[total]="computeTotal()"每次检查都执行,且 OnPush 下容易读到旧值;改为传信号total()或用computed预计算。 @for忘写track:@for的track是必填项,缺失直接编译错误;用业务 id 而非索引,避免错误复用视图。- 依赖 zone 的第三方组件:升级库版本或用
markForCheck包一层适配组件过渡。 - 大列表全量刷新:Zoneless 减少的是检查次数,不是渲染量;超大列表仍需虚拟滚动(见第 43 篇)。