uhyohyo.net

JavaScript初級者から中級者になろう

十七章第三回 Custom Elements

このページの最終更新日:

今回から、いわゆるWeb Componentsに関する話を解説します。これは、ウェブページの構築に使える再利用可能なパーツを作るための方法です。再利用可能なパーツというのは、ある機能を簡単に使える仕組みです。例えば、ボタンはbutton要素を用いて簡単に作ることができます()。このボタンをbutton要素(やinput要素)に頼らずに作ろうとすると結構大変ですよね。ボタンの見た目から始まり、押したときの挙動もちゃんと作りこむ必要があるでしょう。これを作りこんでいるのはブラウザ側ですが、我々はそれを<button>ボタン</button>と書くだけで簡単に使うことができます。もしそれでも自分で何かを作りこまないといけない場合は、作ったものはbutton要素のように簡単に使えるようにするのが望ましいですね。それが再利用可能ということであり、そのための技術がWeb Componentsです。

Web Componentsはいくつかの機能の組み合わせにより成り立っているとされており、その中のひとつが今回紹介するCustom Elementsです。Custom Elementsはその名の通り、独自のHTML要素を定義する方法です。

なお、現時点(2017年12月)でWeb Components関連の技術に対応しているのはChromeだけです(設定を変更すればFirefoxでも動作します)。この分野はGoogleが先行してきた分野であり、性急であるとして他のブラウザベンダ(特にMozillaとか)からは批判もあるようです。ですので、ブラウザ間の合意ができて実戦投入が可能になるまでには時間がかかるしれませんが、最近とても注目されている技術ですので今のうちに理解しておいて損はないでしょう。

このような事情から、ここに書かれていることが将来的に変わる可能性も残っています。この講座も変更に追随するようにはしますが、自身でも情報を集めるようにするのがよいでしょう。

カスタム要素の定義

ではCustom Elementsの解説に入ります。Custom Elementsでは、再利用可能なパーツはカスタム要素として定義します。これはいわば独自のHTML要素です。まずはこのカスタム要素を定義する方法をまず紹介します。

皆さんは、HTML要素はHTMLElementというオブジェクトによって表されていることを既にご存知だと思います。


console.log(document.body instanceof HTMLElement); // true (body要素はHTMLElememtのインスタンス)
console.log(document.body instanceof Element); // true (HTMLElementはElementを継承している)

実は、独自のHTML要素を作るには、HTMLElementを継承したクラスを作ります。これはクラスの構文を使って作る必要があります。クラスの構文を使わずに継承をする方法もありましたが(十一章第七回)、クラスの構文とは細かい違いがあるのでクラスの構文を使わないとうまくいきません。では、そのようなクラスを作ってみましょう。


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

    console.log('MyElement is created');
  }
}

とりあえず作られたらログが出るクラスにしてみました。そして、要素を定義したらそれをカスタム要素として登録する必要があります。そのためには、window.customElementsを用います。このオブジェクトはdefineメソッドを持っており、このメソッドを用いてカスタム要素を登録します。このとき、要素名も決めます。


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

    console.log('MyElement is created');
  }
}

window.customElements.define('my-element', MyElement);

このように、第1引数に要素名、第2引数名にクラス(コンストラクタ)を渡します。この例では、今作ったMyElementがmy-elementという名前で登録されたことになります。

要素名にはいくつか制限があります。まず、要素名はハイフンマイナス(-)を含む必要があります。ハイフンを含むことで、カスタム要素ではない普通のHTML要素と区別することができます。また、アルファベットの大文字は使えません。これは、HTMLは本来要素名の大文字小文字は関係ないので、定義の上では小文字に統一しておきたいという意図があります。もうひとつの制限として、1文字目はアルファベットである必要があります。逆に言えば、これ以外の制限は緩いです。例えば、タグ名にはアルファベット以外も使うことができます。"x-日本語😂😂😂"などの要素名も可能です。

カスタム要素の使用

とりあえずカスタム要素が定義・登録できました。できた要素は使ってみましょう。


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

    console.log('MyElement is created');
  }
}

customElements.define('my-element', MyElement);

// document.createElementを使って要素を作る
var elm = document.createElement('my-element');

console.log(elm instanceof MyElement); // true
console.log(elm.tagName); // "MY-ELEMENT"

