Angular上級 #1 Change Detection — Default、OnPush、Zoneless

読了 9分

中級講座の最後の記事で、上級講座で扱う 7 つのテーマを予告しました。その最初に置いたのが Change Detection です。Angular アプリの画面が「なぜ、いつ再描画されるか」を決めるメカニズムであり、大きなアプリのパフォーマンスを分ける最も太い変数でもあります。

今回は Change Detection が正確に何で、Default 戦略OnPush 戦略 がどう違うのか、その裏で働いていた zone.js がどんな役割を担っていたのか、そして Angular 18 以降に本格化した Zoneless の流れまで一気に整理してみます。

Change Detection とは #

一言で言えば 「データが変わったから画面を再描画せよ」をつなぐメカニズム です。コンポーネントのある値が変わったときに、その値を参照しているテンプレートが自動的に新しい値で更新される — 私たちが当然のように享受しているあの流れの正体です。

Angular はコンポーネントツリーを上から下へ巡回しながら、各コンポーネントのテンプレート式を再評価し、以前の値と異なれば DOM を更新します。この一度の巡回を change detection cycle と呼びます。

問題は「いつこのサイクルを回すか」です。あまりにも頻繁に回すと遅くなり、回さなさすぎると画面が更新されません。Angular は長らく 「非同期作業が終わるたびに 1 回」 というルールでこの問題を解いてきました。そのルールを可能にしたのが zone.js です(少し後で)。

Default 戦略 — ツリー全体を探索 #

特に指定しなければ、すべてのコンポーネントは ChangeDetectionStrategy.Default で動作します。変更検知がトリガーされると ルートからすべてのコンポーネントを巡回 しながら、それぞれのバインディングを再評価します。

src/app/dashboard.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `<p>{{ user.name }} — {{ score }}</p>`,
  // changeDetection: ChangeDetectionStrategy.Default  ← 明示しなければこれ
})
export class DashboardComponent {
  user = { name: 'カーティス' };
  score = 1200;
}

利点はシンプルで安全であることです。どんなデータがどう変わっても、次のサイクルで画面が必ず追従します。「なぜ画面が更新されないんだろう?」のようなデバッグはほとんどありません。

欠点は アプリが大きくなるほどコストが急速に増える ことです。コンポーネントが数百個ある画面で、キー入力 1 回でツリー全体を走査すると考えれば負担が想像できます。ほとんどのコンポーネントはそのサイクルの間に何も変わっていないのに検査されてしまうからです。

OnPush 戦略 — ツリーの一部だけ検査 #

解決策は「本当に必要なときだけ検査しよう」です。ChangeDetectionStrategy.OnPush に指定したコンポーネントは、次の 4 つの場合にだけ検査対象になります。

  1. コンポーネントの @Input(または input()) の参照が変わったとき
  2. コンポーネントまたはその子で イベントが発生したとき(クリック、入力など)
  3. テンプレートの中で使う async パイプ が新しい値を流すとき
  4. コンポーネントが読む シグナル(signal)が変わったとき

上記のいずれにも該当しなければ、そのサブツリーは丸ごと検査をスキップします。大きなアプリで OnPush をうまく敷いておけば、サイクルあたりの検査コンポーネント数が劇的に減ります。

src/app/user-card.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `<p>{{ user().name }} — {{ user().score }}</p>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
  user = input.required<{ name: string; score: number }>();
}

OnPush 適用条件 — Immutable でなければならない #

OnPush が @Input の変化を感知する基準は 参照比較(===) です。オブジェクト内部のフィールドを少し変更しても、参照が同じなら OnPush コンポーネントは変化を認識できません。

間違った例 — mutate
// 親コンポーネント
addPoint() {
  this.user.score += 100;  // 同じオブジェクトを mutate
  // → OnPush の子は更新されないことがある
}
正しい例 — 新しいオブジェクトを返す
addPoint() {
  this.user = { ...this.user, score: this.user.score + 100 };
  // → 参照が変わったので OnPush の子も更新される
}

ルールはシンプルです。OnPush コンポーネントの入力に流れるデータは immutable のように扱う。 新しい値を作って丸ごと差し替えるパターン(...spreadmapfilter)が自然に馴染みます。この点で OnPush は関数型スタイル、そしてシグナルと非常に相性が良いです。

ヒント
シグナルを入力として使うと、この問題はほとんどなくなります。シグナルは値が変わるとそれ自体で信号を発し、OnPush コンポーネントもその信号で目を覚ますからです。「OnPush を使いながら mutate ができないのが歯がゆい」という痛みのかなりの部分はシグナルが解決します。

NgZone と zone.js — これまで働いてくれていた誰か #

ここまで見ると自然な疑問が湧きます — 「では Angular は いつ 変更検知を回すのか?」。その判断をこれまで代わりに行ってくれていた友人が zone.js です。

zone.js はブラウザの非同期 API(setTimeoutPromiseaddEventListenerXHR など)を monkey-patch して、「この非同期作業が始まった / 終わった」をすべて感知できるようにしてくれるライブラリです。Angular は自分が立ち上げた領域内の zone(=NgZone)で非同期作業が終わるすべての瞬間に tick() を呼び出す — つまり変更検知サイクルを回します。

それで私たちはこれまで setState のような明示的な更新呼び出しなしに画面が自動的に更新される魔法を享受できました。クリックハンドラで単に this.count++ の一行を書くだけで画面が追従する理由は、zone.js がそのクリックイベントの終了時点をつかんで Angular に「もう検査するときだよ」と知らせてくれたからです。

代償は重いです。zone.js はすべての非同期 API を傍受するので、バンドルサイズ(約 30KB gzip)と ランタイムオーバーヘッド がついてきます。そして変更検知の頻度が常に非同期作業と 1:1 で結びついているため、実は画面を更新する必要がない場合でもサイクルが回ります。

runOutsideAngular — zone の外で働く #

まだ zone ベースのアプリで、高頻度イベント(スクロール、マウスムーブ、requestAnimationFrame ループなど)を扱うときによく出会う道具が NgZone.runOutsideAngular です。名前のとおり Angular zone の外側で コールバックを実行させ、変更検知がトリガーされないようにブロックします。

src/app/scroll-tracker.component.ts
import { Component, NgZone, inject, OnInit, DestroyRef } from '@angular/core';

@Component({
  selector: 'app-scroll-tracker',
  standalone: true,
  template: `<p>スクロール Y: {{ y }}</p>`,
})
export class ScrollTrackerComponent implements OnInit {
  private zone = inject(NgZone);
  private destroyRef = inject(DestroyRef);
  y = 0;

  ngOnInit() {
    this.zone.runOutsideAngular(() => {
      const onScroll = () => {
        // ここで this.y を変えても画面は描画されない — 変更検知が回らないから
        // 本当に画面を更新しないといけないときだけ zone.run で戻ってくる
        if (window.scrollY % 100 === 0) {
          this.zone.run(() => (this.y = window.scrollY));
        }
      };
      window.addEventListener('scroll', onScroll, { passive: true });
      this.destroyRef.onDestroy(() => {
        window.removeEventListener('scroll', onScroll);
      });
    });
  }
}

スクロールが毎フレーム発生しても、変更検知は 100 ピクセルに 1 回だけ回ります。スクロール・ドラッグ・チャートアニメーションのように イベント頻度が画面更新頻度よりはるかに高い場合、このパターン 1 つで体感パフォーマンスがガラリと変わります。

Zoneless — zone.js なしでも動作する #

Angular 18 で zone.js を抜いても動作する Zoneless モードが本格的な実験段階に入りました。核心の発想はシンプルです — 「もうシグナルが自分で信号を送ってくれるので、zone がすべての非同期を監視する必要はない」

src/app/app.config.ts
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    // ...
  ],
};

angular.json または polyfills 設定で zone.js を除去すれば、本当に zone のないアプリができ上がります。変更検知をいつ回すかは、これからは次のような明示的な信号が決定します。

  • シグナル変更signal.set(...)signal.update(...)
  • async パイプ の新しい値
  • イベントバインディング((click) など)
  • ChangeDetectorRef.markForCheck() のような明示的呼び出し

バンドルから zone.js が抜けることで約 30KB が減り、「無駄なサイクル」がなくなることで微細なパフォーマンス利得も生まれます。ただし シグナルや明示的な信号なしに単にフィールドを変えると、画面は更新されません。 したがって Zoneless は事実上 OnPush + シグナルを前提としたモデルだと考えてもよいです。

注記
2026 年現在、Zoneless API はまだ provideExperimentalZonelessChangeDetection という名前を使っています(名前から Experimental が抜ける時点が安定化段階に入る合図です)。新しいプロジェクトで導入する際は、使用中のライブラリが zone に依存していないか(特に RxJS scheduler、setTimeout ベースのユーティリティなど)を一度確認してください。

Change Detection のデバッグ #

OnPush とシグナル中心に書いていると、たまに「自分の画面はなぜ描画されないんだろう?」または「なぜこんなに頻繁に回るんだろう?」という瞬間が来ます。2 つの道具を手に入れておきましょう。

  • ng.profiler.timeChangeDetection() — 開発モードでコンソールに ng.profiler.timeChangeDetection() を打つと、変更検知の 1 サイクルが平均何 ms かかるかを測定してくれます。OnPush 適用前後の比較に有用です。
  • Angular DevTools — Chrome 拡張。Profiler タブ でどのコンポーネントがどのサイクルで検査されたかを可視化してくれます。「このコンポーネントは OnPush なのになぜ毎回検査されるんだろう?」のような質問に最も速く答えをくれます。
コンソールで測定
> ng.profiler.timeChangeDetection()
ran 500 change detection cycles in 312 ms; 0.624 ms per check

実践ガイド — では、どう使えばよいか #

3 つの推奨事項にまとめます。

  1. 新規プロジェクトは OnPush をデフォルト に設定してください。コンポーネント generator に --change-detection=OnPush を付けるか、チームコンベンションですべてのコンポーネントに OnPush を明示します。最初から OnPush で出発すると immutable パターンが自然に定着します。

  2. シグナルを入力と状態のデフォルトの道具 として使ってください。OnPush + シグナルの組み合わせは「参照比較のせいで頭が痛い」クラシック OnPush の欠点をほとんど取り除いてくれます。中級 #4 で見た effect()input() がこの時点で真価を発揮します。

  3. Zoneless は新規プロジェクトで積極的に検討 しつつ、既存のアプリは段階的に進めてください。ライブラリ互換性のチェック → OnPush の全面適用 → シグナルへの段階的移行 → Zoneless の適用、という順序が安全です。zone.js を抜くのは 最後のステップ であるべきです。

まとめ #

今回は Angular のパフォーマンスの心臓である Change Detection を一気に流しました。

  • Change Detection は データの変化を画面の更新につなぐメカニズム
  • Default はツリー全体を巡回。シンプル・安全だが大きなアプリではコスト
  • OnPush は入力参照の変更 / イベント / async pipe / シグナルの変化にだけ検査
  • OnPush の前提は immutable データ — 新しいオブジェクトに差し替えるかシグナルを使う
  • zone.js + NgZone がこれまで「いつ検査するか」を決めてくれていた
  • 高頻度イベントは runOutsideAngular で zone の外へ
  • Zoneless は zone.js を抜いて、シグナル・イベント・async パイプが直接信号を送るモデル
  • デバッグは ng.profiler.timeChangeDetectionAngular DevTools Profiler

次回の「Angular上級 #2 Signals 深掘り — effect、computed、input・model、Resource API」では、今日頻繁に登場したシグナルそのものを本格的に掘ってみます。effect() の細かな動作ルール、computed() がキャッシュを扱う方式、コンポーネント入力としての input() と双方向バインディングのための model()、そして非同期データをシグナルで扱う新しい標準になる Resource API まで — シグナルの肌理にもう一段深く入っていきます。

X