Angular上級 #3 動的コンポーネントと ViewContainerRef

読了 11分

これまで私たちが触ってきたコンポーネントはほとんど テンプレートに静的に埋め込まれた コンポーネントでした。基礎 #2 のように <app-user-card></app-user-card> と親テンプレートが子の位置を直接書いておけば、Angular コンパイラはそれを見てツリーをビルドします。

ところが実務にはこの絵が合わない場合があります。モーダル、トースト、ツールチップ、ユーザーが選んだウィジェットを表示するダッシュボード、プラグインが動的に登録する画面 — こういう所ではコンポーネントが いつ・どこに・いくつ 表示されるかがコンパイル時点で分かりません。ランタイムにコードが「このコンポーネントを今ここに表示してくれ」と命令する必要があります。

今回はモダン Angular で動的コンポーネントを扱う道具たちを一周してみます。ViewContainerRef/createComponent のような低レベル API から @deferngComponentOutlet、CDK Portal のような高レベルツールまで、最後に小さなトーストサービスを作ってみて締めくくります。

動的コンポーネントが必要な場面 #

代表的な候補を整理してみましょう。

  • モーダル/ダイアログ — 親の overflow や z-index を避けるためには DOM ツリーの外(<body> 直属)に送る方が楽
  • トースト/通知 — どこから呼んでも画面の片隅にちょっと出てきて消えなければならない
  • ツールチップ/ポップオーバー — トリガー要素の位置基準で表示し、普通は一度に 1 つだけ
  • ウィジェット/プラグインシステム — 管理者がウィジェットを選んだり外部プラグインがコンポーネントを登録するケース
  • ウィザード — ステップごとに完全に異なるコンポーネントが入る流れ

共通点は「静的テンプレートでは解けない」ということです。なので Angular はテンプレートに 枠だけ確保しておいて 実際のコンポーネントはコードで埋める道具を複数のレベルで提供します。

ViewContainerRef という場所 #

最も基本となる概念が ViewContainerRef です。名前のとおり 「ビューが入るコンテナの参照」 。普通はテンプレートにマーカー要素を置いて、シグナルベースの viewChild() で参照を取得します。

src/app/host.component.ts
import { Component, ViewContainerRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-host',
  standalone: true,
  template: `
    <h2>ここに動的コンポーネントが入ります</h2>
    <ng-container #slot></ng-container>
  `,
})
export class HostComponent {
  slot = viewChild('slot', { read: ViewContainerRef });
}

<ng-container #slot> は画面に何も描画しないマーカーです。read: ViewContainerRef はその位置を ViewContainerRef として読んでくれというオプションです。

