iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Modern Web

Angular:踏上現代英雄之旅系列 第 28

Day 28|變更檢測:Zoneless

  • 分享至 

  • xImage
  •  

哈囉,各位邦友們!
昨日把專案部署到 GitHub Pages。
接下來就該面對效能,視角放回 Angular 的變更檢測,並探討新版框架提供的 zoneless 模式,看看 Signals 如何帶我們走向更俐落的更新策略。

一、Zone.js 為何帶來方便,卻也埋下一顆效能炸彈?

回顧 Day02,我們啟動專案時就勾選了「zoneless」的選項;app.config.ts 也透過 provideZonelessChangeDetection() 宣示這項設定。要理解它的價值,得先來回顧 Zone.js。

  • Angular 透過 Zone.js 攔截像 setTimeout、事件、Promise 這類非同步操作,結束時自動呼叫 ApplicationRef.tick(),整棵元件樹都被迫重新檢查。
  • 在 Day06~Day12 的互動與 HTTP 實作裡,這種「全域刷新」讓我們幾乎不用管非同步細節,畫面自然更新。但當應用越來越大,每次微小變動都觸發全體元件重跑 change detection,成本就開始累積。
  • 若遇到高頻事件(滾動、動畫、即時數據),Zone.js 甚至會讓整個應用每幀都做白工;因此我們才會看到大量教學叫你用 NgZone.runOutsideAngular() 或手動 ChangeDetectorRef.markForCheck() 來抑制波動。

二、痛點案例:setTimeout 只想更新一個數字,卻喚醒整棵樹

想像我們在英雄資料介面加了一個「戰鬥冷卻倒數」的功能,過去常見的寫法大概長這樣:

import { Component } from '@angular/core';

@Component({
  selector: 'app-cooldown-timer',
  imports: [],
  templateUrl: './cooldown-timer.html',
  styleUrl: './cooldown-timer.scss',
})
export class CooldownTimer {
  seconds = 10;

  ngOnInit() {
    setInterval(() => {
      this.seconds--;
    }, 1000);
  }
}

在 Zone.js 底下,即使只有 seconds 這個欄位被更新,每秒仍會觸發一次整體變更檢測。隨著功能增加、元件樹變深,這種「地圖砲式」更新會造成:

  • 無關的畫面重新計算 @for 清單或 @defer 區塊。
  • 畫面仍然正常,但效能指標(如 LCP、主執行緒占用)卻悄悄下滑。
  • 我們被迫在元件裡注入 NgZoneChangeDetectorRef,在 runOutsideAngular()run() 之間反覆切換,程式碼越寫越複雜。

這就是 outline 說的 Zone.js 痛點:就算只是 setTimeoutsetInterval,也會對整個應用造成不成比例的刷新成本。

三、Zoneless 的核心:讓狀態自己告訴框架「我變了」

Angular v17 之後提供的 provideZonelessChangeDetection() 目標非常直接:拿掉 Zone.js 的全域攔截,改由響應式狀態決定哪些畫面需要更新。Signals 就是這個年代的主角。

在我們的專案中,HeroService 早於 Day21 就把資料放進 signal,組件則透過 computedeffectresource() 將狀態傳遞到 UI。搭配 zoneless 模式時,運作流程變得更乾淨:

  1. 使用者互動或非同步事件更新 signal(例如 this.cooldown.update(v => v - 1))。
  2. Angular 只發送訊號給訂閱該 signal 的模板或 effect,而不是遍歷整棵 component tree。
  3. 沒有 signal 的部分完全不會被打擾,減少 CPU 與記憶體消耗。

重點是:框架從「外部攔截一切事件再回來檢查」進化為「內部精準追蹤資料流」。

四、用 Signals 重寫冷卻計時器

讓我們把前述範例改寫成符合 Day21~Day23 架構的版本:

ng g c cooldown-timer
// src/app/cooldown-timer/cooldown-timer.ts
import { Component, DestroyRef, inject, signal } from '@angular/core';

@Component({
  selector: 'app-cooldown-timer',
  imports: [],
  templateUrl: './cooldown-timer.html',
  styleUrl: './cooldown-timer.scss',
})
export class CooldownTimer {
  private readonly destroyRef = inject(DestroyRef);
  readonly seconds = signal(10);

  constructor() {
    const timerId = window.setInterval(() => {
      this.seconds.update((value) => Math.max(0, value - 1));
    }, 1000);

    this.destroyRef.onDestroy(() => window.clearInterval(timerId));
  }
}
<!-- src/app/cooldown-timer/cooldown-timer.html -->
剩餘 {{ seconds() }} 秒 
  • setInterval 仍然運作,但不再依賴 Zone.js;每次執行時直接更新 signal 即可。
  • 模板 {{ seconds() }} 只會在 seconds 變動時重新繪製,其他畫面完全不受影響。
  • 透過 DestroyRef.onDestroy() 清除計時器,維持 Day19~Day23 強調的資源管理習慣。

將這段元件掛進 Heroes 頁面後,你可以使用 Chrome Performance 或 Angular DevTools 查看,每一秒只會更新這個計時器對應的 DOM,而非整個應用。

五、導入 zoneless 的實務建議

經過 Day01~Day27 的累積,我們其實已具備進入 zoneless 的條件。導入時可以依照以下清單檢視:

  • 狀態集中化:確保關鍵資料都透過 signalcomputedresource() 分享。Day21~Day23 的重構是關鍵前置作業。
  • 事件處理:避免在元件內混用 setTimeout 與共享變數,改以 RxJS 或 requestAnimationFrame 轉成 signal,使更新來源一致。
  • 錯誤回報provideBrowserGlobalErrorListeners()(同樣寫在 app.config.ts)會在 zoneless 模式下協助捕捉異常,記得在 effect 中適度處理例外,避免失敗狀態被吞掉。
  • 相容性評估:若仍有依賴 NgZone 的舊程式碼(例如 Day08 以前的版本),可以建立暫時的 ZonelessCompatService,在需要時手動觸發 markDirty(),逐步汰除舊寫法。
  • 量測工具:部署後用 Day27 建議的 GitHub Pages 或 Vercel Preview,再透過 Web Vitals、Angular DevTools 觀察 Change detection cycles 是否下降,讓優化變得有憑有據。

今日小結:
Zone.js 曾陪伴我們度過 Angular 初期的學習曲線,但當應用規模擴大,它的「全域攔截」模式也帶來了不必要的成本。
透過今天的練習,你應該感受到 zoneless + Signals 的組合能夠更精準地更新畫面,並提升部署後的效能品質。


上一篇
Day 27|部署上線:ng build 與 GitHub Pages
系列文
Angular:踏上現代英雄之旅28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言