既存の Angular アプリケーションの Zoneless 化への道のり

はじめに

こんにちは、ARCH チームの立川です。

先日、Angular v20 がリリースされましたね。

https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301

今回のリリースで v18 から Experimental であった Zoneless(Zone.js に依存しない変更検知) が Developer Preview に昇格しました。新しくアプリケーションを作成される方はインストール時に Zoneless を選択するかどうかの確認が入ります。まだまだ先の話かと思いますが、現在 Zone.js を使用しているアプリケーションは Zoneless 化を検討していかなければならなくなると思います。そろそろ対応を考え始めなければならないと思いつつも、まだ全然準備を始めておらず何をすれば良いのか皆目見当もついていない状態だったため調査してみました。今回はその内容を記事にまとめてみたいと思います。

Zone.js のおさらい

まず、 Zone.js の役割とZoneless という概念がなぜ注目されているのかを書いていきます。既にご存知の方は飛ばしていただいて次章に進んでください。

Zone.js の役割とメリット

Zone.js は、Angular がアプリケーションの状態変化を検知し、自動的に DOM を更新するための非常に重要な役割を持っています。具体的には、setTimeout、Promise、XMLHttpRequest などの非同期 API や、DOM イベント(クリックなど)といった JavaScript の主要な非同期処理を 「パッチ(モンキーパッチ)」 します。これにより、これらの処理が完了した際に Angular に変更検知サイクルを自動で通知して UI が更新されるという仕組みです。この仕組みによって、開発者が手動で detectChanges() を呼び出したり、変更検知のタイミングを細かく管理したりすることなく、より宣言的にコンポーネントを記述できるという大きなメリットがありました。

Zone.js のデメリットと課題

一方で、Zone.js にはいくつか課題があります。

  1. パフォーマンスオーバーヘッド: Zone.js は、すべての非同期処理をパッチするため、アプリケーション全体で不必要な変更検知サイクルが頻繁に走ることがあります。特に大規模なアプリケーションや、大量の非同期処理が同時に実行される場合はこのオーバーヘッドがパフォーマンスのボトルネックとなり、UI の応答性を低下させる原因となることがあります。

  2. バンドルサイズの増加: Zone.js 自体が持つコードのサイズが、最終的なアプリケーションのバンドルサイズを大きくします。初期ロード時間の最適化を目指す場合はこれが課題となってきます。

  3. デバッグの複雑性: Zone.js によるパッチングは、コールスタックを深くしてエラーが発生した際のスタックトレースを読みにくくすることがあります。これによって、問題の原因特定が難しくなりデバッグが複雑になることがあります。

これらの課題を解決するために、新たな変更検知の形として登場したものが Signals であり、それによって実現されるのが今回の肝である Zoneless 化です。

Signals による変更検知

Zone.js の課題を解決して、より効率的な変更検知を実現するために導入されたのが、Angular v16 より提供されている Signals です。

Signals とは?

Signals は、その値に依存する計算や UI の更新をトリガーするリアクティブモデルです。

  • signal(): 状態を保持するリアクティブな値を作成します。
  • computed(): 他の Signal に依存する派生的な値を定義します。依存元の Signal が変更されたときのみ再計算されます。
  • effect(): Signal の変更を監視して副作用(DOM の更新やログ出力など)を実行します。

Signals を利用することにより、必要な場所で必要な変更だけが検知されて DOM が更新されるため、無駄な変更検知サイクルを削減することができます。

先日 Signals を利用した記事を書いているのでぜひそちらも読んでみてください!

nealle-dev.hatenablog.com

Signals と Zoneless

Signals が提供する変更検知の仕組みによって、Zone.js なしでアプリケーションを動作させることができるようになります。Signals を使用しなくても detectChanges などを使用して実現することは可能ですが、なかなか面倒な作業が発生します。以下に簡単な例を挙げます。

// main.ts (抜粋)
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { provideZonelessChangeDetection } from '@angular/core';

bootstrapApplication(AppComponent, {
  providers: [
    // Zone.jsのパッチングを無効化し、Zonelessな挙動を可能にする
    provideZonelessChangeDetection()
  ]
}).catch(err => console.error(err));

AppComponentbootstrapApplication 関数で、provideZonelessChangeDetection() を追加することで、Zone.js のパッチングを無効にできます。この設定により、Zone.js が非同期処理を検知して自動的に変更検知サイクルを走らせることはなくなります。Signals で管理されている状態が変更された時や、明示的に ChangeDetectorRef を使用して変更検知をトリガーした時のみ、UI が更新されるようになります。

// Zoneless環境でのコンポーネント例
import {
  Component,
  inject,
  signal,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
} from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface Post {
  id: number;
  title: string;
}

