1. 项目概述:Angular生命周期钩子不是“魔法”,而是你掌控组件行为的精确扳手
刚接触Angular时,我盯着ngOnInit()这个方法发了足足十分钟呆——它到底在什么时候执行?为什么不能直接在构造函数里初始化数据?后来带团队做性能优化,发现一个页面卡顿的根源竟是ngOnChanges()里没加防抖,导致每毫秒都触发一次DOM重绘。这才真正明白:Lifecycle Hooks(生命周期钩子)根本不是语法糖,而是Angular框架暴露给开发者的、对组件从创建到销毁全过程的精准控制接口。它们分布在组件实例的整个存在周期中,像一串精密校准的时间戳,让你能在输入属性变更的瞬间、视图首次渲染之后、模板更新完成的刹那、组件即将被移除前这些关键节点插入自定义逻辑。如果你正在用Angular开发中大型应用,或者正被“数据变了但界面不更新”“内存泄漏查不到源头”“初始化顺序混乱导致依赖报错”这类问题困扰,那么理解并正确使用这些钩子,就是绕不开的基本功。它不挑人——新手需要靠它建立对Angular运行机制的直觉,老手则依赖它做性能压测、资源清理和状态同步。核心关键词Angular、Lifecycle Hooks、ngOnInit、ngOnChanges、OnDestroy,每一个都对应着一个真实场景里的“救命时刻”。
2. 生命周期钩子的整体设计与思路拆解:为什么是这8个,而不是更多或更少?
Angular的生命周期钩子不是拍脑袋定的,而是严格遵循组件实例在框架内部的状态机流转路径。你可以把它想象成一台全自动咖啡机:豆子(组件类)放进料仓(模块注册),机器启动(组件实例化),经历研磨(构造函数)、萃取(变更检测)、打奶泡(视图渲染)、出杯(DOM挂载),最后清洗(销毁)。每个环节都有明确的输入输出和不可跳过的步骤,而钩子就是你在每个环节旁设置的传感器和控制阀。
2.1 八个钩子的完整时序链:从new到destroy的全旅程
Angular官方定义了8个标准钩子,它们按执行顺序严格排列,形成一条单向不可逆的时间轴:
constructor():这不是Angular钩子,但它是整个生命周期的物理起点。此时组件实例刚被new出来,@Input、@Output、@ViewChild等装饰器绑定的属性都还是undefined,this对象也尚未被Angular注入系统接管。它的唯一使命是基础初始化——比如声明私有变量、绑定事件回调的this指向。切记:这里不能调用任何依赖注入的服务方法,也不能访问任何模板相关属性。ngOnChanges():这是第一个真正的Angular钩子,也是最容易被误用的一个。它只在组件的@Input输入属性发生变更时触发,且每次变更都会调用。参数是一个SimpleChanges对象,里面详细记录了每个输入属性的currentValue(新值)、previousValue(旧值)和firstChange(是否首次变更)。它的设计哲学是“响应式驱动”——你不需要手动监听@Input变化,框架自动通知你。但代价是:如果父组件频繁更新@Input(比如滚动监听传入的scrollTop),这个钩子会高频触发,必须自行加节流。ngOnInit():组件初始化完成后的“成人礼”。此时@Input已赋值完毕,依赖注入的服务(如HttpClient、Router)已就位,@ViewChild/@ContentChild查询到的元素也已可用。90%的数据获取、订阅初始化、第三方库集成(如Chart.js初始化)都应该放在这里,而不是构造函数。我见过太多项目把HTTP请求写在constructor里,结果单元测试时因为服务未注入而崩溃。ngDoCheck():Angular变更检测的“显微镜”。默认情况下,Angular只检测@Input引用变化(即对象地址变了)和基本类型值变化。但如果你用了OnPush策略,或者需要检测对象内部属性变化(比如user.name变了但user引用没变),就得靠它。它会在每次变更检测周期开始时无条件执行,性能开销极大,非必要不启用。我们团队曾用它实现一个“深比较输入对象”的通用指令,但后来发现用ngOnChanges配合lodash.isEqual更轻量。ngAfterContentInit()和ngAfterContentChecked():这两个钩子专为<ng-content>投影内容服务。ngAfterContentInit在投影内容第一次被插入视图后触发,此时@ContentChild查询到的内容才真正可用;ngAfterContentChecked则在每次投影内容被检查后触发。它们的存在,是为了让你能安全地操作那些“不属于本组件模板,但被投射进来的外部内容”。ngAfterViewInit()和ngAfterViewChecked():与上一对对应,但作用于组件自身的视图。ngAfterViewInit在组件模板第一次渲染完成、所有@ViewChild元素(如<canvas>、<div #myDiv>)可访问后触发;ngAfterViewChecked则在每次视图变更检测后触发。DOM操作、第三方UI库初始化(如Bootstrap Modal、Select2)、Canvas绘图,必须放在这里,否则会遇到“元素不存在”的错误。ngOnDestroy():生命周期的终点站,也是防止内存泄漏的最后防线。所有在ngOnInit或ngAfterViewInit中创建的订阅(Observable.subscribe)、定时器(setInterval)、事件监听(addEventListener)、Promise链,都必须在这里取消或清理。Angular不会帮你做这件事——忘记unsubscribe()是导致生产环境内存泄漏的头号原因。我们有个监控告警规则:任何组件的ngOnDestroy方法体为空,CI构建直接失败。
提示:这八个钩子的执行顺序是硬编码在Angular源码中的,无法修改。你只能选择在哪个节点介入,不能跳过或重排。理解这个顺序,是写出可预测、易调试代码的前提。
2.2 为什么没有“ngOnCreated”或“ngOnAttached”?设计取舍背后的工程权衡
你可能会问:为什么没有一个钩子叫ngOnCreated,明确表示“组件实例已创建完毕”?或者ngOnAttached,表示“组件已挂载到DOM”?答案藏在Angular的设计哲学里:它不暴露底层实现细节,只暴露语义明确、稳定可靠的业务时机。ngOnInit已经足够表达“初始化完成”,再加一个ngOnCreated只会增加概念负担;而“挂载到DOM”在服务端渲染(SSR)或Web Worker环境下根本不存在,Angular要保证API在所有平台一致。这种克制,让开发者聚焦在“我要做什么”,而不是“框架底层怎么做的”。
另一个关键取舍是ngDoCheck的“昂贵性”。Angular本可以默认开启深度对象比较,但它选择了显式声明——只有当你主动实现ngDoCheck,才承担额外的性能成本。这体现了框架的务实:默认提供高性能,把复杂度和风险交给需要它的人。我们团队的规范是:除非业务强需求(如实时协作编辑的光标位置同步),否则禁用ngDoCheck,改用OnPush策略+手动触发ChangeDetectorRef.detectChanges()。
3. 核心钩子的实操要点与避坑指南:从原理到一行代码的真相
光知道钩子名字和顺序远远不够。真正决定项目质量的,是你在每一行代码里对细节的拿捏。下面我以四个最常用、也最容易出错的钩子为例,拆解它们的底层原理、典型用法和血泪教训。
3.1ngOnInit():初始化的黄金法则与三重陷阱
ngOnInit看似简单,却是新手踩坑最多的地方。它的核心原理是:Angular在完成组件实例化、注入依赖、赋值@Input后,主动调用你实现的这个方法。它不是事件监听,也不是异步回调,而是同步的、确定性的函数调用。
典型用法:
export class UserListComponent implements OnInit { users: User[] = []; loading = false; constructor(private userService: UserService, private router: Router) {} ngOnInit(): void { // ✅ 正确:服务已注入,可安全调用 this.loadUsers(); // ✅ 正确:路由服务可用,可监听参数变化 this.router.paramMap.subscribe(params => { const id = params.get('id'); if (id) this.loadUserDetails(id); }); } private loadUsers(): void { this.loading = true; this.userService.getAll().subscribe({ next: (data) => { this.users = data; this.loading = false; }, error: (err) => { console.error('Failed to load users', err); this.loading = false; } }); } }三大陷阱与破解方案:
陷阱一:在
constructor里调用异步操作// ❌ 危险!UserService可能未注入,且无法在单元测试中mock constructor(private userService: UserService) { this.userService.getAll().subscribe(...); // 这里userService可能是undefined }破解:所有异步初始化逻辑,一律移入
ngOnInit。构造函数只做同步、无副作用的初始化。陷阱二:
@ViewChild元素在ngOnInit中访问为null@ViewChild('myCanvas') canvasRef!: ElementRef<HTMLCanvasElement>; ngOnInit(): void { const ctx = this.canvasRef.nativeElement.getContext('2d'); // ❌ 报错:Cannot read property 'nativeElement' of undefined }原理:
@ViewChild查询的是组件模板渲染后的DOM元素,而ngOnInit执行时,模板尚未渲染。@ViewChild的可用时机是ngAfterViewInit。破解:将DOM操作移到ngAfterViewInit:ngAfterViewInit(): void { const ctx = this.canvasRef.nativeElement.getContext('2d'); // ✅ 此时canvasRef一定可用 }陷阱三:未处理异步操作的取消,导致内存泄漏
ngOnInit(): void { // ❌ 危险!组件销毁后,订阅依然存在,可能更新已销毁组件的状态 this.userService.getUser(1).subscribe(user => this.user = user); }破解:使用
takeUntil操作符,配合Subject在ngOnDestroy中发出完成信号:private destroy$ = new Subject<void>(); ngOnInit(): void { this.userService.getUser(1) .pipe(takeUntil(this.destroy$)) .subscribe(user => this.user = user); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); }
注意:
takeUntil是RxJS 7+推荐的方式,比手动unsubscribe()更简洁。如果你用的是旧版RxJS,记得在ngOnDestroy里保存每个Subscription并逐个调用unsubscribe()。
3.2ngOnChanges():输入变更的精密手术刀,而非万能监听器
ngOnChanges的参数SimpleChanges是一个Record<string, SimpleChange>对象,其中SimpleChange包含三个关键属性:currentValue、previousValue和firstChange。它的执行时机非常精确:只有当@Input绑定的表达式求值结果发生变化时才触发。这意味着,如果父组件传递的是一个常量(<app-user [name]="'John'"></app-user>),或者一个引用不变的对象(<app-user [user]="currentUser"></app-user>,而currentUser对象本身没变),ngOnChanges根本不会执行。
典型用法:响应式过滤与防抖
export class ProductListFilterComponent implements OnChanges { @Input() searchTerm: string = ''; @Input() category: string = ''; // 防抖控制器 private searchDebouncer = new Subject<string>(); private searchSubscription!: Subscription; ngOnChanges(changes: SimpleChanges): void { // 只对searchTerm变化做防抖处理 if (changes['searchTerm'] && !changes['searchTerm'].firstChange) { this.searchDebouncer.next(changes['searchTerm'].currentValue); } // category变化立即生效 if (changes['category'] && !changes['category'].firstChange) { this.applyCategoryFilter(changes['category'].currentValue); } } ngOnInit(): void { // 启动防抖流 this.searchSubscription = this.searchDebouncer .pipe(debounceTime(300), distinctUntilChanged()) .subscribe(term => this.applySearchFilter(term)); } ngOnDestroy(): void { this.searchSubscription?.unsubscribe(); } }致命误区与真相:
误区:“
ngOnChanges能监听对象内部属性变化”
真相:它只监听@Input绑定的引用变化。如果你传入{name: 'John', age: 30},然后只改age,ngOnChanges不会触发,因为对象引用没变。要监听内部变化,要么用ngDoCheck(不推荐),要么在父组件中创建新对象({...user, age: newAge}),要么在子组件内用ngDoCheck配合lodash.isEqual做深比较。误区:“
firstChange为true时,currentValue和previousValue都是undefined”
真相:firstChange为true时,previousValue是undefined,但currentValue是父组件传入的第一个有效值。你可以安全地用它做首次初始化:ngOnChanges(changes: SimpleChanges): void { if (changes['config'] && changes['config'].firstChange) { // 首次传入config,进行初始化配置 this.initConfig(changes['config'].currentValue); } }
3.3ngAfterViewInit():DOM操作的唯一安全区,以及它的“延迟”本质
ngAfterViewInit的执行时机,是组件模板第一次渲染完成,并且所有@ViewChild和@ViewChildren查询到的元素都已挂载到DOM中。但这里有个关键细节:它并不保证这些元素的CSS样式已计算完毕或布局已稳定。比如,你在一个<div #chartContainer>上调用chartContainer.nativeElement.offsetWidth,在ngAfterViewInit里可能得到0,因为父容器的宽度还没被CSS引擎计算出来。
典型用法:第三方库集成与Canvas初始化
export class ChartComponent implements AfterViewInit, OnDestroy { @ViewChild('chartContainer') containerRef!: ElementRef<HTMLDivElement>; private chart!: Chart; ngAfterViewInit(): void { // ✅ 确保containerRef可用 const container = this.containerRef.nativeElement; // ✅ 使用setTimeout确保CSS布局完成(这是Angular官方推荐的hack) setTimeout(() => { // 创建图表 this.chart = new Chart(container, { type: 'bar', data: this.chartData, options: this.chartOptions }); }, 0); } ngOnDestroy(): void { // ✅ 必须销毁图表实例,释放Canvas上下文 this.chart?.destroy(); } }为什么需要setTimeout?
Angular的变更检测和DOM渲染是分阶段的。ngAfterViewInit在Angular的“渲染阶段”结束时触发,但浏览器的“样式计算”和“布局”阶段(Layout)可能还未开始。setTimeout(fn, 0)将回调推入宏任务队列,在当前任务(包括Layout)完成后执行,从而确保你能拿到真实的尺寸。
更优雅的替代方案:ResizeObserver
ngAfterViewInit(): void { const container = this.containerRef.nativeElement; const resizeObserver = new ResizeObserver(() => { // 当容器尺寸变化时,重新调整图表大小 this.chart?.resize(); }); resizeObserver.observe(container); // 保存引用,以便在ngOnDestroy中停止观察 this.resizeObserver = resizeObserver; } ngOnDestroy(): void { this.resizeObserver?.disconnect(); }3.4ngOnDestroy():内存泄漏的终结者,以及它“永不执行”的幻觉
ngOnDestroy是Angular提供的、唯一一个保证在组件销毁前执行的钩子。它的存在,就是为了给你一个清理资源的机会。但很多开发者有一个危险的幻觉:认为只要写了ngOnDestroy,内存就绝对安全了。事实是:ngOnDestroy本身也可能被跳过。
什么情况下ngOnDestroy不执行?
- 页面强制刷新(F5)或关闭标签页:JavaScript执行环境被浏览器直接销毁,Angular没有机会运行任何清理代码。
- 组件被
*ngIf动态移除,但父组件本身也在销毁过程中:Angular的销毁流程是递归的,如果父组件的ngOnDestroy抛出未捕获异常,子组件的钩子可能不会执行。 - 在
ngOnDestroy里又触发了新的异步操作,且该操作未被正确取消:比如在ngOnDestroy里又发起一个HTTP请求,这个请求的订阅如果没有被takeUntil保护,依然会造成泄漏。
最佳实践清单:
- 所有异步源,必须配对清理:
Observable.subscribe→takeUntil;setInterval→clearInterval;addEventListener→removeEventListener;Promise.then→ 无直接清理方式,应避免在ngOnDestroy里启动新Promise。 - 第三方库实例,必须显式销毁:Chart.js的
destroy()、Mapbox的remove()、WebSocket的close()。 - 避免在
ngOnDestroy里做耗时操作:它应该是一个快速、确定性的清理过程。不要在这里调用HTTP API或执行复杂计算。 - 添加防御性检查:在清理前,先判断资源是否存在,避免
Cannot read property 'xxx' of undefined错误:ngOnDestroy(): void { if (this.chart) { this.chart.destroy(); } if (this.subscription) { this.subscription.unsubscribe(); } }
4. 实操过程与核心环节实现:从零搭建一个带完整生命周期管理的搜索组件
现在,让我们把前面所有的理论,落地到一个真实、可运行的组件中。这个SearchComponent将演示如何协调ngOnChanges、ngOnInit、ngAfterViewInit和ngOnDestroy,构建一个高性能、无泄漏的搜索体验。
4.1 组件需求与架构设计
我们需要一个搜索框,具备以下能力:
- 接收父组件传入的
initialQuery(初始搜索词)和debounceTimeMs(防抖毫秒数)。 - 在用户输入时,防抖后触发搜索。
- 搜索结果以列表形式展示,支持点击跳转。
- 组件销毁时,确保所有订阅和定时器被清理。
- 支持SSR(服务端渲染),因此不能在
ngAfterViewInit里做DOM操作。
架构决策:
- 使用
ReactiveFormsModule的FormControl管理输入状态,比模板驱动更可控。 - 防抖逻辑放在
ngOnChanges中,因为debounceTimeMs可能由父组件动态控制。 - 搜索执行放在
ngOnInit中初始化,利用valueChangesObservable的管道能力。 - DOM相关的焦点管理(如输入框自动聚焦)放在
ngAfterViewInit。 - 清理逻辑集中在
ngOnDestroy,使用takeUntil统一管理。
4.2 完整代码实现与逐行注释
// search.component.ts import { Component, OnInit, OnChanges, AfterViewInit, OnDestroy, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { Observable, Subject, Subscription, timer } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap, takeUntil, catchError, startWith } from 'rxjs/operators'; @Component({ selector: 'app-search', template: ` <div class="search-container"> <input #searchInput [formControl]="searchControl" type="text" placeholder="Search..." class="search-input" /> <ul class="results-list" *ngIf="searchResults$ | async as results; else noResults"> <li *ngFor="let result of results" class="result-item" (click)="onResultClick(result)"> {{ result.title }} </li> </ul> <ng-template #noResults> <p class="no-results">No results found.</p> </ng-template> </div> `, styles: [` .search-container { position: relative; } .search-input { width: 100%; padding: 8px; } .results-list { margin: 0; padding: 0; list-style: none; } .result-item { padding: 8px; cursor: pointer; } .result-item:hover { background-color: #f0f0f0; } `] }) export class SearchComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { // 输入属性 @Input() initialQuery: string = ''; @Input() debounceTimeMs: number = 300; // 输出事件 @Output() searchSubmitted = new EventEmitter<string>(); @Output() resultSelected = new EventEmitter<any>(); // 视图子元素 @ViewChild('searchInput') searchInputRef!: ElementRef<HTMLInputElement>; // 响应式表单控件 searchControl = new FormControl(''); // 搜索结果流 searchResults$: Observable<any[]> = new Observable(); // 内部状态 private searchSubscription!: Subscription; private destroy$ = new Subject<void>(); // 构造函数:仅做最小初始化 constructor(private cdRef: ChangeDetectorRef) {} // 1. 初始化:设置表单值,启动搜索流 ngOnInit(): void { // 设置初始值(注意:setValue会触发valueChanges,所以要在流创建后设置) this.searchControl.setValue(this.initialQuery); // 创建搜索流:监听输入变化 -> 防抖 -> 调用搜索服务 -> 处理错误 this.searchResults$ = this.searchControl.valueChanges.pipe( // 1. 防抖:但这里的防抖时间是固定的,动态防抖在ngOnChanges里处理 debounceTime(this.debounceTimeMs), distinctUntilChanged(), // 2. 切换搜索:取消之前的搜索请求,只保留最新的 switchMap(query => { if (!query.trim()) { return new Observable<any[]>(observer => { observer.next([]); observer.complete(); }); } return this.performSearch(query).pipe( catchError(err => { console.error('Search failed:', err); return new Observable<any[]>(observer => { observer.next([]); observer.complete(); }); }) ); }), // 3. 确保流始终有值(startWith([])),避免*ngIf的闪烁 startWith([]) ); // 4. 订阅搜索流,用于提交事件(可选) this.searchSubscription = this.searchControl.valueChanges.pipe( debounceTime(this.debounceTimeMs), distinctUntilChanged(), takeUntil(this.destroy$) ).subscribe(query => { if (query) { this.searchSubmitted.emit(query); } }); } // 2. 输入变更:动态调整防抖时间 ngOnChanges(changes: import("@angular/core").SimpleChanges): void { // 如果debounceTimeMs发生变化,需要重新创建搜索流 if (changes['debounceTimeMs'] && !changes['debounceTimeMs'].firstChange) { // 取消旧的订阅 this.searchSubscription?.unsubscribe(); // 重新创建搜索流,使用新的防抖时间 this.searchResults$ = this.searchControl.valueChanges.pipe( debounceTime(changes['debounceTimeMs'].currentValue), distinctUntilChanged(), switchMap(query => this.performSearch(query)), startWith([]) ); // 重新订阅 this.searchSubscription = this.searchControl.valueChanges.pipe( debounceTime(changes['debounceTimeMs'].currentValue), distinctUntilChanged(), takeUntil(this.destroy$) ).subscribe(query => { if (query) this.searchSubmitted.emit(query); }); } } // 3. 视图初始化:聚焦输入框(仅在浏览器环境) ngAfterViewInit(): void { // 使用setTimeout确保DOM渲染完成 setTimeout(() => { if (typeof document !== 'undefined') { this.searchInputRef.nativeElement.focus(); } }, 0); } // 4. 销毁:清理所有资源 ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); this.searchSubscription?.unsubscribe(); } // 模拟搜索服务调用(实际项目中替换为HttpClient) private performSearch(query: string): Observable<any[]> { // 模拟API延迟 return timer(500).pipe( map(() => [ { id: 1, title: `Result for "${query}" #1` }, { id: 2, title: `Result for "${query}" #2` } ]) ); } // 结果点击处理 onResultClick(result: any): void { this.resultSelected.emit(result); } }4.3 关键实现点深度解析
ngOnChanges的动态防抖重置:
这是代码中最精妙的一环。debounceTimeMs是一个@Input,它可能在组件生命周期中被父组件动态修改(比如用户在设置里调整了搜索灵敏度)。我们不能让旧的防抖时间继续生效,所以ngOnChanges里检测到它变化后,先取消旧订阅,再用新时间创建新流。这保证了行为的完全可控。searchResults$的startWith([]):
这个操作符确保searchResults$流在创建后立即发出一个空数组[]。这样,*ngIf="searchResults$ | async as results"在初始状态下就能拿到results,避免了results为undefined导致的模板渲染错误。这是处理异步流的黄金习惯。ngAfterViewInit中的setTimeout与document检查:setTimeout解决布局问题,typeof document !== 'undefined'则是为了SSR兼容。在Node.js服务端环境中,document不存在,直接调用focus()会报错。这个检查让组件在服务端和客户端都能安全运行。performSearch的模拟实现:
实际项目中,这里会是this.http.get<any[]>(\/api/search?q=${query}`)。我们用timer(500)模拟网络延迟,map操作符生成假数据。关键是,它被包裹在switchMap`中,确保了“最新请求优先”的语义。
5. 常见问题与排查技巧实录:来自生产环境的12个真实案例
在过去的三年里,我参与了6个大型Angular项目的上线和维护,亲手排查了上百个生命周期相关的Bug。下面整理出12个最具代表性的案例,附上根因分析和一招制敌的解决方案。这些不是教科书理论,而是深夜线上救火后记在笔记本上的血泪笔记。
5.1 “页面白屏,控制台报错:Cannot read property 'xxx' of undefined”
现象:页面加载后一片空白,Chrome DevTools Console显示类似错误。
根因:在ngOnInit或ngAfterViewInit中,过早访问了尚未初始化的@Input或@ViewChild。
排查:在报错行前加断点,检查目标变量的值。如果为undefined,说明访问时机太早。
解决方案:
- 对于
@Input:确保在ngOnChanges或ngAfterViewInit之后访问,或在模板中用*ngIf="inputProp"做守卫。 - 对于
@ViewChild:永远在ngAfterViewInit或ngAfterViewChecked中访问,并在访问前加if (this.childRef) { ... }检查。
5.2 “搜索框输入,结果列表不更新,但控制台能看到HTTP请求成功”
现象:HttpClient返回了数据,this.results = data也执行了,但模板里的*ngFor没刷新。
根因:组件使用了ChangeDetectionStrategy.OnPush,而this.results被赋值后,Angular没有检测到变更。
排查:检查组件@Component元数据中是否有changeDetection: ChangeDetectionStrategy.OnPush。
解决方案:
- 方案A(推荐):在赋值后手动触发变更检测:
this.cdRef.detectChanges()。 - 方案B:改用
Immutable数据结构,用...展开新数组:this.results = [...data],这样引用变化会被OnPush检测到。
5.3 “切换路由后,旧页面的定时器还在跑,CPU飙升”
现象:从A页面导航到B页面,A页面的setInterval仍在后台执行,console.log持续输出。
根因:ngOnDestroy中忘记调用clearInterval。
排查:在ngOnDestroy里加console.log('destroying...'),看是否执行。如果不执行,检查是否被异常中断。
解决方案:
- 在
ngOnInit中保存intervalId:this.intervalId = setInterval(...)。 - 在
ngOnDestroy中清除:if (this.intervalId) clearInterval(this.intervalId)。 - 更健壮的做法:用
Subject+takeUntil管理所有异步源。
5.4 “ngOnChanges被调用了两次,第一次firstChange为true,第二次为false,但值一样”
现象:ngOnChanges日志显示同一属性连续触发两次。
根因:父组件在ngOnInit中设置了@Input,然后又在ngAfterViewInit中再次设置,导致两次变更。
排查:在父组件中搜索对该@Input的赋值语句,检查是否有多处。
解决方案:
- 确保
@Input只在一处被赋值。 - 或者在子组件
ngOnChanges中,对firstChange为true的情况做特殊处理,避免重复初始化。
5.5 “ngAfterViewChecked无限循环,页面卡死”
现象:浏览器无响应,控制台疯狂打印ngAfterViewChecked日志。
根因:在ngAfterViewChecked中修改了影响视图的数据(如this.items.push(newItem)),触发了新一轮变更检测,又进入ngAfterViewChecked,形成死循环。
排查:在ngAfterViewChecked第一行加console.log('checked'),看是否无限打印。
解决方案:
- 绝对禁止在
ngAfterViewChecked中修改任何绑定到模板的数据。 - 如果必须修改,用
ChangeDetectorRef.detach()先脱离检测,修改完再reattach(),但这通常是设计缺陷的标志,应回溯重构。
5.6 “ngDoCheck性能爆炸,页面滚动卡顿”
现象:页面滚动时明显掉帧,Performance面板显示ngDoCheck占用大量CPU。
根因:ngDoCheck中做了重量级操作,如遍历大数组、调用JSON.stringify做深比较。
排查:在ngDoCheck中加console.time('docheck')/console.timeEnd('docheck'),测量耗时。
解决方案:
- 移除
ngDoCheck,改用OnPush+@Input引用变更。 - 如果必须深比较,用
lodash.isEqual代替JSON.stringify,并缓存比较结果。
5.7 “ngOnDestroy没执行,内存泄漏严重”
现象:Chrome Memory面板中,组件实例在导航后仍存在于堆快照中。
根因:ngOnDestroy被跳过,最常见的原因是:在ngOnDestroy之前,组件的某个@Output事件处理器中抛出了未捕获异常。
排查:在ngOnDestroy第一行加console.log,确认是否执行。如果不执行,检查所有事件处理器。
解决方案:
- 在所有
@Output事件处理器中加try...catch。 - 使用
takeUntil(this.destroy$)作为兜底方案,即使ngOnDestroy没执行,destroy$的完成也会终止所有管道。
5.8 “ngAfterContentInit中@ContentChild为undefined”
现象:@ContentChild查询不到投射进来的内容。
根因:投射内容是异步加载的(如*ngIf或*ngFor),在ngAfterContentInit执行时还未渲染。
排查:检查<ng-content>中是否包含条件渲染指令。
解决方案:
- 改用
ngAfterContentChecked,它在每次内容检查后都执行。 - 或者在
ngAfterContentChecked中加防抖,避免高频触发。
5.9 “ngOnInit中调用this.router.navigate,页面没跳转”
现象:navigate方法执行了,但URL没变,页面也没刷新。
根因:router.navigate是异步的,如果在ngOnInit中调用,而组件又很快被销毁(如路由守卫拒绝),导航会被取消。
排查:在navigate后加.then(console.log),看是否进入then。
解决方案:
- 在
ngAfterViewInit中调用,确保组件已稳定。 - 或者用
router.navigateByUrl,它更底层,失败时会抛出错误,便于捕获。
5.10 “ngOnChanges的SimpleChanges里,currentValue是旧值”
现象:ngOnChanges中打印changes.prop.currentValue,发现是上一次的值。
根因:@Input绑定的表达式本身有副作用,比如[prop]="getProp()",而getProp()每次返回不同值,导致Angular的变更检测逻辑混乱。
排查:检