NHN Cloud NHN Cloud Meetup!

Webコンポーネント(2):Custom Elements

前回の記事につながるWebコンポーネントの主要標準の1つであるカスタム要素について紹介します。

TL;DR

  • <div>の代わりに<current-time>のように、適切な名前のタグを使用できる
  • HTML要素とJavaScriptクラスを一度に作る
  • IE11以上のみに対応しており、Polyfillが必要な場合もある
  • 長いMutationObserverコードとサヨナラできる

使い方はコードを見た方が早いでしょう。以下のコードをChromeやSafariにのせてみよう。

<!DOCTYPE html>
<html>
    <head>
        <script src="../src/CurrentTime.js"></script>
    </head>
    <body>
        <current-time>
            <!-- fallback value -->
            6/11/2017, 11:55:49
        </current-time>
    </body>
</html>
class CurrentTime extends HTMLElement {
    constructor() {
        // クラス初期化。下位ノードはアプローチできない。
        super();
    }

    static get observedAttributes() {
        // モニタリングする属性名
        return ['locale'];
    }

    connectedCallback() {
        // DOMに追加された。レンダリングなどの処理をしよう。
        this.start();
    }

    disconnectedCallback() {
        // DOMから除去された。エレメントを整理しよう。
        this.stop();
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
        // 属性が追加/削除/変更された。
        this[attrName] = newVal;
    }

    adoptedCallback(oldDoc, newDoc) {
        // 他のドキュメントから移動した。
        // 頻繁に使うことはない。
    }

    start() {
        // 必要に応じてメソッドを追加できる。
        // このクラスインスタンスはHTML要素である。 
        // したがって`document.querySelector('current-time').start()`を呼び出せる。
        this.stop();
        this._timer = window.setInterval(() => {
            this.innerText = new Date().toLocaleString(this.locale);
        }, 1000);
    }

    stop() {
        // このメソッドもCurrentTimeクラスの必要によって追加した。
        if (this._timer) {
            window.clearInterval(this._timer);
            this._timer = null;
        }
    }
}

// タグがCurrentTimeクラスを使用するようにする。
customElements.define('current-time', CurrentTime);

下記は、参考までに似たような役割を実行するように、カスタム要素を使用せずに作成したコードです。

<!DOCTYPE html>
<html>
    <head>
        <script src="../src/CurrentTime.js"></script>
    </head>
    <body>
        <div class="current-time">
            <!-- fallback value -->
            6/11/2017, 11:55:49
        </div>
    </body>
</html>
class CurrentTime {
    constructor(el) {
        this._el = el;

        this._init();
        this.start();
    }

    _init() {
        // 速成変更モニター
        this._localeChangedObserver = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                if (mutation.type === 'attributes' && mutation.attributeName === 'locale') {
                    this.locale = this._el.getAttribute('locale');
                }
            });
        });
        this._localeChangedObserver.observe(this._el, {
            attributes: true,
            attributeFilter: ['locale']
        });

        // エレメントがDOMから除去されたかモニタ
        this._disconnectedObserver = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                if (mutation.type === 'childList' &&
                    Array.prototype.slice.call(mutation.removedNodes).indexOf(this._el) >= 0) {
                    this.dispose();
                }
            });
        });
        this._disconnectedObserver.observe(this._el.parentNode, {
            childList: true
        });
    }

    start() {
        this.stop();

        this._timer = window.setInterval(() => {
            this._el.innerText = new Date().toLocaleString(this.locale);
        }, 1000);
    }

    stop() {
        if (this._timer) {
            window.clearInterval(this._timer);
            this._timer = null;
        }
    }

    dispose() {
        this.stop();
        this._localeChangedObserver.disconnect();
        this._disconnectedObserver.disconnect();
    }

    static create(el) {
        return new CurrentTime(el);
    }
}

document.addEventListener('DOMContentLoaded', () => {
    document.querySelectorAll('.current-time').forEach(el => {
        CurrentTime.create(el);
    });
}, false);

Modernizing HTML

以下のW3Cから抜粋した序文を紹介します。

This specification describes the method for enabling the author to define and use new types of DOM elements in a document.– W3C:Custom Elements

序文では、新しいDOM要素を定義して使用する方法と述べています。
HTMLは既知のとおり、Webページの文書構造を説明するために生まれた言語で、現代のWebアプリケーションを作成するためにコンポーネントとして使用するには不自然です。私たちがアプリケーションを作成するときに必要なコンポーネントの定義がHTML標準に含まれていないからです。

div中毒

Webアプリケーションを作成する際、最もよく使用しているタグは、divタグでしょう。