document.body.appendChild(elm);

注目すべき点は、上のサンプルを実行すると、document.createElementを実行した時点でコンソールにMyElement is createdとログが表示される点です。さらに、document.createElementの返り値はMyElementのインスタンスになっています。このサンプルでは作った要素をbodyに追加しています。実際body要素を調べると、最後に<my-element></my-element>という要素が追加されています。

なお、カスタム要素もHTML要素の一種(HTMLElementを継承しているし)ということで、tagNameとか、普通のHTML要素が持つプロパティはちゃんと持っています。tagNameプロパティを参照すると"MY-ELEMENT"となるはずです。なお、タグ名が全部大文字になっているのはDOMの慣習です。

ちなみに、カスタム要素のインスタンスはdocument.createElementを使わずに、コンストラクタをnewすることでも作ることができます。


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

    console.log('MyElement is created');
  }
}

customElements.define('my-element', MyElement);

document.body.appendChild(new MyElement());

このようにしてもbody要素に<my-element></my-element>が追加されていることがわかります。

HTMLからの使用

実は、登録したカスタム要素はdocument.createElementとかを用いてJavaScriptから要素を作るほかに、HTMLから利用することができます。やや長いですが、このサンプルを見てください。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <my-element id="elm">
      <p>これはテストです。</p>
    </my-element>
    <script>
      class MyElement extends HTMLElement{
        constructor(){
          super();

          console.log('MyElement is created');
        }
      }

      customElements.define('my-element', MyElement);

      var elm = document.getElementById('elm');

      console.log(elm instanceof MyElement); // true
    </script>
  </body>
</html>

HTML文書中でmy-element要素が使われています。script要素中では、今までと同様にmy-element要素を登録した後、文書中のmy-element要素をdocument.getElementByIdで取得しています。これはやはりMyElementのインスタンスになっています。

このように、登録したカスタム要素はHTML中で使用されているものにもちゃんと適用されます。

ただ、よく考えるとこれは不思議ですね。HTML文書は上から順番に読みこまれるため、文書中でmy-elementが使われた時点ではまだmy-elementがどんな要素なのか不明です。そのため、実際にはmy-elementは一時的に謎の要素として扱われ、customElements.defineにより登録された時点でそのカスタム要素に置き換わります。

この挙動は次のサンプルで見ることができます。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <my-element id="elm">
      <p>これはテストです。</p>
    </my-element>
    <script>
      class MyElement extends HTMLElement{
        constructor(){
          super();

          console.log('MyElement is created');
        }
      }

      var elm = document.getElementById('elm');
      console.log(elm instanceof MyElement); // false

      customElements.define('my-element', MyElement);

      console.log(elm instanceof MyElement); // true
    </script>
  </body>
</html>

今回はカスタム要素を登録する前にmy-element要素を取得しています。このときは当然ながらこの要素は謎の要素であり、MyElementのインスタンスではありません。ところが、customElements.defineを呼び出すとMyElementのインスタンスに変化しました。既に取得した要素も変化するというのは興味深いというか面白いというかやばい挙動ですね。

なお、この例やその前の例ではmy-element要素に対して特別な挙動を設定しているわけではないため、my-element要素にとくに意味はありません。上のサンプルでは中にp要素がありますが、普通にそれが表示されています。

カスタム要素の挙動を決める

以上で、カスタム要素の作り方と使い方が分かりました。しかし、今のままではせっかくカスタム要素を作っても何の意味もありません。そこで、次はカスタム要素の挙動をカスタマイズしてみましょう。

実は、カスタム要素のクラスに特定のメソッドを定義しておくことで、ある状況での挙動を設定できます。例えば、connectedCallbackメソッドは、その要素が文書に挿入されたときに呼び出されます(引数無し)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <my-element id="elm">
      <p>これはテストです。</p>
    </my-element>
    <script>
      class MyElement extends HTMLElement{
        connectedCallback(){
          this.textContent = "hello";
        }
      }
      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

このページを開くと、my-elementの中身が「これはテストです。」ではなく「hello」となります。これは、connectedCallbackが呼び出されたことで中身が書き換えられたからです。

