過去在 Angular 中,只要遇到初始化、View 建立或資源清理,通常會先想到 Lifecycle Hooks:
ngOnInit() // Input 初始化後
ngAfterViewInit() // View / Child View 初始化後
ngAfterViewChecked() // View / Child View 每次檢查後
ngOnDestroy() // Component 銷毀前
這些 Hook 本身沒有問題,只是有些需求關心的其實不是 Component 走到哪個階段,而是 DOM 什麼時候完成 Render、某個 Signal 更新後畫面是否已經同步,或建立的資源要怎麼跟著目前的 Context 一起清理。
這類工作放在傳統 Lifecycle Hooks 中通常也能運作,但 Hook 描述的是 Component 的生命週期,和實際需求不一定完全一致。
Angular 另外提供了:
afterNextRender() // 註冊後最近一次 Render 完成時執行一次
afterEveryRender() // 每次 Angular Render 完成後都會執行
afterRenderEffect() // 追蹤 Signal,等相關變化 Render 完成後再執行
DestroyRef // 註冊目前 Context 被銷毀時要執行的 Cleanup
接下來就從幾個常見情境來看它們的差別。
假設 Component 中有一個 DOM Element:
<div #chart></div>
如果要初始化第三方 Chart Library,過去很常寫在:
ngAfterViewInit() {
const element = this.chart.nativeElement;
new Chart(element);
}
這樣寫沒有問題,因為 ngAfterViewInit() 執行時,Component 的 View 已經初始化完成,也可以取得 ViewChild 的結果。
但它仍然屬於 Change Detection Lifecycle,代表的是 View 已經初始化,不是專門提供給 Render 完成後的 DOM 操作。
其中一個常見情況,是在 ngAfterViewInit() 裡修改已經參與這一輪檢查的狀態:
loading = true;
ngAfterViewInit() {
this.loading = false;
}
Template 同時使用這個值:
@if (loading) {
<p>Loading...</p>
}
Angular 已經檢查過這個 View,這時又修改會影響畫面的狀態,在開發模式下就可能出現:
ExpressionChangedAfterItHasBeenCheckedError
問題不是 ngAfterViewInit() 不能修改資料,而是這一輪 Change Detection 已經檢查過畫面,值卻又在同一輪裡發生改變。
如果使用 Angular 22 預設的 Zoneless Change Detection,有些狀態變化不一定會立刻進入下一次檢查,因此這類問題不一定每次都會直接看到錯誤。
測試時可以在 main.ts 加上:
import {
provideCheckNoChangesConfig,
} from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideCheckNoChangesConfig({
exhaustive: true,
interval: 1000,
}),
],
});
provideCheckNoChangesConfig 參數說明:
exhaustive: true:檢查所有掛在 Application 上的 View,而不只目前被標記需要更新的 View。interval: 1000:每 1 秒額外執行一次 checkNoChanges 檢查。這樣即使某個 View 沒有再次被正常排進 Change Detection,也比較容易看到「畫面上次檢查到的值」和「目前實際值」已經不同,方便觀察 ExpressionChangedAfterItHasBeenCheckedError。
這個設定比較適合拿來除錯或示範,不需要為了避免這類問題長期開啟高頻率檢查。
另一種情況是直接操作 DOM:
ngAfterViewInit() {
const element = this.chart.nativeElement;
element.style.width = '500px';
const width =
element.getBoundingClientRect().width;
}
element.style.width 是修改 DOM,getBoundingClientRect() 則需要讀取 Element 實際排版後的尺寸。
如果程式一直在修改 DOM 後立刻讀取 Layout,瀏覽器可能需要反覆重新計算版面。這種 DOM Write 和 Read 不斷交錯的情況,就是常說的 Layout Thrashing。
如果需求本來就是等這一輪 Render 完成後再處理 DOM,就可以使用 afterNextRender()。
afterNextRender() 會在註冊後最近一次 Angular Render 完成時執行一次。
例如:
import {
afterNextRender,
Component,
ElementRef,
viewChild,
} from '@angular/core';
@Component({
selector: 'app-chart',
template: `
<div #chart></div>
`,
})
export class ChartComponent {
chart =
viewChild.required<ElementRef<HTMLDivElement>>(
'chart',
);
constructor() {
afterNextRender(() => {
const element =
this.chart().nativeElement;
new Chart(element);
});
}
}
這類寫法適合初始化第三方 UI Library、Canvas,或第一次需要讀取實際 DOM 狀態的工作。
兩者差異:
ngAfterViewInit() 關心的是 Component View 是否初始化完成。afterNextRender() 關心的是這一輪 Angular Render 是否已經完成。afterNextRender() 除了直接傳入 Callback,也可以把 DOM 操作拆成不同 Phase。
例如:
afterNextRender({
write: () => {
this.chart()
.nativeElement
.style.width = '500px';
},
read: () => {
const width =
this.chart()
.nativeElement
.getBoundingClientRect()
.width;
console.log(width);
},
});
這裡會先處理 DOM 修改,再讀取尺寸或位置,避免修改與讀取一直交錯。
Render Phase 一共有:
earlyRead // 優先讀取 DOM 狀態,只有真的需要在 Write 前讀取時使用
write // 修改 DOM,例如 style、class 或其他會影響版面的操作
mixedReadWrite // 同時包含 DOM 讀取與修改,能避免就盡量避免
read // 在所有 Write 完成後,再讀取尺寸、位置等 Layout 資訊
一般情況下,如果可以明確區分修改和讀取,優先使用 write 與 read 就好。
如果需求不是只執行一次,而是每次畫面更新後都要重新處理 DOM,過去可能會寫:
ngAfterViewChecked() {
this.measureElement();
}
但 ngAfterViewChecked() 代表的是 Angular 已經完成這個 Component View 的 Change Detection 檢查。
如果需求本來就是每次 Render 完成後都要讀取最新 DOM 狀態,這個 Hook 的語意就沒有那麼直接。
另外 ngAfterViewChecked() 本身執行頻率可能很高,如果裡面持續讀取 Layout 或做較重的 DOM 操作,也容易增加不必要的成本。
如果需要在每次 Application Render 完成後執行,可以使用:
afterEveryRender({
read: () => {
const height =
this.chart()
.nativeElement
.scrollHeight;
console.log(height);
},
});
和 afterNextRender() 相比,兩者最大的差別在執行次數:
afterNextRender():註冊後,在最近一次 Render 完成時執行一次,之後不會再觸發。afterEveryRender():註冊後,每次 Application Render 完成都會執行。因此 afterEveryRender() 裡的工作要盡量保持單純,否則和目前需求無關的 Render,也可能跟著執行同一段程式。
假設現在有一個 Signal:
chartData = signal([10, 20, 30]);
只有 chartData 改變時,才需要更新 Chart。
如果直接寫:
afterEveryRender(() => {
this.updateChart();
});
只要 Application 發生 Render,updateChart() 就會執行,即使這次變化和 Chart 沒有關係。
這時可能會想到前面介紹過的 effect():
effect(() => {
this.chart.updateData(
this.chartData(),
);
});
effect() 可以追蹤 chartData(),所以只有相關 Signal 改變時才會重新執行。
但如果後面的操作還會依賴最新 DOM 狀態,只知道 Signal 已經改變還不夠,因為 effect() 本身不代表這次變化造成的 Render 已經完成。
例如 Template 也使用了 chartData():
<p>資料筆數:{{ chartData().length }}</p>
<div #chart></div>
當 chartData 改變時,Angular 還需要把這次變化反映到畫面上。
如果第三方 Library 接下來要讀取最新的尺寸、位置或其他 DOM 狀態,就要等這次畫面更新完成後再處理。
這時就可以使用 afterRenderEffect()。
afterRenderEffect() 和 effect() 一樣,會追蹤執行過程中讀取的 Signal。
例如:
chartData = signal([10, 20, 30]);
constructor() {
afterRenderEffect(() => {
this.chart.updateData(
this.chartData(),
);
});
}
這裡讀取了 chartData(),所以它會成為 Dependency。
當 chartData 改變時,afterRenderEffect() 會等這次 Render 完成後再執行。
兩者可以先這樣區分:
effect():Signal 改變後執行 Side Effect。afterRenderEffect():Signal 改變後,等 Render 完成再處理 DOM 相關工作。如果只是寫 Log、同步 Storage,或呼叫不依賴 DOM 的程式,使用一般 effect() 就可以。
如果接下來要更新第三方 Chart、量測 Layout,或操作最新的 DOM,再考慮 afterRenderEffect()。
第三方 UI Library 很適合看出這兩個 API 的分工。
第一次建立 Chart:
constructor() {
afterNextRender(() => {
this.chart = new Chart(
this.canvas().nativeElement,
this.chartData(),
);
});
}
後續資料改變:
afterRenderEffect(() => {
this.chart.updateData(
this.chartData(),
);
});
第一次初始化只需要做一次,所以交給 afterNextRender();後續則根據 chartData 的變化更新 Chart,交給 afterRenderEffect()。
這樣不用每次 Render 都重新初始化 Chart,也不會因為其他和 Chart 無關的 Render 而執行更新。
afterRenderEffect() 也可以註冊 Cleanup。
例如:
afterRenderEffect((onCleanup) => {
const observer =
new ResizeObserver(() => {
console.log('resize');
});
observer.observe(
this.element().nativeElement,
);
onCleanup(() => {
observer.disconnect();
});
});
當 Effect 下一次重新執行,或對應的 Injection Context 被銷毀時,上一輪建立的 Observer 就會先被清掉。
如果需求本身就是監聽 DOM 狀態,瀏覽器其實已經提供對應的 API:
ResizeObserver:監聽 Element 尺寸變化。MutationObserver:監聽 DOM 結構或 Attribute 變化。IntersectionObserver:監聽 Element 是否進入可視範圍。所以不一定所有 DOM 監聽都需要交給 afterRenderEffect()。
afterNextRender()、afterEveryRender() 和 afterRenderEffect() 都不會在 SSR 或 Prerender 階段執行。
因此像這些只存在瀏覽器中的 API:
window
document
getBoundingClientRect()
Canvas
如果本來就只需要在 Render 後使用,可以直接放進 Render Callback,不需要另外把平台判斷和 Render 時機混在一起。
除了 Render,Lifecycle 還有另一個常見問題,就是 Cleanup。
例如:
export class DemoComponent
implements OnDestroy {
private timer = window.setInterval(
() => {
console.log('tick');
},
1000,
);
ngOnDestroy() {
clearInterval(this.timer);
}
}
這樣寫沒有問題。
比較麻煩的是,建立資源和清理資源的位置可能離得很遠。
Component 中如果同時建立 Timer、Event Listener、Observer 或第三方 Instance,最後全部集中在 ngOnDestroy() 清理,程式越大,就越容易新增了 Setup,卻忘記補上對應的 Cleanup。
DestroyRef 可以直接註冊目前 Context 被銷毀時要執行的 Callback。
例如:
import {
Component,
DestroyRef,
inject,
} from '@angular/core';
@Component({
selector: 'app-demo',
template: ``,
})
export class DemoComponent {
private destroyRef =
inject(DestroyRef);
constructor() {
const timer =
window.setInterval(() => {
console.log('tick');
}, 1000);
this.destroyRef.onDestroy(() => {
clearInterval(timer);
});
}
}
Timer 在哪裡建立,就可以在附近直接寫對應的 Cleanup。
DestroyRef 不是用來取代 ngOnDestroy(),而是讓建立與清理的程式不用拆得那麼遠。
這個特性在抽共用邏輯時會更明顯。
例如:
function setupTimer(
destroyRef: DestroyRef,
) {
const timer =
window.setInterval(() => {
console.log('tick');
}, 1000);
destroyRef.onDestroy(() => {
clearInterval(timer);
});
}
Component 只需要:
constructor() {
setupTimer(
inject(DestroyRef),
);
}
這段 Helper 自己就知道資源什麼時候建立,也知道什麼時候該清掉,不需要再回到 Component 的 ngOnDestroy() 裡補 Cleanup。
如果 Cleanup 對象是 Observable Subscription,可以直接使用:
takeUntilDestroyed()
例如:
this.userService.users$
.pipe(
takeUntilDestroyed(),
)
.subscribe(users => {
console.log(users);
});
當目前的 Injection Context 被 Destroy 時,Subscription 就會自動結束。
以前常見的寫法是自己準備:
private destroy$ =
new Subject<void>();
再在 ngOnDestroy() 裡:
this.destroy$.next();
this.destroy$.complete();
如果目的只是跟著 Angular 的生命週期取消 Subscription,takeUntilDestroyed() 可以少掉這一層手動管理。

傳統 Lifecycle Hooks 處理的是 Component 本身的生命週期,但有些需求其實更在意 Render 完成後的 DOM 狀態。這時 afterNextRender() 和 afterEveryRender() 可以直接對應 Render 完成後的工作。
如果還需要根據 Signal 的變化決定是否執行,afterRenderEffect() 就能補上一般 effect() 缺少的 Render 時機,避免資料已經改了,但 DOM 還沒更新完成就開始操作。
Cleanup 的部分則可以透過 DestroyRef,讓資源建立和清理放得更靠近;RxJS Subscription 也可以直接搭配 takeUntilDestroyed()。
這些 API 沒有取代傳統 Lifecycle Hooks,而是把 Component Lifecycle、Render 時機和 Cleanup 分得更清楚,寫法也會更接近實際要處理的問題。
onDestroy() 在建立資源的地方註冊 Cleanup。effect() 與 Render 時機的差異,以及 afterRenderEffect() 的使用情境。