Skip to content

Angular 20 Zoneless移行実践:zone.jsから純Signal駆動へ

Angular 20でZonelessが正式に安定化しましたが、実際の移行は設定を1行変えるだけでは済みません。本記事では、中規模のAngularプロジェクト(約80コンポーネント)をzone.jsからZonelessに移行した完全なプロセスを記録し、遭遇したハマりどころと解決策を共有します。

移行準備:コード内のzone.js依存を検出する

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を直接subscribeして通常プロパティを変更
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()に変更
  ③ ObservableをtoSignal()でラップ
  ④ 手動unsubscribeをtakeUntilDestroyed()に変更

フェーズ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