2.2 组件与变更检测单元 组件是 Angular 的基本构件:一个装饰器修饰的类,配模板与样式,拥有输入、输出与生命周期。本节的核心观点:组件树上的每个组件就是变更检测的一个执行单元——检查自根向叶推进,父组件绑定的新值成为子组件的输入,输入变了子组件模板就被重算。 上一节把树的组织讲完了(模块与注入器),这一节看树上的节点:它的三个通信接口与生命周期,以及"父传子"在检查序列里的位置。 承接与目标 2.1 解决了"代码放哪、服务从哪来",本节进入主线的心脏——组件,以及它作为检查单元的职责边界: 写出组件的输入与输出接口,说明 @Input 的 setter 拦截与 ngOnChanges 的触发条件。 按执行顺序列出主要生命周期钩子,指出它们在一轮变更检测中的位置。
组件是 Angular 的基本构件:一个装饰器修饰的类,配模板与样式,拥有输入、输出与生命周期。本节的核心观点:组件树上的每个组件就是变更检测的一个执行单元——检查自根向叶推进,父组件绑定的新值成为子组件的输入,输入变了子组件模板就被重算。
上一节把树的组织讲完了(模块与注入器),这一节看树上的节点:它的三个通信接口与生命周期,以及"父传子"在检查序列里的位置。
2.1 解决了"代码放哪、服务从哪来",本节进入主线的心脏——组件,以及它作为检查单元的职责边界:
组件与外界的一切联系,归纳起来就三个方向:数据进(输入)、事件出(输出)、内容嵌(投影)。
import { Component, Input, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-order-card', template: ` <div class="card"> <h4>{{ title }}</h4> <!-- 输入的变化经由绑定流入;插值在每轮检查中被重算 --> <p>金额:{{ order?.amount }} 元</p> <!-- ng-content:宿主塞进来的内容在这里渲染 --> <ng-content select="[footer]"></ng-content> <button (click)="pay.emit(order!.orderNo)">支付</button> </div> ` }) export class OrderCardComponent { @Input() title = '订单'; // 数据从父组件流入 @Input() order?: { orderNo: string; amount: number }; @Input() set discount(v: number) { // setter 拦截:输入一变就执行 this.finalAmount = (this.order?.amount ?? 0) * (1 - v); } finalAmount = 0; @Output() pay = new EventEmitter<string>(); // 事件向父组件冒出 }
@Input 的 setter 拦截是个被低估的技巧:不需要等整轮检查走到某个钩子,输入被写入的瞬间逻辑就执行。代价是 setter 里不该做重活——它每轮检查都可能被调用(只要父组件绑定的表达式结果变化)。
钩子的顺序必须当知识点背下来,排错时会反复用到:
import { Component, OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy } from '@angular/core'; @Component({ selector: 'app-lifecycle-demo', template: `<span>{{ data }}</span>` }) export class LifecycleDemoComponent implements OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy { @Input() data = ''; ngOnChanges() { /* 仅当输入绑定值变化时触发,且在检查开始阶段 */ } ngOnInit() { /* 首轮检查前执行一次:拉取初始数据的经典位置 */ } ngDoCheck() { /* 每轮检查都执行:自定义变更侦测的最后手段,慎用 */ } ngAfterContentInit() { /* 投影内容首次渲染完成 */ } ngAfterContentChecked() { /* 每轮检查中投影内容检查完 */ } ngAfterViewInit() { /* 组件自身视图首次渲染完成,此后才能安全读 DOM */ } ngAfterViewChecked() { /* 每轮检查中自身视图检查完 */ } ngOnDestroy() { /* 销毁前:退订可观察对象、清理定时器的位置 */ } }

⚠️ 经典报错:在 ngAfterViewChecked 里修改本组件模板绑定的数据,会触发"ExpressionChangedAfterItHasBeenChecked"。原因正是时序——自身视图已经检查完,再改数据等于绕过本轮检查。解法是把修改挪到下一轮(setTimeout 包裹)或改用输入驱动。
背景:订单列表页(父)持有选中的订单号,详情卡(子)要在选中变化时刷新展示。
操作:
// 父组件:selected 变化后,绑定表达式 orderFor(selected) 在下一轮检查中被重算 @Component({ selector: 'app-order-page', template: ` <app-order-detail [order]="orderFor(selected)"></app-order-detail> ` }) export class OrderPageComponent { selected = 'A-1'; orders = [ { orderNo: 'A-1', amount: 199, status: '已付款' }, { orderNo: 'A-2', amount: 88, status: '待支付' } ]; orderFor(no: string) { return this.orders.find(o => o.orderNo === no)!; } } // 子组件:输入 setter 里做轻量预处理,ngOnChanges 里做重处理 @Component({ selector: 'app-order-detail', template: `<p>{{ order.orderNo }}:{{ order.amount }}元 {{ order.status }}</p>` }) export class OrderDetailComponent implements OnChanges { @Input() order!: { orderNo: string; amount: number; status: string }; ngOnChanges() { console.log('输入变化', this.order.orderNo); } }
结果:父组件修改 selected,点击等异步事件结束后一轮检查启动,父模板绑定重算产出新 order 对象,写进子组件输入,子组件 ngOnChanges 触发,子模板重算,屏幕更新。
解读:这条"父模板求值 → 子输入写入 → 子模板求值"的链路就是单向数据流的具体形态,也是默认变更检测策略的全部工作方式。
变式:如果父组件传的是同一对象引用只改了内部字段,ngOnChanges 不会触发(引用未变)——这正是不纯管道与 OnPush 陷阱的共同根源,第 3 章展开。
下一节把"谁给组件供数据"讲透:服务与依赖注入。