注記
旧バージョンのコードでは @ViewChild('slot', { read: ViewContainerRef }) slot!: ViewContainerRef; 形式に出会います。動作は同じですが、シグナルベースの viewChild() の方がタイミングがより明確なので、新たに書くときはシグナル形式が推奨されます。また ng-container の代わりに通常の要素(<div #slot>)に変数を付けると、動的コンポーネントはその要素の 兄弟の位置 に入ります。「その要素の中」ではなく。

createComponent で直接生成 #

枠を確保したら、そこにコンポーネントを作って入れる番です。ViewContainerRef.createComponent() が最もシンプルです。

src/app/alert.component.ts
@Component({
  selector: 'app-alert',
  standalone: true,
  template: `
    <strong>{{ title() }}</strong>
    <p>{{ message() }}</p>
    <button (click)="dismiss.emit()">閉じる</button>
  `,
})
export class AlertComponent {
  title = input.required<string>();
  message = input.required<string>();
  dismiss = output<void>();
}

ホストでこれを動的に表示してみます。

src/app/host.component.ts
@Component({
  selector: 'app-host',
  standalone: true,
  template: `
    <button (click)="showAlert()">警告を表示</button>
    <ng-container #slot></ng-container>
  `,
})
export class HostComponent {
  slot = viewChild('slot', { read: ViewContainerRef });

  showAlert() {
    const container = this.slot();
    if (!container) return;

    container.clear();
    const ref = container.createComponent(AlertComponent);
    ref.setInput('title', '保存失敗');
    ref.setInput('message', 'ネットワーク接続を確認してください。');
    ref.instance.dismiss.subscribe(() => ref.destroy());
  }
}

流れはシンプルです — clear() で枠を空け、createComponent でインスタンスを作り、setInput で input を注入し、output(dismiss)を購読して閉じるタイミングで destroy() で片付け。setInput はシグナル input とよく動作し変更検知を安全にトリガーしてくれるので、ref.instance.title.set(...) のようにインスタンスを直接触る代わりに setInput を使うのが標準です。

旧コードで見ていた ComponentFactoryResolverdeprecated です。v13 から createComponent を直接呼ぶ方式に変わったので、新しいコードでは気にする必要がありません。

@defer ブロック — 最もモダンな方式 #

ルート単位ではなく 画面内の一部 を lazy に表示したいとき、Angular 17+ の @defer ブロックが最もすっきりします。動的生成の効果を持ちつつテンプレートで宣言的に終わらせられます。

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

@defer (on viewport) {
  <app-heavy-chart [data]="metrics()" />
} @placeholder {
  <div class="placeholder">チャート領域</div>
} @loading (after 100ms; minimum 500ms) {
  <div>チャートを準備中です...</div>
} @error {
  <div>チャートを読み込めませんでした。</div>
}

@defer (on viewport) は「このブロックがビューポートに入ったらそのときチャンクを受け取ってインスタンス化」という意味です。トリガーは on viewporton idle(デフォルト)、on timer(2s)on interactionon hoverwhen condition() など多様です。

ビルドツールは @defer ブロック内のコンポーネント import を自動で別チャンクに分離します。ルート lazy loading の画面内バージョンと考えればよいです。チャート、重いエディタ、動画プレイヤーのように「最初から見える必要はないが結局見えるかもしれない」かけらの第 1 候補ツールです。@defer で十分なら、わざわざ createComponent のような低レベル API を使う理由はなく、直接命令的に表示する必要のあるケース(モーダル、トーストなど)でのみその下のレベルに降りればよいです。

ngComponentOutlet — テンプレートでクラスを差し替える #

「表示するコンポーネントの種類がランタイムに決まるが、位置はテンプレートの中に置きたい」 — このときに合うツールが NgComponentOutlet です。ウィザードのステップ、ユーザーが選んだウィジェット、A/B で分かれるフォームのようなケースによく登場します。

src/app/wizard.component.ts
@Component({
  selector: 'app-wizard',
  standalone: true,
  imports: [NgComponentOutlet],
  template: `
    <ng-container *ngComponentOutlet="currentStep(); inputs: stepInputs()" />
    <button (click)="next()">次へ</button>
  `,
})
export class WizardComponent {
  private steps: Type<unknown>[] = [Step1Component, Step2Component, Step3Component];
  private index = signal(0);

  currentStep = () => this.steps[this.index()];
  stepInputs = () => ({ stepIndex: this.index() });

  next() {
    this.index.update(i => Math.min(i + 1, this.steps.length - 1));
  }
}

ngComponentOutlet にコンポーネントの クラスそのもの を渡せば、そこにインスタンスが入ります。クラスを変えると既存のインスタンスは自動で destroy され、新しいコンポーネントがそこに入ります。inputs で input も一緒に渡すことができます。createComponent より表現力は少し劣りますが、テンプレートで宣言的に終わらせられるのでコードが短く、「1 つの枠に入るコンポーネントの種類だけ変わる」シンプルなケースではこちらが第 1 候補です。

CDK Portal — 親ツリーの外に送る #

モーダルやオーバーレイをホストツリーの中に埋め込んでおくと、親の overflow: hidden で切られたり、z-index の競合で潰れたり、transform がかかった祖先のせいで fixed positioning が壊れたりする問題が起きやすいです。これをすっきり解くツールが Angular CDKPortal です。コンポーネントを作った位置に置かずに 別の位置(普通は <body> 直属)に描き入れて くれます。

CDK インストール
npm install @angular/cdk
src/app/modal-host.component.ts
@Component({
  selector: 'app-modal-host',
  standalone: true,
  template: `<button (click)="openSettings()">設定を開く</button>`,
})
export class ModalHostComponent {
  private overlay = inject(Overlay);

  openSettings() {
    const overlayRef = this.overlay.create({
      hasBackdrop: true,
      positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
    });

    overlayRef.attach(new ComponentPortal(SettingsModalComponent));
    overlayRef.backdropClick().subscribe(() => overlayRef.dispose());
  }
}

Overlay は CDK が管理する別の DOM 領域(cdk-overlay-container)にコンポーネントを表示し、位置戦略・バックドロップ・キーボード処理まで一気に解決してくれます。直接 createComponent でモーダルを実装すると backdrop、focus trap、ESC で閉じる、位置計算のような細部に時間を取られますが、CDK Portal/Overlay はその細部を予め解いておいたライブラリです。モーダル、ドロップダウン、ツールチップ — 親ツリーの外に送る必要があるほぼすべてのケースの標準ツールと考えてもよいです。

動的インジェクター — コンポーネントごとに異なるコンテキスト #

動的に表示するコンポーネントごとに 異なるサービスインスタンス を注入したいときがあります。同じモーダルでも呼び出し元によって「閉じる」動作が違わなければならないとか。createComponent はオプションとしてインジェクターを受け取るので、Injector.create() で即席インジェクターを作って渡せます。

src/app/host.component.ts
export class DialogContext {
  constructor(public title: string, public onConfirm: () => void) {}
}

export class HostComponent {
  private parentInjector = inject(Injector);
  slot = viewChild('slot', { read: ViewContainerRef });

  open() {
    const container = this.slot();
    if (!container) return;

    const dialogInjector = Injector.create({
      providers: [{
        provide: DialogContext,
        useValue: new DialogContext('本当に削除しますか?', () => this.handleConfirm()),
      }],
      parent: this.parentInjector,
    });

    container.createComponent(ConfirmDialogComponent, { injector: dialogInjector });
  }

  private handleConfirm() { /* 実際の削除ロジック */ }
}

ConfirmDialogComponentinject(DialogContext) だけすればよく、呼び出し元が異なれば同じコンポーネントでも毎回異なるコンテキストが入ってきます。CDK の MatDialog も内部的に同じパターンで、MAT_DIALOG_DATA トークンがまさにこの役割です。

メモリ管理 #

動的コンポーネントは強力な分、片付けも自分で気にしなければなりません。最も基本は ComponentRef.destroy() — コンポーネントの OnDestroy が回り、子ツリーまで一緒に destroy されます。ViewContainerRef に入っている動的ビューを一度に空にするには clear() を使えばよいです。

ホストコンポーネントが destroy されると、ViewContainerRef.createComponent で作った子はホストのビューツリーにつながっているので 自動で一緒に片付けられます。逆に CDK Overlay のように親ツリーの外に送ったコンポーネントは、ホストが消えても自動では消えません — overlayRef.dispose() を直接呼ぶか、DestroyRef でホストの destroy 時点に紐づける必要があります。

DestroyRef で片付けを紐づける
import { DestroyRef, inject } from '@angular/core';

const destroyRef = inject(DestroyRef);
const overlayRef = this.overlay.create(/* ... */);
destroyRef.onDestroy(() => overlayRef.dispose());
ヒント
output を subscribe で購読したなら、その Subscription もホストと一緒に片付けなければなりません。一度表示して閉じるモーダルなら閉じる時点の destroy で十分ですが、ホストが生きている間に何度も動的コンポーネントを表示するケースでは、毎回作った Subscription をうまく回収しなければリークが生じません。

実践: トーストサービス #

ここまで見てきた道具を組み合わせて、小さなトーストサービスを作ってみます。どこからでも toast.show('保存されました') で呼び出すと画面の右上にちょっと出て消える、よくあるあのトーストです。まずトースト 1 つを描くコンポーネント。

src/app/toast/toast.component.ts
export type ToastVariant = 'info' | 'success' | 'error';

@Component({
  selector: 'app-toast',
  standalone: true,
  template: `
    <div class="toast" [class]="variant()">
      <span>{{ message() }}</span>
      <button (click)="dismiss.emit()">×</button>
    </div>
  `,
})
export class ToastComponent {
  message = input.required<string>();
  variant = input<ToastVariant>('info');
  dismiss = output<void>();
}

サービスはアプリにコンテナを 1 度作っておき、呼び出しが来るたびにその中にトーストコンポーネントを動的に追加します。

src/app/toast/toast.service.ts
@Injectable({ providedIn: 'root' })
export class ToastService {
  private appRef = inject(ApplicationRef);
  private injector = inject(EnvironmentInjector);
  private container: HTMLElement | null = null;

  show(message: string, variant: ToastVariant = 'info', duration = 3000) {
    const host = this.ensureContainer();

    const ref = createComponent(ToastComponent, {
      hostElement: document.createElement('div'),
      environmentInjector: this.injector,
    });
    ref.setInput('message', message);
    ref.setInput('variant', variant);

    host.appendChild(ref.location.nativeElement);
    this.appRef.attachView(ref.hostView);

    const close = () => {
      this.appRef.detachView(ref.hostView);
      ref.destroy();
    };
    ref.instance.dismiss.subscribe(close);
    setTimeout(close, duration);
  }

  private ensureContainer(): HTMLElement {
    if (this.container) return this.container;
    const el = document.createElement('div');
    el.style.cssText = 'position:fixed; top:16px; right:16px; z-index:9999;';
    document.body.appendChild(el);
    return (this.container = el);
  }
}

ここでは ViewContainerRef の代わりに standalone createComponent 関数 を使いました。ホストコンポーネントなしでもコンポーネントを作れるので、サービスの中で直接インスタンス化するときに馴染みます。作られた ComponentRef を ApplicationRef.attachView() で変更検知ツリーに付け、DOM には appendChild で直接挟み込みます。呼び出しは this.toast.show('保存されました。', 'success') の 1 行で終わりです。

このサンプルはわざと小さく書きました。プロダクションではアニメーション、キュー長制限、メッセージ dedup、キーボードアクセシビリティのような細部がさらに入りますが、核心の骨格はこれが全部です。CDK Overlay で作ると位置/バックドロップ/スタック処理がよりすっきりするので、実務では直接書く前に CDK の上に乗せる方を先に検討してみてください。

まとめ #

今回はモダン Angular の動的コンポーネントパターンを一周しました。核心を整理すると:

  • ViewContainerRef場所createComponent挟み込みsetInput で input 注入
  • @defer は画面内 lazy の最もモダンなツール。可能なら第 1 候補で検討
  • ngComponentOutlet はテンプレートでクラスを差し替える宣言的な方法
  • モーダル・オーバーレイは CDK Portal/Overlay で親ツリーの外に表示するのが標準
  • 動的インジェクターでコンポーネントごとに異なるコンテキストを注入
  • destroy()/clear()/dispose() でメモリの片付けは自分で気にする (ComponentFactoryResolver は deprecated)

ほとんどの実務のケースは @deferngComponentOutlet だけで解け、モーダル・トースト類は CDK Overlay に行きます。createComponent を直接呼ぶケースは多くありませんが、ライブラリを作るときや深いところをデバッグするときのために形は覚えておくとよいです。

次回の「Angular上級 #4 RxJS 深掘り」では、非同期データフローのもう一つの軸 — Observable と演算子、マルチキャスト、エラー処理、Signals との境界 — を本格的に扱います。動的コンポーネントと RxJS が出会えば、トーストキュー、モーダルスタック、検索オートコンプリートのようなパターンが一段すっきりします。

X