なぜdivを使うのでしょうか?
他の方法も参照しましたが、HTML標準に従うため解決策にはなりませんでした。似たような用途に使われるspanを作っても問題ありませんが、インラインタグの中にブロックタグを入れるのはHTMLスペックを無視することになるので、最終的にどのようなコンポーネントであれ、ブロックタグであるdivに集約されます。

Authors are strongly encouraged to view the div element as an element of last resort, for when no other element is suitable. – W3C HTML5.1:The div element 
いずれのエレメントも相応しくないのでdivを使用する。

既存のHTML標準は、div以外に適当な解決策を施していません。以下はカスタム要素を使って、適切な名前を持つようになったマークアップのサンプルです。

<div class="user-profile">
    <div class="layout card small">...</div>
</div>

<user-profile>
    <card-layout type="small">...</card-layout>
</user-profile>

JavaScript – HTML:マリオネット人形劇


div人形を使ったJavaScriptが演出する人形劇- Polymer 2.0:Under the Hood – Rob Dodson

上述のとおり、divエレメントはJavaScriptと関連がありません。言い換えれば、エレメントはJavaScriptに気づかず、JavaScriptがエレメントを調整して操り人形のように演出します。下のコードを見てみよう。

// JavaScriptコントロールを束ねるエレメントを取得する必要がある。
constructor(el) {
    this._el = el;
}
...
// このクラスインスタンスが維持するエレメントのinnerText
this._el.innerText = 'text';
...
// クラスインスタントとエレメントのライフサイクルは異なる。
// bootstrap
document.addEventListener('DOMContentLoaded', () => {
    CurrentTime.create(document.querySelector('.current-time'));
}, false);
// finalize
this._disconnectedObserver = new MutationObserver(mutations => { ... this.dispose() ... });
this._disconnectedObserver.observe(this._el.parentNode, {
    childList: true
});

あまりに慣れ親しんでいるので当然のように感じるかもしれませんが、以下のカスタム要素を定義するコードと比較してどのような点が異なるのか、探してみよう。

上記のコードでは、JavaScriptにエレメントをパスするまで互いにどのような関係性もありません。JavaScriptにパスした後もエレメントはプロパティに保存して操作する必要がある三人称の対象である。また、クラスのインスタンスとエレメントがライフサイクルを異にするには常にブートストラップコードが必要で、場合によっては、長いMutationObserverコードを作成する必要があります。

class CurrentTime extends HTMLElement {...}
...
this.innerText = 'text';
...
// connect
connectedCallback() { ... }
// disconnect
disconnectedCallback() { ... }

カスタム要素のコードでは、クラスのインスタンスthisがエレメントのインスタンスそのものです。エレメントとクラスのインスタンスがライフサイクルを共有するには、エレメントのライフサイクルに合わせた任意のブートストラップコードやMutationObserverは必要ありません。最初のコードは私たちが慣れ親しんでいるコードです。そして2番目のコードは見慣れませんが非常に簡潔で自然です。

カスタム要素は、Webアプリケーションを作成するのに必要なHTMLの抜けた部分を、自然にかつ直感的な方法で提供してくれる。

実装

エレメントとクラスを縛る

カスタム要素を登録する方法を調べてみよう。以下は、最も簡単なコードでカスタム要素を登録する方法です。これは、windowCustomElementRegistrycustom-timeタグと指定されたクラスを結ぶ役割をします。

window.customElements.define('current-time', class extends HTMLElement {});
<current-time></current-time>

これで、HTMLで<current-time>タグを使用できるようになりました。何のロジックも与えておらず意味のない状態ですが、current-timeはHTMLで使用できるカスタム要素となりました。

命名規則:-を含めよう

カスタム要素は特別な命名規則を持ちます。簡単に言うと、文字の中に - を1つ以上含める必要があります。
W3C Custom Elements:正しい命名規則

例 : 正しい名前

<tui-editor></tui-editor>
<my-element></my-element>
<super-awsome-carousel></super-awsome-carousel>

例 : 無効な名前

<tuieditor></tuieditor> /* `-` ない */
<font-face></font-face> /* 予約されたタグ名 SVG */
<missing-glyph></missing-glyph> /* 予約されたタグ名 SVG */

このような制約を持つ理由は、HTMLパーサがJavaScriptで宣言されたカスタム要素が分からない状況であっても、カスタム要素かもしれないタグを区別するためです。HTML標準に定義されていなくても、カスタム要素の命名規則に適合しないタグはHTMLUnknownElementインタフェースが割り当てられます。しかし、カスタム要素はHTMLE要素から継承する必要があるため、命名規則に適合したものはHTMLUnknownElementの継承を防ぎます。
WHATWG:タグ名に基づいてHTML要素インターフェースが割り当てられる方法