この例では、customElements.defineが呼び出された時点でmy-elementの実態であるMyElementが作られます。そして、それは既に文書中に追加されているので即座にconnectedCallbackが呼ばれるという流れになります。

基本的には、カスタム要素の表示を制御するのはconnectedCallbackメソッドで行うのがよいでしょう。

他のメソッドとしては、文書中からノードが取り除かれたときに呼ばれるdisconnectedCallbackメソッドや、ノードがある文書から他の文書に移管されたときに呼ばれるadoptedCallbackメソッド、そして要素の属性が変更されたときに呼ばれるattributeChangedCallbackメソッドがあります。

特に、最後のattributeChangedCallbackはconnectedCallbackと並んでよく使うメソッドかもしれません。そこで、このメソッドの使い方も見ておきます。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <my-element id="elm" content="Hello">
      <p>これはテストです。</p>
    </my-element>
    <script>
      class MyElement extends HTMLElement{
        attributeChangedCallback(name, oldValue, newValue){
          if (name === 'content'){
            this.textContent = newValue || '';
          }
        }
      }
      MyElement.observedAttributes = ['content'];
      customElements.define('my-element', MyElement);

      // 5秒後に属性を変更
      setTimeout(()=> {
        document.getElementById('elm').setAttribute('content', 'Hi');
      }, 5000);
    </script>
  </body>
</html>

実は、このメソッドを利用するには先に監視対象の属性を設定しておきます。それが20行目です。このように、MyElementの静的プロパティとしてobservedAttributesを定義しておきます。これは文字列の配列であり、各要素が監視対象の属性名です。監視対象となっている属性に変化があったときにattributeChangedCallbackが呼ばれます。

このメソッドは常に引数3つで呼ばれます。第1引数が変化のあった属性名、第2引数が変化前の値、第3引数が変化後の値です。第2引数・第3引数は属性の値なので文字列ですが、属性が存在しないことを表すnullとなることもあります。例えば、今まで存在しなかった属性が新しく作られた場合は第2引数がnullとなります。属性が消された場合は第3引数がnullとなります。すでに存在する属性が変更された場合は第2引数・第3引数とも文字列となります。

上の例では、contentという属性が変化した場合にtextContentをそれに変化させています。ページを開くとまずmy-elementの中身はHelloとなっており、5秒後にHiに変化します。

このことから分かるように、要素が作られた時点ですでに属性があった場合もちゃんとattributeChangedCallbackが呼ばれます。これは助かりますね。要素が作られたときとそれ以外のときで処理を別々に書く必要がありません。

なお、observedAttributesという静的プロパティを作るのに上の例(20行目)のようにするのは少し格好悪いですね。クラスの構文では(今のところ)静的プロパティを作る構文はありませんが、静的メソッドを作る構文はあるのでそれをgetメソッドにすれば間接的に実現できます。すなわち、次のようにすることもできます。


class MyElement extends HTMLElement{
  static get observedAttributes(){
    return ['content'];
  }
  attributeChangedCallback(name, oldValue, newValue){
    if (name === 'content'){
      this.textContent = newValue || '';
    }
  }
}

これがカスタム要素の基本的な使い方です。ただ、これだけではまだ使い物になりませんね。例えば、上の例のmy-elementは常に中身がcontent属性と一致する要素のように思えますが、手動でいくらでも中身をいじることができます。これはなんだか不安定ですね。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <my-element id="elm" content="Hello">
      <p>これはテストです。</p>
    </my-element>
    <script>
      class MyElement extends HTMLElement{
        attributeChangedCallback(name, oldValue, newValue){
          if (name === 'content'){
            this.textContent = newValue || '';
          }
        }
      }
      MyElement.observedAttributes = ['content'];
      customElements.define('my-element', MyElement);

      // 中身を書き換える
      document.getElementById('elm').textContent = 'Foo!';
    </script>
  </body>
</html>

それ以外にも、今回紹介した機能だけではなんだか機能が足りないように思います。そもそも、Web Componentsの目的は再利用可能なパーツを作ることです。上の例では「常にcontent属性が中身に反映される要素」というパーツを作ったことになります。実際はもっとすごく複雑な機能を持ったパーツを作りたいわけですが、今回紹介した諸々だけではとても面倒です。

次回以降もCustom Elementsに関連する話題ですので、お楽しみに。