2019-12-23に更新

@ionic/angularがWebComponentsでテンプレートチェックを効かせるためにやっていることを調べる

AngularでWebComponetsライブラリを使う時の基本

CustomElementsは基本的にはHTMLElementです(正確にはHTMLElementを継承して作ったもの)。customElements.define()することでグローバルに存在するようになるので、なんらかの方法でライブラリをインクルードすれば、あとはtemplateに書いておけば動きます。Angularから見れば、inputタグでもcustom-inputタグでも同じ話なわけです。

<input type="text"/>
<custom-input type="text"></custom-input>

ただし、Angularのテンプレートチェックに<input>はわかるけど<custom-input>は知らないやつだなあと怒られるので対策が必要です。

@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA],  ←templateチェックを無効にする
})
export class SomeModule {}

これで自由にHTML要素を書いてもよくなりますが、テンプレートの事前チェックが無効化されて実行時の存在判定に後送りされるため、他のタイポなども道連れで検出できなくなります。

<typo-input type="text"></typo-input> ←どこにも定義されてないがビルド時にエラーにならない

このようにAngularでWebComponetsを使うには誓約と制約があります。

@ionic/angularがやっていること

さて、ionicのv4はWebComponentsベースで書き直され、Angularだけでなくreactやvueでもできるようになりましたが、Angularは引き続きテンプレートチェックがビシッと効いています。

不思議なので中身を調べてみましょう。

なんと、同じ名称のAngularComponentが定義されていました❗️

ソースを読むとプロキシという扱いのようなので、これをプロキシコンポーネントと呼びます。
どうやらこいつがビルド時にAngularに差し出されているから、テンプレートの型チェックが効いてるようです。

<ion-input>を例に引っ張ってきました。現バージョンだと@ProxyCmpという新デコレータが作られていて、proxyMethodsproxyInputsの記述は省力化されていますが、構造がわかりやすいので旧スタイルで掲載しています。

export declare interface IonInput extends StencilComponents<'IonInput'> {}
@Component({
  selector: 'ion-input',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: '<ng-content></ng-content>',
  inputs: [
    'accept',
    'autocapitalize',
    'autocomplete',
    'autocorrect',
    'autofocus',
    'clearInput',
    'clearOnEdit',
    'color',
    'debounce',
    'disabled',
    'inputmode',
    'max',
    'maxlength',
    'min',
    'minlength',
    'mode',
    'multiple',
    'name',
    'pattern',
    'placeholder',
    'readonly',
    'required',
    'size',
    'spellcheck',
    'step',
    'type',
    'value',
  ],
})
export class IonInput {
  ionInput!: EventEmitter<CustomEvent>;
  ionChange!: EventEmitter<CustomEvent>;
  ionBlur!: EventEmitter<CustomEvent>;
  ionFocus!: EventEmitter<CustomEvent>;
  protected el: HTMLElement;
  constructor(c: ChangeDetectorRef, r: ElementRef) {
    c.detach();
    this.el = r.nativeElement;
    proxyOutputs(this, this.el, ['ionInput', 'ionChange', 'ionBlur', 'ionFocus']);
  }
}
proxyMethods(IonInput, ['setFocus', 'getInputElement']);
proxyInputs(IonInput, [
  'color',
  'mode',
  'accept',
  'autocapitalize',
  'autocomplete',
  'autocorrect',
  'autofocus',
  'clearInput',
  'clearOnEdit',
  'debounce',
  'disabled',
  'inputmode',
  'max',
  'maxlength',
  'min',
  'minlength',
  'multiple',
  'name',
  'pattern',
  'placeholder',
  'readonly',
  'required',
  'spellcheck',
  'step',
  'size',
  'type',
  'value',
]);

全体構造

通常のAngularComponentに則ったデコレータとclassです。proxyOutputsproxyMethodsproxyInputsはionicが用意しているユーティリティ関数です。

生のWebComponentsに対してバインドする方法

接続

コンポーネントデコレータのselectorをcustomElementと同じ名称にしています。相当ハックだと思ったのですが、Angularのコンポーネントはカスタム要素としてHTMLにそのまま出力するので、selectorをHTMLに存在している要素名にするとHTMLではHTMLに存在している要素として解釈するわけなんですよね。もちろん_nghost-wke-c2のような属性は付きます。良い子は真似するな案件ですが、customElementに限らず標準HTML要素のinputとかspanとかでも同じことできます。

プロキシ

コンポーネントでは、elementRefから引っ張ったnativeElementの生DOM参照をクラスプロパティに保持しています。
proxyInputsとproxyMethodsは、そのクラスプロパティに保持したnativeElementを通して生のionic-inputに対して、HTML属性のget/setやionic内部に用意されているメソッドを生やしています。

export const proxyInputs = (Cmp: any, inputs: string[]) => {
  const Prototype = Cmp.prototype;
  inputs.forEach(item => {
    Object.defineProperty(Prototype, item, {
      get() {
        return this.el[item];
      },
      set(val: any) {
        this.z.runOutsideAngular(() => (this.el[item] = val));
      }
    });
  });
};

export const proxyMethods = (Cmp: any, methods: string[]) => {
  const Prototype = Cmp.prototype;
  methods.forEach(methodName => {
    Prototype[methodName] = function () {
      const args = arguments;
      return this.z.runOutsideAngular(() =>
        this.el[methodName].apply(this.el, args)
      );
    };
  });
};

proxyOutputsは、rxjsのfromEvent(中でaddEventListenerやremoveEventListenerしながらObservable化するやつ)を、コンポーネントの各EventEmitter型のプロパティにセットしているものです。コンポーネントclassに同名のクラスプロパティが用意されています。

export const proxyOutputs = (instance: any, el: any, events: string[]) => {
  events.forEach(eventName => instance[eventName] = fromEvent(el, eventName));
}

まとめ

プロキシするコンポーネントを用意することで、グローバルに存在しているWebComponentsライブラリでも、Angularのテンプレートの中でまるでAngularComponentかのように扱えるようになり、テンプレートの型チェックが効くようになっています。

ツイッターでシェア
みんなに共有、忘れないようにメモ

studioTeaTwo

More features, more future.

Crieitは個人で開発中です。 興味がある方は是非記事の投稿をお願いします! どんな軽い内容でも嬉しいです。
なぜCrieitを作ろうと思ったか

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください!

ボードとは?

関連記事

コメント