詳細ガイド
コンポーネント

Angular elementsの概要

Angular elements はAngularコンポーネントを カスタム要素(Web Componentsとも呼ばれる)としてパッケージ化したものです。これは、新しいHTML要素をフレームワークに依存しない方法で定義するためのWeb標準です。

カスタム要素はWebプラットフォーム機能であり、Angularがサポートするすべてのブラウザで利用できます。 カスタム要素は、コンテンツがJavaScriptコードによって作成および制御されるタグを定義できるようにすることで、HTMLを拡張します。 ブラウザは、定義されたカスタム要素のCustomElementRegistryを保持しており、これはインスタンス化可能なJavaScriptクラスをHTMLタグにマッピングします。

@angular/elementsパッケージは、createCustomElement()APIをエクスポートしており、これはAngularのコンポーネントインターフェースと変更検知の機能から組み込みのDOM APIへのブリッジを提供します。

コンポーネントをカスタム要素に変換すると、必要なすべてのAngularインフラストラクチャがブラウザで利用可能になります。 カスタム要素の作成はシンプルで簡単であり、コンポーネントで定義されたビューを自動的に変更検知とデータバインディングに接続し、Angularの機能を対応する組み込みのHTML同等物にマッピングします。

カスタム要素の使用

カスタム要素は自己ブートストラップされます。DOMに追加されると開始し、DOMから削除されると破棄されます。 カスタム要素が任意のページのDOMに追加されると、他のHTML要素と同じように見え、動作し、Angularの用語や使用規則に関する特別な知識は必要ありません。

ワークスペースに@angular/elementsパッケージを追加するには、次のコマンドを実行します。

      
npm install @angular/elements --save

仕組み

createCustomElement()関数は、コンポーネントをブラウザにカスタム要素として登録できるクラスに変換します。 設定したクラスをブラウザのカスタム要素レジストリに登録した後、新しい要素を、DOMに直接追加するコンテンツ内で組み込みのHTML要素と同じように使用します。

      
<my-popup message="Use Angular!"></my-popup>

カスタム要素がページに配置されると、ブラウザは登録されたクラスのインスタンスを作成し、それをDOMに追加します。 コンテンツはコンポーネントのテンプレートによって提供され、Angularテンプレート構文を使用し、コンポーネントとDOMデータを使用してレンダリングされます。 コンポーネントの入力プロパティは、要素の入力属性に対応します。

コンポーネントをカスタム要素に変換する

Angularは、Angularコンポーネントとその依存関係をカスタム要素に変換するためのcreateCustomElement()関数を提供します。

変換プロセスはNgElementConstructorインターフェースを実装し、 コンポーネントの自己ブートストラップインスタンスを生成するように構成されたコンストラクタークラスを作成します。

ブラウザのネイティブなcustomElements.define()関数を使用して、設定されたコンストラクターとその関連するカスタム要素タグをブラウザのCustomElementRegistryに登録します。 ブラウザが登録された要素のタグを検出すると、コンストラクターを使用してカスタム要素インスタンスを作成します。

IMPORTANT: コンポーネントのセレクターをカスタム要素のタグ名として使用することは避けてください。 これは、Angularが単一のDOM要素に対して2つのコンポーネントインスタンスを作成するため、予期しない動作につながる可能性があります。 1つは通常のAngularコンポーネント、もう1つはカスタム要素を使用するものです。

マッピング

カスタム要素はAngularコンポーネントを_ホスト_し、コンポーネントで定義されたデータとロジック、および標準のDOM API間のブリッジを提供します。 コンポーネントのプロパティとロジックは、HTML属性とブラウザのイベントシステムに直接マッピングされます。

  • 作成APIは、入力プロパティを探してコンポーネントを解析し、カスタム要素に対応する属性を定義します。 プロパティ名をカスタム要素と互換性があるように変換します。カスタム要素は大文字と小文字の区別を認識しません。 結果として得られる属性名は、ダッシュで区切られた小文字を使用します。 例えば、inputProp = input({alias: 'myInputProp'})を持つコンポーネントは、対応するカスタム要素としてmy-input-propという属性を定義します。

  • コンポーネントの出力はHTMLのカスタムイベントとしてディスパッチされ、カスタムイベントの名前は出力名と一致します。 例えば、valueChanged = output()を持つコンポーネントの場合、対応するカスタム要素は"valueChanged"という名前のイベントをディスパッチし、出力されたデータはイベントのdetailプロパティに格納されます。 エイリアスを提供した場合、その値が使用されます。clicks = output<string>({alias: 'myClick'});は"myClick"という名前のディスパッチイベントを生成します。

