Skip to content

Angular 20 Zoneless 遷移實戰:從 zone.js 到純 Signal 驅動

Angular 20 讓 Zoneless 正式穩定,但實際遷移並不是改一行配置就完事。本文記錄了將一箇中型 Angular 專案(約 80 個元件)從 zone.js 遷移到 Zoneless 的完整過程,分享遇到的坑和解決方案。

遷移準備:檢測程式碼中的 zone.js 依賴

在去掉 zone.js 之前,先搞清楚專案中有哪些地方隱式依賴了它:

bash
# 安裝 Angular ESLint 的 Zoneless 規則集
npm install --save-dev @angular-eslint/eslint-plugin

# .eslintrc.json 中啟用規則
{
  "rules": {
    "@angular-eslint/no-async-lifecycle-method": "warn",
    "@angular-eslint/prefer-on-push-component-change-detection": "warn"
  }
}

常見的 zone.js 隱式依賴模式:

typescript
// ❌ 模式 1:在 setTimeout/setInterval 回撥中修改非 Signal 狀態
export class BadComponent {
  value = 0; // 普通屬性(非 Signal)

  ngOnInit() {
    setTimeout(() => {
      this.value = 42; // zone.js 會攔截 setTimeout,觸發變更檢測
      // Zoneless 下:不會觸發 UI 更新!
    }, 1000);
  }
}

// ✅ 修復:使用 Signal
export class GoodComponent {
  value = signal(0);

  ngOnInit() {
    setTimeout(() => {
      this.value.set(42); // Signal 更新,Zoneless 也能檢測到
    }, 1000);
  }
}
typescript
// ❌ 模式 2:直接訂閱 RxJS Observable 並修改普通屬性
export class BadComponent implements OnDestroy {
  data: User[] = [];
  private sub = this.userService.getUsers().subscribe((users) => {
    this.data = users; // Zoneless 下:不觸發更新
  });
}

// ✅ 修復方案 A:使用 Signal + takeUntilDestroyed
export class GoodComponent {
  data = signal<User[]>([]);

  constructor() {
    this.userService
      .getUsers()
      .pipe(takeUntilDestroyed())
      .subscribe((users) => this.data.set(users));
  }
}

// ✅ 修復方案 B:使用 toSignal()
export class BetterComponent {
  data = toSignal(this.userService.getUsers(), { initialValue: [] });
}

分階段遷移策略

階段 1(1-2周):全面改用 OnPush + 清除 zone 依賴
  ① 所有元件加上 changeDetection: ChangeDetectionStrategy.OnPush
  ② 將普通屬性改為 signal()
  ③ 用 toSignal() 包裹 Observable
  ④ 用 takeUntilDestroyed() 替代手動 unsubscribe

階段 2(1周):並行測試 Zoneless 模式
  ① 在開發環境單獨建立 Zoneless 構建
  ② 執行 E2E 測試,找出因 zone.js 依賴而失敗的用例
  ③ 修復所有問題

階段 3(上線):生產切換
  ① 將 provideZonelessChangeDetection() 加入生產配置
  ② 從 polyfills 中移除 zone.js
  ③ 監控 Sentry/錯誤日誌 3 天

第三方庫的相容性問題

typescript
// 問題:某些第三方庫(如老版 Google Maps、Monaco Editor)
// 在 zone.js 不攔截的情況下無法觸發變更檢測

// 解決方案:在第三方庫回撥中手動標記
import { ChangeDetectorRef, inject } from '@angular/core';

@Component({ ... })
export class MapComponent {
  private cdr = inject(ChangeDetectorRef);
  private map!: google.maps.Map;

  initMap() {
    this.map = new google.maps.Map(this.mapContainer.nativeElement, options);

    // 第三方回撥:手動通知 Angular
    this.map.addListener('click', (event: google.maps.MapMouseEvent) => {
      this.selectedLocation.set(event.latLng);
      // 如果使用 Signal,不需要 markForCheck
      // 如果還有普通屬性需要更新:
      this.cdr.markForCheck();
    });
  }
}

測試中的 Zoneless 配置

typescript
// spec 檔案中配置 Zoneless 測試
import { provideZonelessChangeDetection } from "@angular/core";
import { TestBed } from "@angular/core/testing";

describe("CounterComponent", () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent],
      providers: [
        provideZonelessChangeDetection(), // 測試也使用 Zoneless
      ],
    }).compileComponents();
  });

  it("should update count", async () => {
    const fixture = TestBed.createComponent(CounterComponent);
    const button = fixture.nativeElement.querySelector("button");

    button.click();
    await fixture.whenStable(); // 等待 Signal 傳播

    expect(fixture.nativeElement.querySelector("p").textContent).toContain("1");
  });
});

遷移結果

我們專案遷移到 Zoneless 後的實際資料:

包體積:減少 13KB(gzip),首屏載入提升約 50ms(弱網)
變更檢測次數:減少約 60%(無 zone 的全域性攔截)
LCP 提升:平均 120ms(減少了 JS 執行時間)
記憶體佔用:減少約 8%(無 zone.js 的 patch 開銷)

總結

Zoneless 遷移的核心是"把所有可變狀態都 Signal 化"。這本來就是 Angular 17+ 推薦的最佳實踐,所以如果你的新專案一開始就用 Signal API,遷移到 Zoneless 幾乎是零成本的。難點在於老專案——大量使用 subscribe + 直接賦值的程式碼需要系統性改造。分階段遷移(OnPush → Zoneless)比一次性切換風險更低。

MIT Licensed