@Component({
  selector: 'app-fetch-example',
  template: `
    <p>Title: {{ title() }}</p>
    <p>Classic Title: {{ classicTitle }}</p>
    <button (click)="update()">Update Title (Signal)</button>
    <button (click)="updateClassicTitle()">
      Update Classic (Needs manual detection)
    </button>
  `,
  // Zoneless 化した場合は、OnPush変更検知戦略が推奨される
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FetchExample {
  cdr = inject(ChangeDetectorRef);
  http = inject(HttpClient);

  readonly url: string = 'https://jsonplaceholder.typicode.com/posts/1';
  title = signal('No Title');
  classicTitle: string = 'No Title';

  async update() {
    this.http.get<Post>(this.url).subscribe({
      next: (post) => {
        this.title.set(post.title); // Signal は自動で検知されるため、追加の操作は不要
      },
      error: (error) => {
        console.error('Failed To Load', error);
      },
    });
  }

  async updateClassicTitle() {
    this.http.get<Post>(this.url).subscribe({
      next: (post) => {
        this.classicTitle = post.title;
        this.cdr.detectChanges(); // ここで手動での変更検知トリガーがないと、UI は更新されない
      },
      error: (error) => {
        console.error('Failed To Load', error);
      },
    });
  }
}

title という Signal の値は、変更された時に自動で UI が更新されます。一方で、classicTitle のような従来の変数では、Zone.js がない環境では cdr.detectChanges() を明示的に呼び出す必要があり、これを抜け漏れなく既存アプリケーションのすべての箇所に適用していくことはなかなか難しい作業かと思われます。

Zone.js 依存のアプリケーションを Zoneless 化するためのポイント

この章が本題ですが、新規でアプリケーションを作成する場合を除き、現在運用中のアプリケーションを Zoneless 化するポイントについてまとめていきたいと思います。

Angular のバージョンアップ

まず、大前提として、現在使用中の Angular を Zoneless 化できるバージョンまで上げる必要があります。少なくとも v18 に上げる必要がありますが Experimental なので、 v20 以降(執筆段階では最新版は v20)までバージョンアップしてから Zoneless 化を検討するのが望ましいと思います。

Signals ベースのリアクティブプログラミングの普及

Zoneless 化する前に開発チーム全体で Signals ベースのリアクティブプログラミングに慣れておく必要があります。Signals は v16 より導入されているので利用が浸透していない開発組織は少ないかもしれませんが、新しい機能を追加する際に Signals を利用した実装アプローチを導入するなどして組織全体の共通理解を得ることが重要となります。特に Zoneless 化すると Signals を利用しない手段はないというくらいに強調して共通認識を持っておいた方が良いと思います(実際には先述の例の通り手動検知する方法がありますが、必要最小限に利用する方向で考えるのがベターだと思います)。

Signals への移行と手動変更検知の導入

ここまで事前準備でここからが実際の作業となります。すべてのコンポーネントに対して一括で適用していくことは現実的ではないので各コンポーネント毎に順に適用することになると思います。

  1. 状態管理の Signals 化: コンポーネントのプロパティやサービス内の共有状態など、UI に表示されるデータや計算結果で、変更時に UI を更新したいものは signal()computed() に移行します。
  2. ChangeDetectionStrategy.OnPush の導入: 全てのコンポーネントの changeDetectionChangeDetectionStrategy.OnPush に設定します。これは Zoneless 環境では必須に近い設定となります。
  3. 手動変更検知の導入 ( ChangeDetectorRef ): Signals に移行しきれない既存のコードや、RxJS の Observable などを利用していてデータが更新されても UI が更新されない場合は、ChangeDetectorRef サービスを注入し、detectChanges() または markForCheck() を呼び出して変更検知をトリガーする必要があります。

2 の OnPush 戦略のコンポーネントは、以下のいずれかの条件で変更検知が実行されます。

  • Input プロパティへの参照が変更されたとき。
  • コンポーネントのテンプレート内でイベントハンドラが発火したとき。
  • ChangeDetectorRefdetectChanges() または markForCheck() が明示的に呼び出されたとき。
  • テンプレート内で使用されている Signal の値が変更されたとき。

また、繰り返しとなりますが、3 の手動変更検知については極力導入せずに可能な限り Signals に移行すべきだと思います。

外部ライブラリの対応

Zone.js に依存しているライブラリを使用している場合は、どうにかして使用しない選択肢をとらないとなりません。

  1. 使用している UI コンポーネントライブラリ(Angular Material など)やユーティリティライブラリが、Zoneless 環境で動作するか、もしくは Zoneless 対応版が提供されているかを確認します。
  2. もし Zoneless に対応していないライブラリがあり、それがプロジェクトにとってクリティカルな場合、Zoneless に対応した代替ライブラリへの移行を検討します。

どうしても難しい場合は、 NgZone.run() を使用するなどして特定のコンポーネントやサービスでのみ Zone.js を部分的に有効化するという選択肢を取るしかないと思います。ただし、当然コードの複雑性は増してしまうので最終手段として考えるのが望ましいと思います。

Zone.js の無効化とアプリケーションの動作確認

先ほどの例でも挙げましたが、bootstrapApplication 関数で、provideZonelessChangeDetection() を追加します。v18 の場合は、provideExperimentalZonelessChangeDetection という異なる名称となっているので v20 以降にして対応するのが望ましいと思います。追加した後、アプリケーションの動作確認を行い、問題なければ次のステップに進みます。

Zone.js の削除

バンドルサイズを削減するために Zone.js 自体を削除します。ZoneJS は通常、angular.jsonpolyfills オプションを介して、build と test の両方のターゲットでロードされます。ビルドから削除するには、 両方から zone.jszone.js/testing を削除します。その後で以下の通り zone.js 本体も忘れずに削除してください。

npm uninstall zone.js

まとめ

今回の調査を通じて、既存アプリケーションを Zoneless 化するのは簡単ではないと改めて感じました。まずは開発組織内で Signals ベースのリアクティブプログラミングに慣れることが重要です。また、現在使っている外部ライブラリの Zoneless 対応状況を確認し、必要であれば代替ライブラリを検討するなど、事前の準備が欠かせません。Zoneless 化の本格的な作業に入るまでには、まだまだハードルが高そうです。

新規でアプリケーションを作成する方は、インストール時に Zoneless アプリケーションを選択できるので試してみてはいかがでしょうか。

参考文献