HTML要素の継承とコンストラクタ

カスタム要素スペックは、基本的にES6クラスを登録するように定義されています(ES5プロパティ形態を使用する方法は後述します)。当然ながらカスタム要素クラスconstructorはその他のES6クラスのconstructorと変わらず動作します。
下記の「yey!」のメッセージは、new CurrentTime()から新しいインスタンスを生成するたびに確認することができます。また、DOMに<current-time>エレメントが追加されるたびにメッセージを表示します。

<current-time></current-time>
class CurrentTime extends HTMLElement {
    constructor() {
        super(); // いつも一番前に

        console.log('yey!');
    }
}
window.customElements.define('current-time', CurrentTime);

ただし、使い方において注意すべき点があります。従来の方法では次のコードのようにconstructorでDOMを操作しがちです。これが可能な理由は、私たちがDocumentの「DOMContentLoaded」イベントを受け取り、DOMがロードされてからクラスを初期化するとき、constructorは実行された時点でエレメントはDOMに付随する状態になっています。したがってconstructorからどのようなDOM操作をしても差支えありません。

class CurrentTime {
    constructor(el) {
        super();

        this._initDOM(el); // DOM操作
    }
}

しかし、HTML要素を継承したカスタム要素のコンストラクタ実行時点では、まだDOMに追加されていない状態です。そのため、次のようにconstructorではいかなるDOM操作もできません。したがって、ここではDOMとは関係ないクラスのインスタンスの準備しかできません。

constructor() {
    super(); // いつも一番前に

    console.log(this.parentNode); // null
    console.log(this.firstChild); // null
    console.log(this.innerHTML); // ""
    console.log(this.getAttribute('locale')); // null
    this.setAttribute('locale', 'ko-KR'); // エラー: Uncaught DOMException: Failed to construct 'CustomElement': The result must not have attributes
    this.innerText = 'Arr'; // エラー: Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children
}

connectedCallback&disconnectedCallback

connectedCallbackdisconnectedCallbackHTML要素を継承した場合、カスタム要素がDOMに追加/削除されるたびに実行されます。このとき、インスタンスオブジェクトは生成/破壊されないので、DOMを変更することで何回も実行できます。
また、一度作成されたオブジェクトは自動で破壊されないため、disconnectedCallbackで適切にインスタンスオブジェクトを整理する作業が必要です。
connectedCallback実行時には、このインスタンスがDOMに追加された後であるため、DOMを操作する作業はここで行うのが適当です。下のコードを実行すると、予想通り属性/親/子の値を変更できます。

なお、connectedCallbackが実行された時点で当該要素がDOMに追加されていますが、子要素にはまだDOMに追加されていません。したがって、子要素を変更できても、HTML形式で挿入された子要素にアクセスできないことに注意しましょう。

別途、子要素にアクセスする方法は後で説明します。

<current-time locale="ko-KR">
    자식!
</current-time>
class CurrentTime extends HTMLElement {
    ...
    connectedCallback() {
        // このエレメントはDOMに追加された。
        console.log(this.parentNode); // ok "<body></body>"
        console.log(this.firstChild); // null <--- まだ子要素にはアクセスできない。
        console.log(this.innerHTML); // "" <--- まだ子要素にはアクセスできない。
        console.log(this.getAttribute('locale')); // ok "ko=KR"
        this.setAttribute('locale', 'en-US'); // ok
        this.innerText = 'Arr'; // ok
    }
    ...
    disconnectedCallback() {
        // このエレメントがDOMから除去された。
        // connectedElementで実行したセット作業を整理しよう。
    }
}
window.customElements.define('current-time', CurrentTime);

このコールバックメソッドは、MutationObserverchlidListを通じて処理する方法より簡潔で直感的な方法を提供します。下のMutationObserverからの方式と比較してみよう。

...
this._disconnectedObserver = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
        if (mutation.type === 'childList' &&
            Array.prototype.slice.call(mutation.removedNodes).indexOf(this._el) >= 0) {
            ...
        }
    });
});
this._disconnectedObserver.observe(this._el.parentNode, {
    childList: true
});
...

MutationObserverはIE11以降に対応し、同様の機能を提供するMutationEventsは対応外となりました。カスタム要素もIE11からサポートしている点を考慮すると、MutationObserverを使うプロジェクトでは、カスタム要素の導入を積極的に考慮してみてもよいと思われます。
Mutation Eventsは性能にも問題がある- Mutation Events Replacement:The story so far / existring points of consensus

attributeChangedCallback&observedAttributes

