CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-185 · Angular 高级教程

Angular 22+ 教程 20:Change Detection、OnPush 默认与 Zoneless

变更检测(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@fortrack 是必填项,缺失直接编译错误;用业务 id 而非索引,避免错误复用视图。
  • 依赖 zone 的第三方组件:升级库版本或用 markForCheck 包一层适配组件过渡。
  • 大列表全量刷新:Zoneless 减少的是检查次数,不是渲染量;超大列表仍需虚拟滚动(见第 43 篇)。

系列导航

← 算法 020:树:哈夫曼树(Huffman Tree) 目录 设计模式 020:中介者(Mediator) →
← 返回文章列表