iT邦幫忙

2026 iThome 鐵人賽

DAY 22
2
Modern Web

Angular 22 Signal 進化論系列 第 22 篇

Day 22:Angular Render Lifecycle,從 afterNextRender 到 DestroyRef

  • 分享至 

  • xImage
  •  

過去在 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

接下來就從幾個常見情境來看它們的差別。

ngAfterViewInit() 的限制

假設 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():Render 完成後執行一次

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 是否已經完成。

拆分 DOM Read 與 Write

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 就好。

ngAfterViewChecked() 的限制

如果需求不是只執行一次,而是每次畫面更新後都要重新處理 DOM,過去可能會寫:

ngAfterViewChecked() {
  this.measureElement();
}

但 ngAfterViewChecked() 代表的是 Angular 已經完成這個 Component View 的 Change Detection 檢查。

如果需求本來就是每次 Render 完成後都要讀取最新 DOM 狀態,這個 Hook 的語意就沒有那麼直接。

另外 ngAfterViewChecked() 本身執行頻率可能很高,如果裡面持續讀取 Layout 或做較重的 DOM 操作,也容易增加不必要的成本。

afterEveryRender():每次 Render 完成後執行

如果需要在每次 Application Render 完成後執行,可以使用:

afterEveryRender({
  read: () => {
    const height =
      this.chart()
        .nativeElement
        .scrollHeight;

    console.log(height);
  },
});

和 afterNextRender() 相比,兩者最大的差別在執行次數:

  • afterNextRender():註冊後,在最近一次 Render 完成時執行一次,之後不會再觸發。
  • afterEveryRender():註冊後,每次 Application Render 完成都會執行。

因此 afterEveryRender() 裡的工作要盡量保持單純,否則和目前需求無關的 Render,也可能跟著執行同一段程式。

afterEveryRender() 不知道是哪個資料改變

假設現在有一個 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():Signal 改變後等 Render 完成

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()。

afterNextRender() 與 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() 也可以註冊 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()。

Render API 與 SSR

afterNextRender()、afterEveryRender() 和 afterRenderEffect() 都不會在 SSR 或 Prerender 階段執行。

因此像這些只存在瀏覽器中的 API:

window
document
getBoundingClientRect()
Canvas

如果本來就只需要在 Render 後使用,可以直接放進 Render Callback,不需要另外把平台判斷和 Render 時機混在一起。

ngOnDestroy() 的限制

除了 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:讓 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(),而是讓建立與清理的程式不用拆得那麼遠。

DestroyRef 也可以交給其他 Function

這個特性在抽共用邏輯時會更明顯。

例如:

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。

RxJS 可以直接使用 takeUntilDestroyed()

如果 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() 可以少掉這一層手動管理。

本日小結

Angular Render Hooks 怎麼選?看執行次數、Signal 依賴與 Cleanup

傳統 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 分得更清楚,寫法也會更接近實際要處理的問題。

資料來源


上一篇
Day 21:Angular Template 還適合呼叫 Method 嗎?從 Pipe、RxJS 到 computed
下一篇
Day 23:RouteReuseStrategy,切換路由一定要銷毀 Component 嗎?
系列文
Angular 22 Signal 進化論 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言