attributeChangedCallbackは上記のコールバックと同様、MutationObserverattributesを監視する機能を持ちます。与えられた名前でメソッドを登録しておくと属性が変更されるたびにコールバックメソッドが実行されます。重要なのは、observedAttributesで興味のある属性名をリスト化しておくという点です。

...
static get observedAttributes() {
    // モニタリングする属性名
    return ['locale'];
}

attributeChangedCallback(attrName, oldVal, newVal) {
    // 属性が追加/削除/変更された。
    this[attrName] = newVal;
}
...

adoptedCallback

このコールバックメソッドは、エレメントが別のDocumentから運ばれてくるときに実行されます。document.adoptNode()が当該エレメントを対象に実行されたとき、呼び出されると説明されています。

adoptedCallback(oldDoc, newDoc) {
    // 他のDocumentから移動してきた
}

これまでカスタム要素スペックを基準に1つずつ実装に必要な内容を説明しました。カスタム要素が新しい概念を取り入れるには馴染みがなく困難に思われるかもしれません。しかし上記の内容をまとめると、決して多くありません。

  • window.customElements.define:タグ名とクラスを接続する
  • タグ名には、必ず複数の-を含めるべき
  • constructor:インスタンスの生成であり、DOM操作はできない
  • connectedCallback / disconnectedCallback:エレメントがDOMに追加/削除された。DOM操作を操作できる
  • attributeChangedCallback / observedAttributes:興味のある属性を監視できる
  • adoptedCallback:忘れよう

対応ブラウザとPolyfill

対応ブラウザ

Polyfill

モバイルを含めIE11以上の最新ブラウザでPolyfillを使用すると、カスタム要素を使用できます。webcomponents-loader.jsを使うと簡単にアクセスできます。プロジェクトのソースをBabelで変換している場合は、custom-elements-es5-adapter.jsが追加されます。
Googleスタイルというべきか、Polyfillの使用において複数のオプションを提供していますが、大きく以下の3つの方法で解決できます。

  • Custom Elements Polyfill : Custom Elements Polyfillのみ
  • webcomponentjs concatenatedのPolyfillは若干複雑な形を持っているが、webcomponents-hi-ce.jswebcomponents-hi-sd-ce.jsなどのカスタム要素の略であるceが含まれるファイルを使うか、すべてのPolyfillを含むwebcomponents-lite.jsを使用すればよい。
  • webcomponentsjs loader : ダイナミックで必要なものだけロードするPolyfill loader

しばしば対応ブラウザを拡張するためにES5ではJavaScriptを変換しますが、カスタム要素標準はcustomElements.defineにES6クラスを要求します。このような場合、custom-elements-es5-adapter.jsをさらにロードする必要があります。
custom-elements-es5-adapter.jsはネイティブブラウザでカスタム要素をサポートしますが、JavaScriptファイルをES5に変換した場合に必要で、IE11では必要ないでしょう。

もう少し詳しく

これから上記で調べた主要スペックに加えて、実際のカスタム要素を実装するときに参考になる内容を紹介します。

アップグレード

カスタム要素をcustomElements.defineを使って定義しましたが、DOMでは使われない場合があります。しかし、一度定義すると、後でDOMにその名前のエレメントが追加される場合でも、すぐに指定されたエレメントを結びつけます。また適切なコールバックメソッドを正常に呼び出します。

逆にDOMにエレメントが存在しても、customElements.defineメソッドを使って宣言していない場合は、DOM要素はspanと同様に動作し、後でcustomElements.defineで宣言すると指定されたクラスに結合します。このような過程をupgradeといいます。

このプロセスは特に重要で、私たちが「DOMContentLoaded」イベントを見てインスタンスを初期化する理由と同様に、DOM作成後にJavaScriptが実行される場合、DOMのカスタム要素はスタイルも動作も持たないまま画面に表示されるためです。

このような場合、:defined pseudoクラスを使って、以下のようにcustomElements.defineを通じて定義されるまでエレメントを区別することもでき、必要に応じてスタイルを適用できます。

current-time:not(:defined) {
    display: hidden;
}

子要素

connectedCallbackメソッドで子要素にアクセスできない件は上述しました。このコールバックメソッドで子要素がDOMに追加されてdefineされたかを知るには若干異なる戦略が必要です。
MutationObserverPromiseなどさまざまな方法がありますが、最も簡単で確実なのは、子要素のconnectedCallbackで親要素のメソッドを実行したり、Custom Eventを発生させるものです。
DOMを修正するconnectedCallbackがこのような特性を持つため、Introducing Custom Elementsの”Asynchronously Defining Custom Elements”の部分で述べられた戦略を読むと参考になります。

