WebComponent を Vue コンポーネントツリーの末端として使う

JSフレームワークの末端がWebComponentsになるのか、なれるのか、検証してみた - Qiita の記事を読んで、Vue だとどうなるかな、と思って軽く検証した。自分の手に馴染んているのが Vue というだけの理由で、決して React dis ではないです( React は React で、そのうちいいかんじの仕組みが入るような気がする )。

結論、問題なく使えると思う。

そもそも WebComponent を末端で利用するモチベーションは?

現状、CSS フレームワーク、UI フレームワークがあり、そのフレームワークをラップして View フレームワーク( React, Vue, Angular )のコンポーネントとして提供するライブラリをよく見かける( react-xxx とか vue-xxx みたいなの )。こういったライブラリの組み合わせは、UI フレームワークの数を M, View フレームワークの数を N とすると M * N の組み合わせになる。
各 View フレームワーク向けに 1 から UI フレームワークコンポーネント化して、このようなライブラリを提供するのは効率が悪い。
また、View フレームワークの機能や仕様の差があるため致し方ない部分もあるが、提供されるコンポーネントの I/F が統一感のないものになってしまう。

これらの問題は、再利用可能な単位で WebComponent( Custom Element )を定義すれば、解決するような気がする。View フレームワークから直接利用してもよいし、そのコンポーネントを View フレームワークコンポーネントしてラップしたライブラリを提供する場合でも、以前ほどのコストはかからず、フレームワーク間である程度統一された I/F になる。

ここでの WebComponent は、Python でいう アプリケーション・サーバーとフレームワークの間を取り持つ WSGIRuby でいう Rack のような立ち位置に近い。

Vue で WebComponent を使う

WebComponent で独自の要素を定義して使う。独自の要素といっても、普通の abutton といった要素と同じ I/F なので属性で値を渡して、要素の中で発生したことはイベントを通して知ればよい。

次の様なクリックされたらカスタムイベント my-click を発火する WebComponent ( Custom Element ) を定義しておいて、

import { html, render } from '/node_modules/lit-html/lib/lit-extended.js';

const template = props => html`
  <style>
    :host {
      display: block;
    }
  </style>
  <div>
    <button on-click="${props.onClick}">${props.text}</button>
  </div>
`;

export default class MyButton extends HTMLElement {
  static get observedAttributes() {
    return ['text'];
  }
  constructor() {
    super();
    this._shadowRoot = this.attachShadow({mode: 'open'});
  }
  connectedCallback() {
    this.render();
  }
  attributeChangedCallback() {
    this.render();
  }
  render() {
    const text = this.getAttribute('text');
    render(template({
        text,
        onClick: this.onClick,
    }), this._shadowRoot);
  }
  onClick() {
    this.dispatchEvent(new CustomEvent("my-click", {bubbles: true, composed: true}));
  }
}

window.customElements.define('my-button', MyButton);

Vue のコンポーネントで以下のように使う。

import Vue from '/node_modules/vue/dist/vue.esm.browser.js';

Vue.component('home', {
    data() {
        return {
            value: 0,
        };
    },
    mounted() {
        setInterval(() => {
            this.value += 1;
        }, 1000);
    },
    methods: {
        onClick() {
            console.log('onClick', this.value);
        },
    },
    template: `
        <my-button v-bind:text="value" v-on:my-click="onClick"></my-button>
    `,
});

v-bind ディレクティブで、カウントしている値を text 属性の値としてバインディングして、v-on ディレクティブで my-button コンポーネントから発火される my-click イベントをハンドリングする。

Vue でのイベントハンドラの扱い

v-on ディレクティブは、カスタムイベントを含む DOM イベントをハンドリングすることができる( 細かいけど、Vue コンポーネントの場合は Vue の持っているイベント機構で発火されたイベントもハンドリングできる )。
このディレクティブが肝で、React と違って、イベントハンドラの関数をコンポーネントに渡す必要がない(渡すこともできるが、あえてそうする必要がない)。
なので、WebComponent ( Custom Element ) へ関数を渡すことを考えなくてもよい。

最初に書いたモチベーションの場合、WebComponent を書く人間と、それを使う人間が分かれることがほとんどになるので、
WebComponent は、自身を使おうとしている View フレームワークや、それが関数を渡そうとしていること、あるいは関数を渡すための仕掛けについて、関心を持つべきではないし、コンポーネント自体の再利用性も鑑みると関数を引き渡すことは諦めたほうがいいのではないかと思う。将来的に双方で関数を渡したい・受け取りたいニーズが高まれば別。

WebComponent でのイベントリスナーの扱い

DOM 操作したくないので button 要素のクリックのイベントハンドラの管理も含め lit-extended でだいぶ楽をしたが、extended ではない lit-html ( addEventListener )を使う場合はこんなかんじになる。

import { html, render } from '/node_modules/lit-html/lit-html.js';

const template = props => html`
  <style>
    :host {
      display: block;
    }
  </style>
  <div>
    <button>${props.text}</button>
  </div>
`;

export default class MyButton extends HTMLElement {
  static get observedAttributes() {
    return ['text'];
  }
  constructor() {
    super();
    this._shadowRoot = this.attachShadow({mode: 'open'});
    this._shadowRoot.addEventListener('click', (e) => {
        if (e.target.tagName === 'BUTTON') {
            this.onClick();
        }
    });
  }
  connectedCallback() {
    this.render();
  }
  attributeChangedCallback() {
    this.render();
  }
  render() {
    const text = this.getAttribute('text');
    render(template({
        text,
        onClick: this.onClick,
    }), this._shadowRoot);
  }
  onClick() {
    this.dispatchEvent(new CustomEvent("my-click", {bubbles: true, composed: true}));
  }
}

window.customElements.define('my-button', MyButton);

button 要素にイベントリスナーを登録すると lit-html が要素を差し替えることを考慮して(この例では考えなくていいかもだが)、適切なフックで都度 addEventListener, removeEventListener を呼ぶ必要がある。そこで、shadowRoot に対してイベントリスナーを登録し、イベントを発生された要素に応じて、カスタムイベントを発火させるようにする。
shadowRoot に登録するイベントリスナーが分岐だらけになりそうな気もするのだけど、コンポーネントの責務さえしっかり決まっていればさほど問題にならないような気がする。コンポーネントの状態管理が複雑になるのは、また別の問題。

正直、この例だったら lit-html を使わず DOM API でも実装できるが、実際にコンポーネントを作ろうとしたら、もう少し複雑なものになって DOM API ではつらくなり lit-html を使うことになると思うので試した。

色々書いたけれども、Vue では WebComponent が普通に使えると思うので、今のうちから各種 CSS フレームワーク、UI フレームワークの WebComponent( Custom Element )を定義したライブラリを作っておけば、後で重宝されるかもしれないですね。