2021年3月26日

カスタム要素

独自のメソッド、プロパティ、イベントなどを持つ、クラスで記述されたカスタムHTML要素を作成できます。

カスタム要素が定義されると、組み込みのHTML要素と同等に使用できます。

HTMLの辞書は豊富ですが、無限ではありません。<easy-tabs>, <sliding-carousel>, <beautiful-upload>のようなものはありません。必要なその他のタグを考えてみてください。

特別なクラスで定義してから、HTMLの一部であるかのように使用できます。

カスタム要素には2つの種類があります

  1. 自律カスタム要素 – 抽象クラス HTMLElement を拡張した「完全に新しい」要素。
  2. カスタマイズされた組み込み要素HTMLButtonElement などに基づいて、カスタマイズされたボタンのような、組み込み要素を拡張する要素。

最初は自律カスタム要素について説明し、その後、カスタマイズされた組み込み要素に移ります。

カスタム要素を作成するには、ブラウザに、その表示方法、要素がページに追加または削除されたときに何をすべきかなど、いくつかの詳細を伝える必要があります。

これは特別なメソッドを持つクラスを作成することで行われます。メソッドはわずかしかなく、すべてオプションであるため、簡単です。

以下は、メソッドの完全なリストを含むスケッチです

class MyElement extends HTMLElement {
  constructor() {
    super();
    // element created
  }

  connectedCallback() {
    // browser calls this method when the element is added to the document
    // (can be called many times if an element is repeatedly added/removed)
  }

  disconnectedCallback() {
    // browser calls this method when the element is removed from the document
    // (can be called many times if an element is repeatedly added/removed)
  }

  static get observedAttributes() {
    return [/* array of attribute names to monitor for changes */];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // called when one of attributes listed above is modified
  }

  adoptedCallback() {
    // called when the element is moved to a new document
    // (happens in document.adoptNode, very rarely used)
  }

  // there can be other element methods and properties
}

その後、要素を登録する必要があります

// let the browser know that <my-element> is served by our new class
customElements.define("my-element", MyElement);

これで、タグ <my-element> を持つ任意のHTML要素について、MyElement のインスタンスが作成され、前述のメソッドが呼び出されます。JavaScriptで document.createElement('my-element') も実行できます。

カスタム要素名にはハイフン - を含める必要があります

カスタム要素名にはハイフン - を含める必要があります。例えば、my-element および super-button は有効な名前ですが、myelement は有効ではありません。

これは、組み込みのHTML要素とカスタムHTML要素の間で名前の競合が発生しないようにするためです。

例: “time-formatted”

たとえば、HTMLにはすでに日付/時刻用の <time> 要素があります。ただし、それ自体ではフォーマットは行いません。

時刻を適切に、言語対応の形式で表示する <time-formatted> 要素を作成しましょう

<script>
class TimeFormatted extends HTMLElement { // (1)

  connectedCallback() {
    let date = new Date(this.getAttribute('datetime') || Date.now());

    this.innerHTML = new Intl.DateTimeFormat("default", {
      year: this.getAttribute('year') || undefined,
      month: this.getAttribute('month') || undefined,
      day: this.getAttribute('day') || undefined,
      hour: this.getAttribute('hour') || undefined,
      minute: this.getAttribute('minute') || undefined,
      second: this.getAttribute('second') || undefined,
      timeZoneName: this.getAttribute('time-zone-name') || undefined,
    }).format(date);
  }

}

customElements.define("time-formatted", TimeFormatted); // (2)
</script>

<!-- (3) -->
<time-formatted datetime="2019-12-01"
  year="numeric" month="long" day="numeric"
  hour="numeric" minute="numeric" second="numeric"
  time-zone-name="short"
></time-formatted>
  1. クラスには connectedCallback() という1つのメソッドしかありません。ブラウザは、<time-formatted> 要素がページに追加されたとき(またはHTMLパーサーがそれを検出したとき)にそれを呼び出し、組み込みの Intl.DateTimeFormat データフォーマッタを使用します。これはブラウザ全体で十分にサポートされており、適切にフォーマットされた時間を表示します。
  2. 新しい要素を customElements.define(tag, class) で登録する必要があります。
  3. そして、どこでも使用できます。
カスタム要素のアップグレード

ブラウザが customElements.define の前に <time-formatted> 要素を検出した場合、エラーにはなりません。ただし、要素はまだ不明で、標準外のタグと同じです。

このような「未定義」の要素は、CSSセレクター :not(:defined) でスタイルを設定できます。

customElement.define が呼び出されると、「アップグレード」されます。それぞれに TimeFormatted の新しいインスタンスが作成され、connectedCallback が呼び出されます。それらは :defined になります。