class CurrentTimeText extends HTMLElement {
    ...
    connectedCallback() {
        // call parents callback
        this.parentNode.childReady(this);
        // or
        this.parentNode.dispatchEvent(new Event('childReady'));
    }
    ...
}

上の方法が気に入らない方もいるでしょう。次回の記事で紹介するShadow DOMで解決できるので少し待ってください。

window.customElements&CustomElementRegistry

windowが生成されるとき、ブラウザはCustomElementRegistryインスタンスを初期化して、window.customElementsに割り当てます。このオブジェクトのインスタンスメソッドは、次のとおりです。

W3C:CustomElementRegistry

  • window.customElements.define:タグ名にJavaScriptクラスをカスタム要素に登録する
window.customElements.define('current-time', CurrentTime);
  • window.customElements.get:タグ名で定義されたクラスを持ってくる
const CurrentTime = window.customElements.get('current-time');
const anotherTime = new CurrentTime();
  • window.customElements.whenDefined:タグ名のカスタム要素が登録されたときのイベントを受け取る
customElements.whenDefined('current-time').then(() => {
    console.log('current-time defined!');
});

Fallback strategy

<current-time>
    /* fallback html */
    13:00
</current-time>

カスタム要素を理解しないIE8〜10のレガシーブラウザで<current-time>カスタムタグを持つ要素は、HTMLUnknownElementインタフェースを所有し、innerHTMLをそのままレンダリングすることになります。また、HTMLUnknownElementもスタイルを与えることができるので、従来のブラウザを考慮する必要がある環境では、spanと考えてfallback htmlを子として持つのが望ましいでしょう。

これは、Githubで使用しているカスタム要素の失敗戦略ですが、動的にカスタム要素のコンテンツが変化しなくても、ページがサーバーを介してリフレッシュされるときに、サーバーからレンダリングした適切なコンテンツを表示できるようにする方法です。

Autonomous custom elements vs Customized built-in elements

Autonomouse custom elementsHTML要素を継承するカスタム要素の形態で、Customized built-in elementsは、HTML標準に定義されたdivinput tableなどのビルトイン要素を継承しています。

ビルトインエレメントを継承する理由は、当該エレメントの動作をそのまま継承しつつ、その他の機能やスタイルを追加するためです。ただし若干煩わしい文法を要求します。

<button is="current-time">6/11/2017, 11:55:49</button>
class CurrentTime extends HTMLButtonElement {
    ...
}
customElements.define('current-time', CurrentTime, {extends: 'button'});

HTMLではisがあり、クラスextendsHTMLButtonElementを継承することを明示するにもかかわらず、再びcustomElements.defineでボタンを継承していることを明示されます。これは本当にベストな状態でしょうか?

標準に従うと、HTMLとJavaScriptの生態系を考慮した止むを得ない文法とのことで、HTMLパーサやJavaScriptパーサがブラウザのみに存在するものではなく、buttonが各パーサにbuttonとして認識されるためには、これらの文法が必要となります。Autonomouse custom elementsが必ず-を含み、HTML要素を継承する明示的な方法であるのに対し、HTMLパーサで(ブラウザではなく)Customized built-in elementsは、buttonあるいはtableなどで継承したエレメントとして認識されるべきという理由です。

おまけ:HTMLElement & HTMLUnknownElement

実際、HTML標準で定められた名前以外のタグを使用するのは、カスタム要素標準ではなくても以前から可能でした。
HTML標準によると、タグ名に基づいて適切なHTMLElementが下位インターフェイスに接続し、標準名のいずれにも適合しないタグにはHTMLUnknownElementインターフェースを割り当てます。 HTMLUnknownElementが何かエラーのように感じられるかも知れませんが、HTMLSpanElementと変わりません。

HTMLSpanElementHTMLElementを継承し、display属性としてinline値を持つインタフェースですが、HTMLElementdisplayの基本的な属性値がinlineであるため、同様にHTMLElementを継承するHTMLUnknownElementHTMLSpanElementと変わりません。つまり、<lol>タグは<span>タグと変わりません。カスタム要素標準の前から、特別な名前の<random><lol>などに適切なスタイルを割り当てて、いくらでも使用できました。

Determine HTML5 Element Support in Javascript:HTMLUnknownElementで、HTMLUnknownElementの楽しい視点を紹介しています。

おわりに

次回は、Webコンポーネントの第2の基準、Shadow DOMについて紹介します。Webコンポーネントの標準は単独でも使えますが、一緒に使うとさらなる相乗効果が期待されるため、カスタム要素にShadow DOMを結合して、どのように活用できるか期待してください。

参照

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop