no-image

Custom Elements v1で独自のHTML要素を定義する

HTMLを書く上で一番よく見かける要素は何でしょうか。それはおそらくdiv要素でしょう。大量に散らばったdiv要素は、もはやHTMLではありふれた光景となっています。しかし見た目が美しくありませんし、マークアップ的にも推奨できる行為ではありません。

そこで現れたのがCustom Elementsです。Custom Elementを使用すれば、独自の要素を定義することができ、マークアップがわかりやすくなります。この記事では、Custom Elementsについて簡単に解説します。

Web ComponentsとCustom Elements

Web Componentsは以下の4つからなる規格です。

  • Templates
  • Shadow DOM
  • Custom Elements
  • HTML Imports

今回はこのうちCustom Elementsに焦点を当てていきます。

独自要素を定義するCustom Elements

独自要素を定義する

Custom Elementsユーザ独自のHTML要素を定義するための規格です。まずは例を見てみましょう。次のような独自要素を定義したいとします。

<my-element></my-element>

このとき、JavaScriptでは次のようなコードを書きます:

// HTMLElementを継承して、独自の要素を定義します
class MyElement extends HTMLElement {
  constructor() {
    super();
  }
}

// <my-element>要素としてMyElementクラスを登録します
customElements.define('my-element', MyElement);

まず、独自要素を定義するためには、HTMLElementを継承したクラスが必要です。そして定義したクラスをcustomElements.defineメソッドで要素として登録します。これで独自要素が使えるようになります。

しかしひとつ注意点があります。独自要素は、必ず名前にハイフン(-)を含む必要があります。つまり、<element>や<tab>といった名前は使えず、<my-element>や<app-tab>などという名前をつける必要があるということです。これは既存のHTML要素とユーザ定義のHTML要素の区別をつけるためです。

独自要素の使い方は普通のHTML要素と同じです。

<my-element></my-element>

必要なことは、たったこれだけです。しかしこれだけでは何が便利なのかよくわかりません。これから、Custom Elementsについて、もう少し掘り下げてみましょう。

独自要素にAPIを定義する

通常のHTML要素には、様々なAPIが存在します。例えば<input>要素はvalueプロパティで値を読み書きすることができます。Custom Elementsでも同様に、独自要素にメソッドやプロパティを追加することができます。APIを定義する方法は簡単です。クラスにメソッドやプロパティを実装するだけです。

例として、次のような独自要素を考えてみましょう。

  • 数字をカウントすることができる<num-counter>要素
    • valueAsNumberプロパティで数値の読み書きができる
    • increaseメソッドで数値を1増加させることができる

これをCustom Elementsで定義すると次のようになります:

// NumCounterクラスを定義する
class NumCounter extends HTMLElement {
    constructor() {
        super();
    }

    get valueAsNumber() {
        return parseInt(this.textContent);
    }

    set valueAsNumber(val) {
        this.textContent = val.toString();
    }

    increase() {
        this.valueAsNumber += 1;
    }
}

// num-counter要素を登録する
customElements.define('num-counter', NumCounter);

// <num-counter>を作成してbodyに加える
const counter = new NumCounter();
document.body.appendChild(counter);

// 初期値を5に設定して、2回増加させる
counter.valueAsNumber = 5;
counter.increase();
counter.increase();

これを実行すると「7」と表示されます(初期値が5で、2回増加させているので)。このようにして、Custom Elementsには独自にAPIを定義することができます。

既存の要素を拡張する

Custom Elementsでは、まったく新しい独自要素を作る他に、既存の要素を拡張することもできます。

例として、button要素を拡張したpretty-button要素を作成してみましょう。このときコードは次のようになります:

// PrettyButtonクラスを定義する
// HTMLButtonElementを継承する
class PrettyButton extends HTMLButtonElement {
    constructor() {
        super();
        this.style.border = '1px solid rgb(60, 60, 60)';
        this.style.borderRadius = '3px';
        this.style.backgroundColor = 'transparent';
        this.style.padding = '0.5em 1em';
    }
}

// PrettyButtonをbuttonの拡張として登録する
customElements.define('pretty-button', PrettyButton, {extends: 'button'});

// button要素を作成してpretty-buttonとする
// createElementのかわりに単純にnew PrettyButton()してもよい
const prettyButton = document.createElement('button', {is: 'pretty-button'});
prettyButton.textContent = 'Pretty button!';
document.body.appendChild(prettyButton);