カスタム要素に関する情報を取得するには、次のメソッドがあります

  • customElements.get(name) – 指定された name を持つカスタム要素のクラスを返します。
  • customElements.whenDefined(name) – 指定された name を持つカスタム要素が定義されると解決する(値なしで)プロミスを返します。
constructor ではなく connectedCallback でのレンダリング

上記の例では、要素の内容は connectedCallback でレンダリング(作成)されます。

なぜ constructor でないのですか?

理由は簡単です。constructor が呼び出されるとき、それはまだ時期尚早です。要素は作成されていますが、ブラウザはこの段階で属性をまだ処理/割り当てていません。getAttribute の呼び出しは null を返します。そのため、そこでは実際にレンダリングできません。

さらに、考えてみれば、本当に必要になるまで作業を遅らせる方がパフォーマンスの点で優れています。

connectedCallback は、要素がドキュメントに追加されたときにトリガーされます。別の要素に子として追加されただけでなく、実際にページの一部になったときです。したがって、デタッチされたDOMを構築し、要素を作成して、後で使用するために準備できます。それらはページに表示されたときにのみ実際にレンダリングされます。

属性の監視

<time-formatted> の現在の実装では、要素がレンダリングされた後、それ以上の属性の変更は効果がありません。これはHTML要素としては奇妙です。通常、a.href のように属性を変更する場合、変更がすぐに表示されることを期待します。したがって、これを修正しましょう。

属性を監視するには、observedAttributes() 静的ゲッターでそのリストを提供します。このような属性の場合、attributeChangedCallback はそれらが変更されたときに呼び出されます。他の、リストされていない属性についてはトリガーされません(これはパフォーマンス上の理由からです)。

以下は、属性が変更されたときに自動更新される新しい <time-formatted> です

<script>
class TimeFormatted extends HTMLElement {

  render() { // (1)
    let date = new Date(this.getAttribute('datetime') || Date.now());

    this.innerHTML = new Intl.DateTimeFormat("default", {
      year: this.getAttribute('year') || undefined,
      month: this.getAttribute('month') || undefined,
      day: this.getAttribute('day') || undefined,
      hour: this.getAttribute('hour') || undefined,
      minute: this.getAttribute('minute') || undefined,
      second: this.getAttribute('second') || undefined,
      timeZoneName: this.getAttribute('time-zone-name') || undefined,
    }).format(date);
  }

  connectedCallback() { // (2)
    if (!this.rendered) {
      this.render();
      this.rendered = true;
    }
  }

  static get observedAttributes() { // (3)
    return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
  }

  attributeChangedCallback(name, oldValue, newValue) { // (4)
    this.render();
  }

}

customElements.define("time-formatted", TimeFormatted);
</script>

<time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted>

<script>
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
</script>
  1. レンダリングロジックは render() ヘルパーメソッドに移動されます。
  2. 要素がページに挿入されるときに1回呼び出します。
  3. observedAttributes() にリストされた属性の変更については、attributeChangedCallback がトリガーされます。
  4. …そして要素を再レンダリングします。
  5. 最後に、ライブタイマーを簡単に作成できます。

レンダリング順序

HTMLパーサーがDOMを構築するとき、要素は次々と、親が子よりも先に処理されます。例えば、<outer><inner></inner></outer> がある場合、<outer> 要素が最初に作成されDOMに接続され、次に <inner> が接続されます。

これはカスタム要素にとって重要な結果につながります。

たとえば、カスタム要素が connectedCallbackinnerHTML にアクセスしようとすると、何も取得されません

<script>
customElements.define('user-info', class extends HTMLElement {

  connectedCallback() {
    alert(this.innerHTML); // empty (*)
  }

});
</script>

<user-info>John</user-info>

実行すると、alert は空になります。

これはまさに、その段階では子がなく、DOMが未完成であるためです。HTMLパーサーはカスタム要素 <user-info> を接続し、その子に進もうとしていますが、まだ完了していません。

カスタム要素に情報を渡したい場合は、属性を使用できます。それらはすぐに利用できます。

または、本当に子が必要な場合は、ゼロ遅延 setTimeout でそれらへのアクセスを延期できます。

これは機能します

<script>
customElements.define('user-info', class extends HTMLElement {

  connectedCallback() {
    setTimeout(() => alert(this.innerHTML)); // John (*)
  }

});
</script>

<user-info>John</user-info>

これで、HTML解析が完了した後に非同期で実行するため、行 (*)alert は「John」と表示されます。必要に応じて子を処理し、初期化を完了できます。

一方、この解決策も完璧ではありません。ネストされたカスタム要素も setTimeout を使用して自身を初期化する場合、それらはキューに登録されます。外側の setTimeout が最初にトリガーされ、次に内側の setTimeout がトリガーされます。

したがって、外側の要素は内側の要素よりも前に初期化を完了します。

