生命周期钩子(Lifecycle Hooks)是组件从创建到销毁过程中的固定回调点。理解每个钩子的触发时机、执行顺序,以及信号时代哪些钩子可以”退休”,是从会写 Angular 到写好 Angular 的必经之路。本文基于 Angular v22:钩子 API 全部保留,但推荐用法已大幅向信号 API 迁移。
生命周期全景
| 钩子 | 接口 | 调用时机 | 信号时代还需要吗 |
|---|---|---|---|
ngOnChanges | OnChanges | 首次及每次 @Input 装饰器输入变化后(先于 ngOnInit) | 优先用 input() + computed / effect 替代 |
ngOnInit | OnInit | 首次 ngOnChanges 之后调用一次 | 仍常用:一次性初始化 |
ngDoCheck | DoCheck | 每次变更检测周期 | 极少;配合 differ 做细粒度 diff |
ngAfterContentInit | AfterContentInit | 内容投影首次渲染后 | contentChild() / contentChildren() 信号后通常不需要 |
ngAfterContentChecked | AfterContentChecked | 每次投影内容检查后 | 少用 |
ngAfterViewInit | AfterViewInit | 组件自身及子视图首次渲染后 | viewChild() 信号查询后通常不需要 |
ngAfterViewChecked | AfterViewChecked | 每次视图检查后 | 少用,常被误当作”CD 钩子” |
ngOnDestroy | OnDestroy | 实例销毁前 | 仍核心;DestroyRef / takeUntilDestroyed 是等价形态 |
注意:ngOnChanges 只响应 @Input 装饰器输入。函数式 input() 的变化不会触发它——这是迁移老代码时最容易踩的坑。
执行顺序
单次创建流程(忽略子组件交错的细节):
sequenceDiagram
participant P as 父组件
participant C as 子组件
P->>C: 实例化(构造器执行)
C->>C: ngOnChanges(首次 @Input)
C->>C: ngOnInit
C->>C: ngDoCheck
C->>C: ngAfterContentInit
C->>C: ngAfterContentChecked
C->>C: ngAfterViewInit
C->>C: ngAfterViewChecked
Note over C: 交互与后续变更检测周期
P->>C: ngOnDestroy(销毁前)
两个容易忽略的细节:
- 父子组件的钩子是交错的:子组件的
ngAfterViewInit先于父组件的ngAfterViewInit(深度优先,子视图渲染完毕父视图才算完毕)。 - 开发模式下 Angular 会对变更检测执行双检查(detect changes twice),因此”在钩子里同步修改模板已读取的状态”会抛出
ExpressionChangedAfterItHasBeenCheckedError——这在生产模式不报错,属于开发期保护。
关键钩子详解
ngOnInit:一次性初始化
构造器里只做轻量工作(依赖注入、字段默认值),需要依赖输入的初始化放 ngOnInit(此时 @Input 已绑定)。不过信号时代有了更细的划分:初始化”组件自身状态”用构造器里的 computed / effect 也可以表达,ngOnInit 更多承担”发起首次数据加载”这类副作用。
ngOnChanges:响应输入变化
历史写法:
export class FilterComponent implements OnChanges {
@Input() keyword = '';
ngOnChanges(changes: SimpleChanges) {
if (changes['keyword']) {
this.search(changes['keyword'].currentValue);
}
}
}
v22 推荐写法:
export class FilterComponent {
keyword = input.required<string>();
constructor() {
effect(() => this.search(this.keyword()));
}
}
AfterContent 与 AfterView:投影与视图
ngAfterContentInit/Checked:<ng-content>投影内容渲染完毕,此时可以安全读取@ContentChild查询结果。ngAfterViewInit/Checked:自身模板渲染完毕,此时可以安全读取@ViewChild查询结果、执行 DOM 测量。
ngOnDestroy:清理
凡是手工申请的资源——定时器、订阅、WebSocket、浏览器监听器——都要在这里归还。v22 提供了两个更不易漏的形态:
import { DestroyRef, inject, takeUntilDestroyed } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class TimerComponent implements OnDestroy {
private destroyRef = inject(DestroyRef);
constructor() {
// 形态一:DestroyRef 注册回调
const id = setInterval(() => this.tick(), 1000);
this.destroyRef.onDestroy(() => clearInterval(id));
// 形态二:RxJS 订阅自动跟随组件销毁
this.http.get<Stats>('/api/stats')
.pipe(takeUntilDestroyed())
.subscribe(stats => this.stats.set(stats));
}
ngOnDestroy() {
// 传统钩子仍有效,三种形态可共存
}
}
信号时代:谁退休了
| 传统组合 | v22 推荐 | 说明 |
|---|---|---|
@Input() + ngOnChanges | input() / input.required() + computed / effect | 声明式响应;信号 input 不触发 ngOnChanges |
@ViewChild + ngAfterViewInit | viewChild() / viewChild.required() | 声明即用,模板与 effect 中读取自动追踪 |
@ContentChild + ngAfterContentInit | contentChild() / contentChildren() | 同上 |
| AfterViewInit 里操作 DOM | afterNextRender / afterRenderEffect | 与变更检测解耦,SSR 安全 |
| ngOnDestroy 手动退订 | takeUntilDestroyed / DestroyRef.onDestroy | 样板代码更少、更难漏 |
afterRenderEffect:渲染后副作用
需要”每次渲染后都执行”的 DOM 操作(滚动到底部、测量尺寸),用 afterRenderEffect:
export class ChatComponent {
private list = viewChild.required<ElementRef<HTMLElement>>('list');
messages = signal<Message[]>([]);
constructor() {
afterRenderEffect(() => {
const el = this.list().nativeElement;
el.scrollTop = el.scrollHeight;
});
}
}
它支持按阶段注册回调(beforeRender / afterRender),读取 DOM 的动作放在渲染前后这个明确窗口内,避免与变更检测互相干扰。只在下一次渲染执行一次的场景用 afterNextRender。
Zoneless 下的注意点
Zoneless 稳定后(v21 起),钩子本身照常执行,区别在于”谁触发刷新”:只有信号写入等显式通知才会调度变更检测。因此:
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() | date: 'HH:mm:ss' }}</div>
推论:在 ngOnInit 等钩子里修改”普通字段”对模板不可见(历史上依赖 zone.js 补丁的隐式刷新在 Zoneless 下消失)。凡是模板要读的状态,一律用信号表达。
常见坑
- 构造器里读取信号 input 得到
undefined(非 required 时):首次变更检测之前输入尚未绑定。需要”输入就绪后”的逻辑放到effect或ngOnInit(@Input场景)里。required input 在绑定前读取会直接抛运行时错误,这在开发期能更快暴露问题。 ExpressionChangedAfterItHasBeenCheckedError:在ngAfterViewInit里同步修改父组件已渲染的状态。修复方向:改为computed声明派生状态,或用afterNextRender/effect延后一拍。- 钩子与继承:子类覆写钩子时记得调用
super.ngOnInit(),Angular 不会自动串联。 ngAfterViewChecked里做重活是性能杀手:它每个 CD 周期都跑,且常引发上面的 ExpressionChanged 错误。