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)の方が一括切り替えよりリスクが低くなります。