既存の要素を拡張する方法は簡単です。まず既存の要素を継承したクラスを作成します。そしてcustomElements.defineするときに、引数に{exnteds: ‘要素名’}を渡します。そしてcreateElement時に{is: ‘独自要素名’}を引数に渡します(クラスを直接newしてもかまいません)。これだけで既存の要素が拡張できます。

この拡張した要素を、JavaScriptから操作するのではなくHTMLに直接記述する場合は、is属性を用いて次のようにします:

<button is='pretty-button'>Pretty button!</button>

Custom Elementsのイベント

Custom Elementsでは、DOM操作に関わる様々なイベントが発生します。イベントが発生すると、Custom Elementsの対応するコールバックメソッドが呼び出されます。コールバックメソッドは次の4つあります。

  • connectedCallback()
    • 要素がDOMに追加された時に呼び出されます
  • disconnectedCallback()
    • 要素がDOMから取り除かれた時に呼び出されます
  • attributeChangedCallback(attrName, oldVal, newVal)
    • 要素の属性が変更された時に呼び出されます
  • adoptedCallback()
    • 要素が異なるdocumentに移動された時に呼び出されます

それぞれのメソッドをクラスに実装することで、対応した処理を行うことができます。

class MyElement extends HTMLElement {
    constructor() {
        super();
    }

    connectedCallback() {}
    disconnectedCallback() {}
    attributeChangedCallback(attrName, oldVal, newVal) {}
    adoptedCallback() {}
}

属性の変更を監視する

前述のattributeChangedCallbackメソッドを利用することで、属性の変更を監視することができます。もう少し詳しく見ていきましょう。

attributeChangedCallbackメソッドは、Custom ElementsのstaticなobservedAttributesプロパティで定義された属性が変更された時にのみ呼び出されます。observedAttributesプロパティは監視する属性を配列で返します。

class MyElement extends HTMLElement {
    constructor() {
        super();
    }

    static get observedAttributes() {
        return ['disabled'];
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
        console.log('属性が変更されました');
    }
}

この例ではdisabled属性を監視しています。要素のdisabled属性が変更されたとき(またはHTML上で初期値として設定されたとき)、attributeChangedCallbackが呼び出されます。

定義済み・未定義のCustom Elementsにスタイルを適用する

:defined擬似クラスを利用することで、定義済みのCustom Elementsにスタイルを適用することができます。また、:not擬似クラスと組み合わせて、:not(:defined)とすることで、未定義の要素にスタイルを適用することができます。

:not(:defined) {
    display: none;
}

Shadow DOMと組み合わせる

Shadow DOMはCustom ElementsとともにWeb Componentsの一員です。これらを組みわせることで、より効果的なコンポーネント化が可能になります。Shadow DOMについては、詳しくはShadow DOM v1でHTMLの内容と構造を分離するをご覧ください。

例えばTwitterのプロフィールを表示するtwitter-card要素を作ることを考えましょう。twitter-card要素は次のように使用する要素です:

<twitter-card username="古都こと" userid="kfurumiya"></twitter-card>

すると次のように表示されます:

twitter-card

この要素を実現するには、なかなか複雑な処理が要求されるでしょう。デザインを実現するため、twitter-card要素の内部にいくつかの要素が必要になりそうですが、できればHTMLを汚染したくありません。つまり、どうやらShadow DOMの助けが必要になりそうです。

Custom ElementsとShadow DOMを同時に利用してみましょう。このときJavaScriptのコードは次のようになります:

class TwitterCard extends HTMLElement {
    constructor() {
        super();

        const shadowRoot = this.attachShadow({mode: 'open'});
        shadowRoot.innerHTML = `
            <style>
                :host {
                    display: inline-block;
                    border: 1px solid rgb(120, 120, 120);
                    padding: 0.5em 2em;
                    text-align: center;
                }
                
                .username {
                    font-size: 1.5em;
                }
            </style>
            <div class="username"></div>
            <div class="userid"></div>
        `;

        this._username = shadowRoot.querySelector('.username');
        this._userid = shadowRoot.querySelector('.userid');
    }

    static get observedAttributes() {
        return ['username', 'userid'];
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
        const username = this.getAttribute('username');
        const userid = this.getAttribute('userid');

        this._username.textContent = username;
        this._userid.innerHTML = `
            <a href="https://twitter.com/${userid}">@${userid}</a>
        `;
    }
}

customElements.define('twitter-card', TwitterCard);

Custom Elementsを利用することで独自のtwitter-card要素を定義し、Shadow DOMを利用することでtwitter-card要素の内部を表面から隠すことができました。このようにして、Web Componentsの規格は複数のものを組み合わせることで、より効果的になります。