例でそれをデモンストレーションしましょう

<script>
customElements.define('user-info', class extends HTMLElement {
  connectedCallback() {
    alert(`${this.id} connected.`);
    setTimeout(() => alert(`${this.id} initialized.`));
  }
});
</script>

<user-info id="outer">
  <user-info id="inner"></user-info>
</user-info>

出力順序

  1. outer connected.
  2. inner connected.
  3. outer initialized.
  4. inner initialized.

外側の要素が内側の要素 (4) よりも前に初期化 (3) を完了していることを明確に確認できます。

ネストされた要素の準備ができた後にトリガーされる組み込みのコールバックはありません。必要な場合は、自分で実装できます。たとえば、内側の要素は initialized のようなイベントをディスパッチでき、外側の要素はそれらを聞いて反応できます。

カスタマイズされた組み込み要素

<time-formatted> のように、作成した新しい要素には、関連付けられたセマンティクスがありません。それらは検索エンジンには不明であり、アクセシビリティデバイスはそれらを処理できません。

しかし、そのようなことは重要になる可能性があります。例えば、検索エンジンは、実際に時刻を表示していることを知りたいでしょう。また、特別な種類のボタンを作成する場合、既存の <button> 機能を再利用してみませんか?

組み込みHTML要素を、そのクラスから継承することで拡張およびカスタマイズできます。

たとえば、ボタンは HTMLButtonElement のインスタンスなので、それをベースに構築しましょう。

  1. クラスで HTMLButtonElement を拡張する

    class HelloButton extends HTMLButtonElement { /* custom element methods */ }
  2. customElements.define に3番目の引数を提供し、タグを指定します

    customElements.define('hello-button', HelloButton, {extends: 'button'});

    同じDOMクラスを共有する異なるタグが存在する可能性があるため、extends を指定する必要があります。

  3. 最後に、カスタム要素を使用するには、通常の <button> タグを挿入しますが、それに is="hello-button" を追加します

    <button is="hello-button">...</button>

以下は完全な例です

<script>
// The button that says "hello" on click
class HelloButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', () => alert("Hello!"));
  }
}

customElements.define('hello-button', HelloButton, {extends: 'button'});
</script>

<button is="hello-button">Click me</button>

<button is="hello-button" disabled>Disabled</button>

新しいボタンは組み込みボタンを拡張しています。したがって、disabled 属性のような同じスタイルと標準機能を保持します。

参照

まとめ

カスタム要素には2つのタイプがあります

  1. 「自律型」- HTMLElement を拡張する新しいタグ。

    定義スキーム

    class MyElement extends HTMLElement {
      constructor() { super(); /* ... */ }
      connectedCallback() { /* ... */ }
      disconnectedCallback() { /* ... */  }
      static get observedAttributes() { return [/* ... */]; }
      attributeChangedCallback(name, oldValue, newValue) { /* ... */ }
      adoptedCallback() { /* ... */ }
     }
    customElements.define('my-element', MyElement);
    /* <my-element> */
  2. 「カスタマイズされた組み込み要素」- 既存の要素の拡張機能。

    .define の引数がもう1つ必要で、HTML で is="..." が必要です。

    class MyButton extends HTMLButtonElement { /*...*/ }
    customElements.define('my-button', MyElement, {extends: 'button'});
    /* <button is="my-button"> */

カスタム要素はブラウザ間で十分にサポートされています。polyfill が https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs にあります。

タスク

すでに、きれいにフォーマットされた時刻を表示する <time-formatted> 要素があります。

現在の時刻を表示する <live-timer> 要素を作成します。

  1. 内部的には <time-formatted> を使用し、その機能を重複させないようにする必要があります。
  2. 毎秒チック(更新)します。
  3. すべてのチックで、tick という名前のカスタムイベントを生成する必要があり、event.detail に現在の日付を含めます(カスタムイベントのディスパッチの章を参照してください)。

使い方

<live-timer id="elem"></live-timer>

<script>
  elem.addEventListener('tick', event => console.log(event.detail));
</script>

デモ

タスクのサンドボックスを開きます。

ご注意ください

  1. 要素がドキュメントから削除されたときに setInterval タイマーをクリアします。これは重要です。そうしないと、不要になった場合でもチックし続け、ブラウザはこの要素とそれによって参照されるメモリをクリアできなくなります。
  2. 現在の日付は elem.date プロパティとしてアクセスできます。すべてのクラスメソッドとプロパティは、当然要素のメソッドとプロパティです。

サンドボックスで解答を開きます。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点について提案がある場合は、コメントするのではなく、GitHub issue を提出するか、プルリクエストを送ってください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには、<code> タグを使用し、数行の場合は <pre> タグで囲み、10 行以上の場合にはサンドボックス (plnkr, jsbin, codepen…) を使用してください。