Angular上級 #7 パフォーマンスチューニング — Virtual Scroll、Image、Profiler

読了 13分

上級講座の最後の記事です。これまで Change Detection、Signals、RxJS の深さ、DI、SSR/Hydration、マイクロフロントエンドまで — Angular を構成する大きな柱を 1 つずつ押さえてきました。最後に扱うテーマは パフォーマンス です。

パフォーマンスチューニングは 1〜2 個のトリック集ではありません。大きなアプリが遅くなるときは普通 1 箇所ではなく 複数の layer にわたって同時に問題が生じています。バンドルが大きくて初回ペイントが遅く、変更検知があまりに頻繁に回って入力が引っかかり、画像が重くて LCP が遅れる — こうしたものが一度に重なっているのが普通です。だから今回はトリック集ではなく layer 別にどこにどのツールを適用するか を地図のように整理することに焦点を当てます。

パフォーマンスを見る 3 つの layer #

大きく 3 つの layer に分けると頭が整理されます。

  1. ビルド layer — ユーザーにどのコードを、どれだけ、いつ送るか。バンドルサイズ、コード分割、lazy loading、defer block がここに属します。
  2. ランタイム CD layer — 到着したコードが画面を再描画する頻度とコスト。Change Detection 戦略、シグナル、trackrunOutsideAngular がここに属します。
  3. リソース layer — 画像、フォント、外部スクリプトのような静的リソース。NgOptimizedImage、preconnect、フォント読み込み戦略がここに属します。

問題の信号別に、どの layer を先に見るかが異なります。初回画面が遅い → ビルド/リソース、スクロール・入力が引っかかる → CD、メモリが増え続ける → CD/購読リーク。今回はこの 3 つの layer を一周しながらツールとパターンを整理します。

Change Detection を再び — OnPush + シグナル回収 #

最も大きな単一のレバーは依然として Change Detection です。上級 #1 で見た内容を一行に縮めると — OnPush をデフォルトに、入力と状態はシグナルで です。

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

@Component({
  selector: 'app-user-row',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="row">
      <span>{{ user().name }}</span>
      <span>{{ user().score }}</span>
      <button (click)="select.emit(user().id)">選択</button>
    </div>
  `,
})
export class UserRowComponent {
  user = input.required<User>();
  select = output<string>();
}

このコンポーネント 1 つだけ見ると平凡ですが、数千個が画面に表示されるリスト を想像してみると差が大きいです。Default 戦略ならキー入力 1 回でツリー全体が検査されますが、OnPush + シグナル入力の組み合わせでは変わった行だけが検査対象になります。大きなアプリで OnPush を全面適用したときにキー入力の遅延が目に見えて減る最も大きな理由がこれです。

新規プロジェクトなら ng generate component 時に --change-detection=OnPush をデフォルトに設定するか、ESLint ルールで OnPush を強制するコンベンションが堅実です。

大きなリスト — @fortrack を正確に #

OnPush の次に大きなリストで効果が大きいのは track キーを正確に与えること です。Angular 17 から @fortrack必須 になりました。

src/app/user-list.component.html
@for (user of users(); track user.id) {
  <app-user-row [user]="user" (select)="onSelect($event)" />
}

track は「この項目が同じ項目かを見分けるキー」です。間違ったキーを与えると — 例えば track $index — 配列の中間に項目 1 つが追加されただけでもその後のすべての行が「異なる項目」と判断されて DOM が丸ごと再生成されます。OnPush が無力化される最もよくある原因がこれです。ドメイン ID(user.idpost.slug など)のように項目を安定的に識別する値を使うのが定石です。

間違った例 — index を使用
@for (user of users(); track $index) {
  <!-- 中間に追加/削除されるとすべての行が再生成 -->
  <app-user-row [user]="user" />
}
正しい例 — 安定的な ID
@for (user of users(); track user.id) {
  <app-user-row [user]="user" />
}

CDK Virtual Scroll — 数万個の項目 #

OnPush + 正確な track でも足りない場合があります。項目数自体が数千〜数万個 なら DOM にそのすべての行が生きていること自体が重いですよね。このときに取り出すツールが CDK Virtual Scroll です。

インストール
ng add @angular/cdk
src/app/user-list.component.ts
import { Component, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { UserRowComponent } from './user-row.component';

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [ScrollingModule, UserRowComponent],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="viewport">
      <app-user-row *cdkVirtualFor="let user of users(); trackBy: byId"
                    [user]="user" />
    </cdk-virtual-scroll-viewport>
  `,
  styles: [`.viewport { height: 600px; }`],
})
export class UserListComponent {
  users = signal<User[]>(/* ... 50,000 個 ... */);
  byId = (_: number, u: User) => u.id;
}

cdk-virtual-scroll-viewport は画面に見える領域 + 少しのバッファだけを DOM に表示し、残りは除去します。5 万個の項目があっても実際の DOM には 20〜30 個だけ生きている形ですね。スクロールパフォーマンスがほぼ項目数と無関係に一定になります。

itemSize各項目のピクセル高さ です。固定の高さが最も速く、可変の高さが必要なら AutoSizeVirtualScrollStrategy(@angular/cdk-experimental)を使えますが、コストがさらにかかります。可能ならデザイン段階で行の高さを統一するのがよいです。

ヒント
Virtual scroll を使うほどかは意外とシンプルな基準です — 項目が普通 1,000 個を超えるか。それ以下なら OnPush + 正確な track だけで十分滑らかで、virtual scroll の複雑度(スクロール位置の復元、項目の測定など)を入れる価値が大きくありません。

NgOptimizedImage — <img ngSrc> #

画像は Lighthouse で LCP(Largest Contentful Paint) スコアを最もよく取りこぼす項目です。Angular 16+ では NgOptimizedImage ディレクティブでこの部分をほぼ自動化できます。

src/app/hero.component.ts
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';

@Component({
  selector: 'app-hero',
  standalone: true,
  imports: [NgOptimizedImage],
  template: `
    <img ngSrc="/images/hero.webp"
         width="1200" height="630"
         priority
         alt="ダッシュボードのヒーロー画像" />
  `,
})
export class HeroComponent {}

普通の <img src> と異なる点は次のとおりです。

  • 自動 lazy loading — 画面外の画像は自動で loading="lazy"priority 属性を付けた初回画面の画像だけ即座にロード + fetchpriority="high"
  • CDN ローダーの統合provideImgixLoader('https://my-app.imgix.net') のような provider を入れると、width/height ベースで適切なサイズを自動でリクエスト。CDN ドメインの preconnect も自動
  • ビルド/ランタイム警告widthheight を抜かすとコンソールが教えてくれます。CLS(レイアウトシフト)防止次元

小さな変更ですが効果は大きいです。画像中心の画面なら <img><img ngSrc> に変えるだけで LCP が 1 秒以上減るケースもよくあります。

Web Vitals と Lighthouse #

パフォーマンスを「感覚」ではなく数字で扱うには Web Vitals という共通言語を覚えておくとよいです。Google が定義したユーザー体感パフォーマンス指標 3 つがあります。

  • LCP(Largest Contentful Paint) — 最大のコンテンツが見える時点。2.5 秒以下推奨
  • INP(Interaction to Next Paint) — クリック/入力後に画面が反応するのにかかる時間。200ms 以下推奨(2024 年から FID の代替)
  • CLS(Cumulative Layout Shift) — レイアウトが急に飛ぶ累積量。0.1 以下推奨

Chrome DevTools の Lighthouse タブ で一度に測定でき、実際のユーザー分布は web-vitals npm パッケージで自前で収集して分析サーバーに送ればよいです(onLCPonINPonCLS コールバックを登録して navigator.sendBeacon で送信)。

Lighthouse のスコアだけ見ずに 実際のユーザー環境で収集した分布 を見るのがポイントです。開発 PC の Lighthouse は 90 点ですが、モバイルユーザーの 75 パーセンタイルは 60 点というケースがよくあるからです。

Angular DevTools Profiler #

ランタイム CD のコストを可視化する最もよいツールが Angular DevTools Chrome 拡張の Profiler タブ です。録画を開始して画面を操作した後に停止すると、Change Detection サイクルごとに どのコンポーネントがどれだけかかったか が flame chart で可視化されます。同じコンポーネントがサイクルごとに繰り返し検査されているなら OnPush が抜けているという信号ですね。

典型的な使用の流れ — Lighthouse で INP が悪く出れば、Profiler を点けてそのインタラクションを再現し、どのコンポーネントがサイクルを長く掴んでいるか を見つけ出します。そのコンポーネントに OnPush を適用したり、入力をシグナルに変えたり、より小さな子に分けたりする形で絞っていきます。

コンソールの古い友達 ng.profiler.timeChangeDetection() も依然として有効です。OnPush 適用前後の平均 CD 時間を比較すれば、変更の効果が定量的に見えます。

ブラウザコンソール
> ng.profiler.timeChangeDetection()
ran 500 change detection cycles in 187 ms; 0.374 ms per check

ビルド分析 — --stats-json + source-map-explorer #

ビルド layer は 中級 #5 コード分割 で扱いましたが、チャンクの中に正確に何が入っているかを見る 2 つのツールをさらに押さえておきます。

stats.json + visualizer / source-map-explorer
# オプション 1 — stats.json
ng build --stats-json
npx esbuild-visualizer --metadata dist/my-app/stats.json --filename bundle.html

# オプション 2 — source map ベース (より正確)
ng build --source-map
npx source-map-explorer dist/my-app/browser/*.js

チェックポイントは次のとおりです。

  • main チャンクにチャート・エディタ・PDF・moment のような重いライブラリ が見えれば lazy チャンクに移す候補
  • 同じライブラリが複数のチャンクに重複 して現れれば、中級 #5 のよくある失敗 で見たパターン — shared フォルダに整理
  • Tree-shaking ができないライブラリ(例: import _ from 'lodash')は modular import(import debounce from 'lodash-es/debounce')に置き換え

CI にバンドルサイズの回帰を防ぐステップを置くのもよい投資です。angular.jsonbudgets を活用すれば、一定サイズを超える PR でビルドが失敗するようにできます。

angular.json (budgets)
"budgets": [
  { "type": "initial", "maximumWarning": "300kb", "maximumError": "500kb" },
  { "type": "anyComponentStyle", "maximumWarning": "4kb" }
]

高頻度イベントは zone の外へ — runOutsideAngular #

上級 #1 で見たパターンですが、パフォーマンスチャプターでもう一度強調する価値があります。スクロール・マウスムーブ・requestAnimationFrame ループのように イベントの頻度が画面更新の頻度より高い 場合、毎イベントごとに変更検知が回るのは大きな無駄です。

src/app/cursor-tracker.component.ts
ngOnInit() {
  this.zone.runOutsideAngular(() => {
    let lastFrame = 0;
    const onMove = (e: MouseEvent) => {
      const now = performance.now();
      if (now - lastFrame < 100) return;  // 100ms throttle
      lastFrame = now;
      this.zone.run(() => this.pos.set({ x: e.clientX, y: e.clientY }));
    };
    window.addEventListener('mousemove', onMove);
    this.destroyRef.onDestroy(() => window.removeEventListener('mousemove', onMove));
  });
}

毎フレーム発生するイベントでも変更検知は 100ms に 1 回だけ回ります。Zoneless モードではこのパターンがなくなりますが(シグナルを呼ばないと信号が行かないからですね)、安定化されてすべてのプロジェクトが乗り換えるまではしばらく有効です。

@defer block — 画面外のコンポーネントは lazy で #

Angular 17 で入った @defer は lazy loading をルート単位からコンポーネント単位へもう一段細かく分けます。

src/app/dashboard.component.html
<h1>ダッシュボード</h1>
<app-summary />

@defer (on viewport) {
  <app-heavy-chart />
} @placeholder {
  <div class="skeleton">チャート読み込み中...</div>
} @loading (after 100ms) {
  <app-spinner />
} @error {
  <p>チャートを読み込めませんでした。</p>
}

@defer は中にあるコンポーネントとその依存関係を 別チャンク に分離します。そしてトリガー条件が満たされるまでコード自体を受け取りません。トリガーは多様です。

  • on viewport — 画面に入ったとき(IntersectionObserver ベース)
  • on idle — ブラウザが idle 状態になったとき(デフォルト)
  • on timer(2s) — 一定時間後
  • on interaction — ユーザーがクリック・hover したとき
  • on hover — hover だけで
  • when condition() — シグナル/式が true になったとき

ルート単位の lazy loading は ページが切り替わる時点 にのみ効果がありますが、@defer は 1 つのページの中でも 初回画面に必ず必要な部分とそうでない部分 を分けて受け取らせてくれます。重いチャート、コメント領域、おすすめセクションのように「スクロールしてようやく見える」ものが典型的な候補ですね。

注記
Angular 19 から @defer は SSR/Hydration ともシームレスに動作します(上級 #5 で見た incremental hydration)。サーバーでは placeholder をレンダリングし、クライアントではトリガーに合わせてコンポーネントだけ hydrate する形ですね。SSR を使うアプリなら @defer が lazy loading 以上の意味を持つようになります。

フォントと外部スクリプト #

最後によく忘れられるリソース layer 2 つです。

フォント — ウェブフォントが遅れて到着するとテキストが一度に再描画されながら(FOIT/FOUT)CLS と LCP を同時に取りこぼします。次の 2 つが基本です。

src/index.html
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/pretendard-subset.woff2" crossorigin />
src/styles.css
@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/pretendard-subset.woff2') format('woff2');
  font-display: swap;  /* テキストは fallback で先に表示し、フォントが到着したら差し替え */
}

サブセット化(必要なグリフだけ含める)で日本語/韓国語フォントのサイズを 1MB → 100KB 台に減らせます。

外部スクリプト — Google Analytics、チャットウィジェット、A/B テスト SDK のようなものです。<head> に同期的に埋め込むと初回ペイントをその分食ってしまいます。async/defer を使うか、いっそ初回ペイント以降に requestIdleCallback で動的挿入するパターンがよく使われます。if (typeof window === 'undefined') return; の 1 行で SSR 環境をガードしてあげることも忘れずに。

パフォーマンスチューニングチェックリスト #

最後に 1 ページに整理します。

ビルド layer

  • ng build --stats-json + visualizer で main チャンクを点検
  • よく使わない重いライブラリは lazy チャンクへ
  • ページの中の重い領域は @defer
  • angular.json budgets で回帰を遮断

ランタイム CD layer

  • 新規コンポーネントは OnPush をデフォルト
  • 入力・状態はシグナル優先
  • @fortrack は安定的な ID
  • 1,000+ 項目のリストは CDK Virtual Scroll
  • 高頻度イベントは runOutsideAngular(または Zoneless)
  • Angular DevTools Profiler で定期点検

リソース layer

  • <img><img ngSrc> へ(widthheight 必須、初回画面は priority)
  • フォントは preload + font-display: swap + サブセット化
  • 外部スクリプトは async/defer または idle 以降にロード

測定

  • Lighthouse は出発点、実ユーザーデータ(web-vitals)が真実
  • LCP / INP / CLS を一緒に見る

上級講座の振り返り #

これで Angular 上級講座 7 編が締めくくられました。一度振り返ってみると:

  • #1 Change Detection — Default/OnPush、NgZone、Zoneless
  • #2 Signals 深掘り — computed、effect、input/output/model、linkedSignal
  • #3 RxJS 深掘り — Subject ファミリー、高階演算子、カスタムオペレータ
  • #4 Dependency Injection 深掘り — トークン、階層、multi providers、関数型ガード
  • #5 SSR とハイドレーション — Angular Universal、full/incremental hydration
  • #6 マイクロフロントエンド — Module Federation、Native Federation
  • #7 パフォーマンスチューニング — ビルド/CD/リソースの 3 つの layer + Profiler

基礎が「Angular で画面を作る」、中級が「Angular を実務で使う」だったとすれば、上級は「Angular の内側のメカニズムを理解し、大きなアプリを扱う」でした。各記事で扱ったツールたちは初めて出会ったときには別々のように見えますが、大きなアプリで一緒に働いてみると結局 1 つの絵にまとまります — シグナルベースのリアクティブモデル + OnPush + コード分割 + SSR の組み合わせがモダン Angular の標準骨格です。

次のシリーズ — Angular 実戦講座 #

基礎 → 中級 → 上級まで固めたなら、これからは 実際に手を動かして小さなプロダクトを最初から最後まで作ってみる段階 です。次のシリーズである 「Angular 実戦講座」 では、1 つのドメイン(小さな SaaS 形態の管理ダッシュボード)を選んで、次のような過程を順番に扱っていく予定です。

  1. ダッシュボードの骨格 — Standalone + Router + レイアウトコンポーネントで大きな枠を作る
  2. 認証フロー — ログイン/ログアウト、トークン保存、ルートガード、インターセプタでセッション更新
  3. フォーム + API — Reactive Forms で複雑な入力を扱い、HttpClient + Resource API でバックエンドとつなぐ
  4. 状態管理 — シグナルベースの store から始め、NgRx Signal Store まで、ドメインが大きくなるにつれてどう進化させるか
  5. UI ライブラリ — Angular Material または PrimeNG でデザインシステムを乗せ、テーマを作る
  6. テストとデプロイ — ユニット・コンポーネント・E2E テスト、GitHub Actions で CI/CD、Cloudflare/Vercel でデプロイ

ここまで付いてこられた方なら、ツールと概念は既に手の中にあります。実戦シリーズはそのツールを 1 つのプロダクトのコンテキストの中でどう組み合わせるか — 実務に近い意思決定を毎ステップお見せすることに焦点を当てる予定です。その間に Angular も 1〜2 度新しいバージョンをさらに発表するでしょうが、新しく入ってくる機能はシリーズの中で自然に溶かして扱います。

基礎から上級までの長い旅路を一緒にしてくださってありがとうございました。実戦シリーズで再びお目にかかります。

X