詳細については、Webコンポーネントのドキュメントのカスタムイベントの作成を参照してください。

例: Popupサービス

以前は、実行時にコンポーネントをアプリケーションに追加したい場合、_動的コンポーネント_を定義し、それをロードしてDOMの要素にアタッチし、すべての依存関係、変更検知、イベント処理を配線する必要がありました。

Angularカスタム要素を使用すると、必要なインフラストラクチャとフレームワークをすべて自動的に提供することで、プロセスがよりシンプルかつ透過的になります。必要なのは、希望するイベント処理の種類を定義することだけです。 (アプリケーションで使用しない場合は、コンポーネントをコンパイルから除外する必要はあります。)

以下のPopupサービス例のアプリケーションは、動的にロードするかカスタム要素に変換できるコンポーネントを定義しています。

ファイル 詳細
popup.component.ts 入力メッセージを表示するシンプルなポップアップ要素を、アニメーションとスタイル付きで定義します。
popup.service.ts PopupComponentを呼び出す2つの異なる方法を提供する注入可能サービスを作成します。動的コンポーネントとして、またはカスタム要素として。動的読み込み方式には、より多くのセットアップが必要であることに注目してください。
app.component.ts PopupServiceを使用して実行時にDOMにポップアップを追加する、アプリケーションのルートコンポーネントを定義します。アプリケーションの実行時、ルートコンポーネントのコンストラクターはPopupComponentをカスタム要素に変換します。

比較のため、デモでは両方の方法を示しています。 一方のボタンは動的読み込み方式を使用してポップアップを追加し、もう一方はカスタム要素を使用します。 結果は同じですが、準備が異なります。

popup.component.ts

      
import {Component, computed, input, output} from '@angular/core';import {animate, state, style, transition, trigger} from '@angular/animations';@Component({  selector: 'my-popup',  template: `    <span>Popup: {{ message }}</span>    <button type="button" (click)="closed.next()">✖</button>  `,  animations: [    trigger('state', [      state('opened', style({transform: 'translateY(0%)'})),      state('void, closed', style({transform: 'translateY(100%)', opacity: 0})),      transition('* => *', animate('100ms ease-in')),    ]),  ],  styles: [    `      :host {        position: absolute;        bottom: 0;        left: 0;        right: 0;        background: #009cff;        height: 48px;        padding: 16px;        display: flex;        justify-content: space-between;        align-items: center;        border-top: 1px solid black;        font-size: 24px;      }      button {        border-radius: 50%;      }    `,  ],  host: {    '[@state]': 'state()',  },})export class PopupComponent {  message = input('');  closed = output<void>();  state = computed(() => (this.message() ? 'opened' : 'closed'));}

popup.service.ts

      
import {ApplicationRef, createComponent, EnvironmentInjector, Injectable} from '@angular/core';import {NgElement, WithProperties} from '@angular/elements';import {PopupComponent} from './popup.component';@Injectable()export class PopupService {  constructor(    private injector: EnvironmentInjector,    private applicationRef: ApplicationRef,  ) {}  // Previous dynamic-loading method required you to set up infrastructure  // before adding the popup to the DOM.  showAsComponent(message: string) {    // Create element    const popup = document.createElement('popup-component');    // Create the component and wire it up with the element    const popupComponentRef = createComponent(PopupComponent, {      environmentInjector: this.injector,      hostElement: popup,    });    // Attach to the view so that the change detector knows to run    this.applicationRef.attachView(popupComponentRef.hostView);    // Listen to the close event    popupComponentRef.instance.closed.subscribe(() => {      document.body.removeChild(popup);      this.applicationRef.detachView(popupComponentRef.hostView);    });    // Set the message    popupComponentRef.instance.message = message;    // Add to the DOM    document.body.appendChild(popup);  }  // This uses the new custom-element method to add the popup to the DOM.  showAsElement(message: string) {    // Create element    const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement(      'popup-element',    ) as any;    // Listen to the close event    popupEl.addEventListener('closed', () => document.body.removeChild(popupEl));    // Set the message    popupEl.message = message;    // Add to the DOM    document.body.appendChild(popupEl);  }}

app.component.ts

      
import {Component, Injector} from '@angular/core';import {createCustomElement} from '@angular/elements';import {PopupComponent} from './popup.component';import {PopupService} from './popup.service';@Component({  selector: 'app-root',  template: `    <input #input value="Message" />    <button type="button" (click)="popup.showAsComponent(input.value)">Show as component</button>    <button type="button" (click)="popup.showAsElement(input.value)">Show as element</button>  `,  providers: [PopupService],  imports: [PopupComponent],})export class AppComponent {  constructor(    injector: Injector,    public popup: PopupService,  ) {    // Convert `PopupComponent` to a custom element.    const PopupElement = createCustomElement(PopupComponent, {injector});    // Register the custom element with the browser.    customElements.define('popup-element', PopupElement);  }}

カスタム要素の型定義

document.createElement()document.querySelector()のような汎用DOM APIは、指定された引数に適した要素型を返します。 例えば、document.createElement('a')を呼び出すとHTMLAnchorElementが返され、TypeScriptはこれにhrefプロパティがあることを認識しています。 同様に、document.createElement('div')HTMLDivElementを返しますが、TypeScriptはこれにhrefプロパティがないことを認識しています。

カスタム要素名(この例ではpopup-element)のような未知の要素で呼び出された場合、TypeScriptが返された要素の正しい型を推論できないため、これらのメソッドはHTMLElementのような汎用型を返します。

Angularで作成されたカスタム要素はNgElement(これはさらにHTMLElementを拡張します)を拡張します。 さらに、これらのカスタム要素は対応するコンポーネントの各入力に対応するプロパティを持ちます。 例えば、popup-elementstring型のmessageプロパティを持ちます。

カスタム要素の正しい型を取得したい場合、いくつかの選択肢があります。 次のコンポーネントに基づいてmy-dialogカスタム要素を作成すると仮定します。

      
@Component(…)class MyDialog {  content =  input(string);}

正確な型定義を取得する最も簡単な方法は、関連するDOMメソッドの戻り値を正しい型にキャストすることです。 そのためには、NgElementおよびWithProperties型(両方とも@angular/elementsからエクスポートされています)を使用します。

      
const aDialog = document.createElement('my-dialog') as NgElement & WithProperties<{content: string}>;aDialog.content = 'Hello, world!';aDialog.content = 123;  // <-- ERROR: TypeScript knows this should be a string.aDialog.body = 'News';  // <-- ERROR: TypeScript knows there is no `body` property on `aDialog`.

これは、カスタム要素に対して型チェックやオートコンプリートサポートなどのTypeScript機能を素早く利用するための良い方法です。 しかし、複数の場所で必要になる場合、すべての出現箇所で戻り値をキャストする必要があるため、煩雑になる可能性があります。

各カスタム要素の型を一度だけ定義すればよい代替方法は、HTMLElementTagNameMapを拡張することです。これは、TypeScriptがタグ名に基づいて返された要素の型を推論するために使用するものです(document.createElement()document.querySelector()などのDOMメソッドの場合)。

      
declare global {  interface HTMLElementTagNameMap {    'my-dialog': NgElement & WithProperties<{content: string}>;    'my-other-element': NgElement & WithProperties<{foo: 'bar'}>;  }}

これで、TypeScriptは組み込み要素と同じように正しい型を推論できます。

      
document.createElement('div')               //--> HTMLDivElement (built-in element)document.querySelector('foo')               //--> Element        (unknown element)document.createElement('my-dialog')         //--> NgElement & WithProperties<{content: string}> (custom element)document.querySelector('my-other-element')  //--> NgElement & WithProperties<{foo: 'bar'}>      (custom element)

制限事項

@angular/elementsにより作成されたカスタム要素を破棄してから再アタッチする際には、disconnect()コールバックに関する問題があるため注意が必要です。この問題に遭遇する可能性のあるケースは以下の通りです。

  • AngularJSにおいてng-ifまたはng-repeat内でコンポーネントをレンダリングする場合
  • 要素をDOMから手動でデタッチし、再アタッチする場合