3.1 变更检测机制深度剖析


文档摘要

3.1 变更检测机制深度剖析 变更检测是 Angular 在异步事件后自根向叶重算绑定表达式、按差异更新 DOM 的机制。本节是全册主峰:拆解默认策略的执行模型、OnPush 策略的跳过条件与例外、Zone.js 触发与信号触发两条路线。前两章的每一处伏笔——引用相等、管道纯度、状态容器选择——都在这里合龙。 带着一个问题进入 带着一个问题进入:第 1 章那个计数器实验里,屏幕到底怎么知道要更新?本节把这只黑箱完全拆开: 描述默认策略下检查的遍历顺序与单向数据流约束。 说出 OnPush 组件被检查的四种例外情形,并各举一例。 对比 Zone 触发与信号触发两条路线:粒度、成本、迁移策略。 用调试工具亲眼观察一次检查波及的组件范围。

3.1 变更检测机制深度剖析

变更检测是 Angular 在异步事件后自根向叶重算绑定表达式、按差异更新 DOM 的机制。本节是全册主峰:拆解默认策略的执行模型、OnPush 策略的跳过条件与例外、Zone.js 触发与信号触发两条路线。前两章的每一处伏笔——引用相等、管道纯度、状态容器选择——都在这里合龙。

带着一个问题进入

带着一个问题进入:第 1 章那个计数器实验里,屏幕到底怎么知道要更新?本节把这只黑箱完全拆开:

  1. 描述默认策略下检查的遍历顺序与单向数据流约束。
  2. 说出 OnPush 组件被检查的四种例外情形,并各举一例。
  3. 对比 Zone 触发与信号触发两条路线:粒度、成本、迁移策略。
  4. 用调试工具亲眼观察一次检查波及的组件范围。

一、默认策略的执行模型

第 1 章已给出初定义,现在精确化。每个组件在编译期生成了自己的检查函数,内容是"重算本组件模板里全部绑定并写 DOM"。一轮检查就是从根组件开始深度优先逐个执行这些检查函数。执行期间有一条铁律——单向数据流:检查中不允许再修改本组件或父辈已检查过的数据,否则抛"检查后变更"错误。

import { Component } from '@angular/core'; @Component({ selector: 'app-root', template: ` <app-a [x]="x"></app-a> <app-b [x]="x"></app-b> <!-- 检查顺序:本组件绑定 → A 子树 → B 子树;x 的重算只发生一次 --> ` }) export class RootComponent { x = 1; }

默认策略从不判断"这个组件可能没变"——只要检查启动,全树跑一遍。当树有几百个组件、每个组件几十个绑定时,每轮检查就是几千次表达式求值。好在单次求值很快,问题出在频率(第 2.10 节的六路轮询案例)与树规模的乘积上。

二、OnPush:给组件装上闸门

OnPush 策略的含义:除非有证据表明本组件可能变了,否则跳过它的检查(子树一并跳过)。证据有四种:

import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, inject } from '@angular/core'; @Component({ selector: 'app-order-row', changeDetection: ChangeDetectionStrategy.OnPush, // 装上闸门 template: `<li>{{ order.orderNo }}:{{ order.amount }} 元</li>` }) export class OrderRowComponent { @Input() order!: { orderNo: string; amount: number }; // 例外一:任一输入绑定的引用变化(或基础类型值变化) // 例外二:本组件模板里的事件被触发(点击、输入等发生在本组件内) // 例外三:手动标记(如下) // 例外四:模板里 async 管道收到流的新值 private cdr = inject(ChangeDetectorRef); manualRefresh() { this.cdr.markForCheck(); // 例外三:向上声明"我需要被检查" } }

四种例外对照四种真实场景:输入引用变——父组件用不可变更新换出新对象(2.9 节纪律在此兑现);组件内事件——用户直接操作本组件;手动标记——Zone 触发的回调改了数据但引用没换(定时器改对象内部字段的场景);async 管道——流式数据落地(管道内部自动标记)。

⚠️ 最经典的坑:OnPush 组件接收对象输入,父组件原地改对象字段(引用没变),组件永远不更新。解法两选一:父侧改为不可变更新(正道);子侧在已知时机手动 markForCheck(救急)。这也解释了 2.9 节为什么把"不可变更新"立为铁律。

三、两条触发路线

两条路线可以共存(过渡期常态):Zone 负责兜底全树检查,信号路径负责热点组件的精准更新。完全去 Zone 是近年版本的演进方向,收益是包体与每轮检查的组织成本,代价是所有"靠运气更新"的代码全部现形——必须改成显式的信号或手动标记。这个"现形"其实是好事:它把第 2 章反复强调的因果链变成了硬约束。

图:同一数据变化在两种策略下的检查范围

图:同一数据变化在两种策略下的检查范围

四、亲眼看见:调试实战

背景:怀疑列表页频繁检查导致卡顿,需要证据。
操作:浏览器开发者工具性能面板录制 10 秒操作;框架开发模式下查看组件树检查范围。
结果:火焰图中出现密集的检查相关调用栈,每次高频事件后都有一段;其中大半时间花在未被 OnPush 保护的列表行组件。
解读:性能面板给出的是"频率 × 单次成本"的乘积证据;把列表行组件加 OnPush 并配 trackBy 后再录,同样操作下检查相关耗时显著缩短。
变式:把高频动画的定时器用 NgZone 的 runOutsideAngular 移出 Zone,检查频率进一步下降——这是 3.2 节的第一招。

本节要点回顾

  • 执行模型:编译期生成每组件的检查函数,检查即自根向叶深度优先执行;单向数据流是硬约束。
  • 默认策略:不做任何"可能没变"的判断,成本 = 频率 × 树规模。
  • OnPush 四例外:输入引用变、组件内事件、手动 markForCheck、async 管道收值。
  • 不可变纪律的兑现:原地改对象字段在 OnPush 下等于不更新——第 2.9 节铁律的机制根源。
  • 两条路线:Zone 触发全树检查,信号触发精准检查;共存过渡、演进方向是去 Zone。

下一节把本节机制兑换成工程手段:性能优化。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U