Marginalia

Angular v15 hostDirectivesのユースケース検討: 状態監視系ディレクティブの合成

この記事ではAngular v15で追加される @Directive.hostDirectives の実用的なユースケースとして、状態監視系ディレクティブを合成する使い道を検討する。

hostDirectivesは基本的にライブラリ製作者に向けたAPIである上に実装されてから日が浅いのでまだ詳しいドキュメントはないが、ひとまず一次情報としてはこのIssueが一番適しているだろう。

公式チャンネルではないが、Angular Componentsチームの @crisbeto が機能について詳しく語っている動画もあるので参考にしてほしい。

ユースケース例: ViewportDirectiveの合成

hostDirectives はスタンドアロンなディレクティブを別のコンポーネントやディレクティブに合成できる機能であるから、合成されるディレクティブは再利用可能性が高いユーティリティ的なものが主になるだろう。

わざわざディレクティブとして実装して再利用したいユーティリティといえば、だいたいは ElementRef を参照してDOMを扱う類のものである。そこで今回はDOMの状態を監視するAPIのひとつである IntersectionObserver を利用して、画面内に要素が出入りするイベントをアプリケーションで扱えるようにする ViewportDirective を例に hostDirectives を試してみよう。

動作するサンプルはStackblitzで公開している。

ViewportDirective の実装

本質的な部分ではないので詳細は省くが、 IntersectionObserver を使ってホスト要素が完全に表示されたときに viewportIn イベントを、ホスト要素が完全に画面外に隠れたときに viewportOut イベントを発火する。

@Directive({
  selector: '[appViewport]',
  standalone: true,
})
export class ViewportDirective implements AfterViewInit, OnDestroy {
  private el = inject(ElementRef).nativeElement as HTMLElement;
  private intersectionObserver = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        // 100%表示
        if (entry.isIntersecting && entry.intersectionRatio === 1) {
          this.viewportIn.emit();
        }
        // 100%非表示
        if (!entry.isIntersecting && entry.intersectionRatio === 0) {
          this.viewportOut.emit();
        }
      }
    },
    {
      threshold: [0, 1],
    }
  );

  @Output()
  readonly viewportIn = new EventEmitter<void>();
  @Output()
  readonly viewportOut = new EventEmitter<void>();

  ngAfterViewInit() {
    this.intersectionObserver.observe(this.el);
  }

  ngOnDestroy() {
    this.intersectionObserver.disconnect();
  }
}

ディレクティブとして直接利用する

まずは ViewportDirective をそのままテンプレート中で直接呼び出して利用する。比較対象として書いているだけなので特に解説することはない。

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [ViewportDirective],
  template: `
  <div class="container">
    <div style="height: 110vh; background: tomato;">110vh</div>

    <div appViewport style="padding: 16px; border: 1px solid black;"
      (viewportIn)="onViewportIn('direct')" 
      (viewportOut)="onViewportOut('direct')">
      viewport directive (direct)
    </div>
  </div>
  `,
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  onViewportIn(name: string) {
    console.log(name, 'onViewportIn');
  }

  onViewportOut(name: string) {
    console.log(name, 'onViewportOut');
  }
}

コンポーネントに合成して利用する (hostDirectives )

では、 hostDirectives を使ってコンポーネントに合成して ViewportDirective を使ってみよう。まずは合成する先のコンポーネントとして BannerComponent を定義する。

@Component({
  selector: 'app-banner',
  standalone: true,
  template: `
    <ng-content></ng-content>
  `,
})
export class BannerComponent {}

次に hostDirectives プロパティをコンポーネントメタデータに追加し、次のように ViewportDirective を追加する。 hostDirectives に追加するディレクティブは imports に追加する必要はない。( imports はテンプレートコンパイルのためのメタデータであるから)

デフォルトではアウトプットは合成されないため、 ViewportDirective が持つ2つのアウトプットを BannerComponent の一部として公開するために、 outputs プロパティを設定している。

@Component({
  ...,
  hostDirectives: [
    {
      directive: ViewportDirective,
      outputs: ['viewportIn', 'viewportOut'],
    },
  ],
})

これにより、 親コンポーネントでは BannerComponent には定義されていない viewportInviewportOut イベントにもアクセスできる。

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [ViewportDirective, BannerComponent],
  template: `
  <div class="container">
    <div style="height: 110vh; width: 100%; background: skyblue;">110vh</div>

    <app-banner
      (viewportIn)="onViewportIn('composite')" 
      (viewportOut)="onViewportOut('composite')">
      viewport directive (composite)
    </app-banner>
  </div>
  `,
  styleUrls: ['./app.component.css'],
})
export class AppComponent {}

コンポーネント内部から参照する (dependency injection)

もうひとつの使い方として、 hostDirectives に追加したディレクティブの参照をDependency Injectionで取得することが考えられる。合成した機能を親コンポーネントに対して露出するのではなく、内部で利用する形だ。

次のように inject 関数で取得したホスト要素の ViewportDirective インスタンスを使い、 viewportInviewportOut のイベントを購読して処理を行うことができる。

@Component({
  ...
  hostDirectives: [
    {
      directive: ViewportDirective,
    },
  ],
  host: {
    '[class.in-viewport]': 'isInViewport',
  },
})
export class BannerComponent {
  private viewport = inject(ViewportDirective, { self: true });
  isInViewport = false;

  ngOnInit() {
    merge(
      this.viewport.viewportIn.pipe(map(() => true)),
      this.viewport.viewportOut.pipe(map(() => false))
    ).subscribe((isInViewport) => {
      this.isInViewport = isInViewport;
    });
  }
}

インプット・アウトプットの再公開は名前の設計が重要

今回の検討で感じたのは、 outputs を使ったケースでは <app-banner> コンポーネントが viewportIn / viewportOut を自身のアウトプットとして再公開したが、 ViewportDirective が公開するときに適した命名とは違っているように思う。

ディレクティブのインプット・アウトプットは、ディレクティブ名をprefixとするような命名がされやすい。たとえば routerLink に対して routerLinkActive のような感じだ。なぜかといえば同じホスト要素に複数の属性ディレクティブが付与されることがあり、名前空間を分けて衝突しないようにするからだ。

BannerComponent から再公開したインプット・アウトプットが合成されたものであったとしても、 BannerComponent を利用する側からすれば直接定義されたものとの間に違いはない。だから BannerComponent が持っていても不自然ではない名前で公開するようにエイリアスを設定するのがいいだろう。エイリアスは 元の名前: 再公開する名前 で設定できる。

@Component({
  ...,
  hostDirectives: [
    {
      directive: ViewportDirective,
      outputs: [
        'viewportIn: shown', // `<app-banner (shown)="onBannerShown()">
      ],
    },
  ],
})