uhyohyo.net

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

十七章第五回 Shadow DOM 2

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

今回も、前回紹介したShadow DOMについての解説です。Shadow DOMにはまだ紹介していない便利機能があります。

template要素

しかしその前に、HTML5のtemplate要素について紹介しておきます。これはCustom Elementsを使うときに便利な要素です。

template要素は、別のところで使いたいHTMLの断片をあらかじめ書いておくことができる要素です。template要素の中身はその場で表示するためにあるわけではないので、ページの表示時にはtemplate要素の中身は無視されます。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <p>これはテストです。</p>
    <template id="mytmp">
      <p>テンプレートの中身です。</p>
      <p>テンプレートの中身その2です。</p>
    </template>
    <script>
      var elm = document.getElementById('mytmp');
      console.log(elm.hasChildNodes()); // false
      console.log(elm.content); // #document-fragment
    </script>
  </body>
</html>

この例を表示すると、ページには「これはテストです。」とだけ表示されてtemplate要素の中身は表示されないことが分かります。

実際、15行目でチェックしているように、template要素は子要素が無い状態になっています。template要素は特殊な要素であるため、中に書かれている構造が子要素にならないのです。子要素ではないということは、template要素の中身は真の意味で、文書中には存在しないということになります。よって、例えばdocument.getElementsByTagNameなどでも見つけることはできません。

その代わり、template要素のノード(HTMLTemplateElement)はcontentプロパティを持ちます。これはDocumentFragmentであり、実はtemplate要素の中身はここに入っています。具体的に言えば、上の例でいうelm.contentの中身は以下に相当する構造になっているわけです。


<p>テンプレートの中身です。</p>
<p>テンプレートの中身その2です。</p>

template要素の機能はこれだけです。では、これをCustom Elements及びShadow DOMと組み合わせてみましょう(サンプル)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <p>これはテストです。</p>
    <my-element></my-element>

    <template id="mytmp">
      <p>テンプレートの中身です。</p>
      <p>テンプレートの中身その2です。</p>
    </template>
    <script>
      class MyElement extends HTMLElement {
        constructor(){
          super();

          // template要素を取得
          const tmp = document.getElementById('mytmp');
          // その中身をコピー
          const content = tmp.content.cloneNode(true);

          // ShadowRootを作って中身を追加
          this.attachShadow({
            mode: 'open',
          }).appendChild(content);
        }
      }

      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

ポイントは、ShadowRootの中身を作るところです(25–28行目)。前回はinnerHTMLを使ってセットしていましたが、今回は違います。代わりに、template要素の中身をcloneNodeによりコピーして、ShadowRootに追加しています。いちいちcloneNodeを使う理由は、my-element要素が複数回作られるかもしれないため、もとのデータ(template要素のcontentプロパティ)はそのまま残しておきたいからです。

これは、innerHTMLを文字列でベタ書きするよりも良いですね。第一に、コンストラクタのコードが短くなります。innerHTMLを使う場合はHTML文字列を全部コンストラクタ内に書く必要がありますが、この方法ならデータ量がどれだけだろうと上のコードと同じような記述で済みます。

また、パフォーマンス上の利点もあります。innerHTMLの場合はデータが文字列なので、innerHTMLにそれが代入されるとまずは文字列をHTMLとして解析してDOMの木構造を作る必要があります。対して、template要素の中身は既に解析されており、木構造として保持されています。先ほどから述べているように、template要素のcontentプロパティにDocumentFragmentとして保持されているわけですね。これをコピーして持ってくるということは、既に木構造の形で存在しているものをそのままコピーするだけで良いため、文字列を解析するよりも高速に行うことができるのです。

以上がtemplate要素とCustom Elements, Shadow DOMの組み合わせ方です。今後はこれを使っていきましょう。

slot要素

では、Shadow DOMの話に戻ります。今までのサンプルでは、Shadow DOMを用いる時点でカスタム要素の(普通の木構造の上での)中身は無視されていました。例えば、上の例を次のように変えても表示は何も変わりません。


<my-element>
  <p>ここは無視されます!!!!!😠😠😠</p>
</my-element>

しかし、実際のHTML要素は、その中身が無視される(というより中身を書かない)要素もあるものの、多くの要素はそうではありません。我々もそういう要素を作りたいですね。それを実現してくれるのがスロットの仕組みです。スロットは、shadowツリーの中にslot要素を配置することにより作ります(サンプル2)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>slot要素のサンプル</title>
  </head>
  <body>
    <p>これはテストです。</p>
    <my-element>
      <p>my-elementの中身です。</p>
    </my-element>

    <template id="mytmp">
      <p>↓↓↓ここにスロットがあります。</p>
      <slot></slot>
      <p>↑↑↑ここにスロットがあります。</p>
    </template>
    <script>
      class MyElement extends HTMLElement {
        constructor(){
          super();

          const tmp = document.getElementById('mytmp');
          const content = tmp.content.cloneNode(true);

          this.attachShadow({
            mode: 'open',
          }).appendChild(content);
        }
      }

      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

サンプル2を表示すると、次のように表示されているはずです。


これはテストです。

↓↓↓ここにスロットがあります。

my-elementの中身です。

↑↑↑ここにスロットがあります。

これが意味していることは、my-elementの子要素がスロットの位置に表れているということです。

このように、shadowツリーの中にスロットを配置することで、本来の(shadowツリーではない方の)子要素をスロットの位置に配置することができます。ちなみに、shadowツリーではない本来の子孫たちのことは、shadowツリーと対比してlightツリーと呼ばれます。

さて、スロットがあれば、少しは意味のあるカスタム要素が作れそうですね。例えば、次の例は注意文を表示するカスタム要素x-warningを定義しています(サンプル3)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>slot要素のサンプル2</title>
  </head>
  <body>
    <x-warning>
      <p>注意文です!!!!!!!!</p>
    </x-warning>

    <template id="warning-tmp">
      <style>
        section.wrapper {
          background-color: #ffffe3;
          color: #666600;
          border: 10px dashed #444400;
          padding: 20px;
        }
      </style>
      <section class="wrapper">
        <h1>注意</h1>
        <slot></slot>
      </section>
    </template>
    <script>
      class Warning extends HTMLElement {
        constructor(){
          super();

          this.attachShadow({
            mode: 'open',
          }).appendChild(document.getElementById('warning-tmp').content.cloneNode(true));
        }
      }

      customElements.define('x-warning', Warning);
    </script>
  </body>
</html>

このx-warning要素はCSSを用いて注意文を注意っぽい見た目で表示しています。また、「注意」という見出しも付いていますね。

ここで注意してほしいのが、上の例におけるx-warning要素の子要素はあくまでp要素ひとつであるということです。(改行のテキストノードを除けば。)Shadow DOMによって、その子要素がどのように料理されるかがカスタマイズされているのです。そして、子要素をどこで使うのかを示すのがslot要素です。

また、x-warning要素の中身をあとから書き換えた場合、x-warning要素の表示もそれに追随して変わります(ぜひ自分で試してみてください)。このことは、slot要素はあくまでx-warning要素の子要素をそこに直接当てはめているのであり、要素がshadowツリー内にコピーされているとかではないということを示しています。

複数のスロットを扱う

実は、shadowツリーの中には複数のスロットを作ることができます。これによりカスタム要素の表示を外からよりカスタマイズできます。

例えば、上の例では「注意」という見出しは固定でしたが、ここもカスタマイズしたいとしましょう。そのためには、「注意」に該当するスロットを作ります。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>slot要素のサンプル3</title>
  </head>
  <body>
    <x-warning>
      <p>注意文です!!!!!!!!</p>
    </x-warning>

    <template id="warning-tmp">
      <style>
        section.wrapper {
          background-color: #ffffe3;
          color: #666600;
          border: 10px dashed #444400;
          padding: 20px;
        }
      </style>
      <section class="wrapper">
        <h1><slot name="title">注意</slot></h1>
        <slot></slot>
      </section>
    </template>
    <script>
      class Warning extends HTMLElement {
        constructor(){
          super();

          this.attachShadow({
            mode: 'open',
          }).appendChild(document.getElementById('warning-tmp').content.cloneNode(true));
        }
      }

      customElements.define('x-warning', Warning);
    </script>
  </body>
</html>

22行目が新しく作ったスロットです。ここでは2つの新要素が登場しました。

ひとつは、slot要素のname属性です。このようにスロットには名前を付けることができます。複数のスロットがある状況では、このようにスロットに名前を付けてスロットを区別してあげる必要があります。

もうひとつは、slot要素に子がある点です。今回の場合、子は従来の見出しである「注意」というテキストです。

まず、カスタム要素の子要素がスロットに入ると先ほど述べましたが、正確にはshadowツリーの中のデフォルトスロットに入ります。デフォルトスロットとは、name属性により名前が付けられていないスロットです。(もし名前のないスロットを複数作ってしまった場合は一番最初のものとなります。)

それ以外のスロットに入るものを指定するには、次の例のように明示的に指定する必要があります(サンプル4)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>slot要素のサンプル3</title>
  </head>
  <body>
    <x-warning>
      <span slot="title">ちゅうい</span>
      <p>注意文です!!!!!!!!</p>
    </x-warning>

    <template id="warning-tmp">
      <style>
        section.wrapper {
          background-color: #ffffe3;
          color: #666600;
          border: 10px dashed #444400;
          padding: 20px;
        }
      </style>
      <section class="wrapper">
        <h1><slot name="title">注意</slot></h1>
        <slot></slot>
      </section>
    </template>
    <script>
      class Warning extends HTMLElement {
        constructor(){
          super();

          this.attachShadow({
            mode: 'open',
          }).appendChild(document.getElementById('warning-tmp').content.cloneNode(true));
        }
      }

      customElements.define('x-warning', Warning);
    </script>
  </body>
</html>

今回、x-warning要素の子要素がひとつ増えました。それは9行目のspan要素です。注目すべきは、このspan要素にslot属性が付いているという点です。slot属性によりスロット名が指定されている場合、その要素はデフォルトスロットに入る代わりに指定した名前のスロットに入ります。よって、この場合「ちゅうい」というテキスト(正確にはそのテキストを含むspan要素)がtitleスロットに入ります。そのあとのp要素はslot属性が指定されていないため、デフォルトスロットに入ります。

なお、下の例のように、同じスロットに複数の要素を入れることができます。


<x-warning>
  <span slot="title">ちゅうい</span>
  <span slot="title">ちゅうい2</span>
  <span slot="title">ちゅうい3</span>
  <p>注意文です!!!!!!!!</p>
</x-warning>

こうすると見出しは「ちゅういちゅうい2ちゅうい3」となるでしょう。

また、スロットはlightツリーの一番上(つまり、カスタム要素の直接の子要素)にある必要があります。次のような位置にslot属性を持つ要素を配置しても無意味です。


<x-warning>
  <p>注意文です!!!!!<span slot="title">!!!</span></p>
</x-warning>

そして、スロットの子は、そのスロットに対して中身が指定されなかったときに表示される値、すなわちスロットのデフォルト値です。上の例では、titleスロットの子は「注意」というテキストだったので、titleスロットに何も与えられなかった場合は見出しとしてデフォルトの「注意」が表示されることになります。

以上がスロットの使い方です。まとめると、カスタム要素はshadowツリーの中にスロットを用意することで、その要素に与えられた中身を利用することができます。また、スロットは複数用意することができ、デフォルトスロット以外に要素を入れたいときは対応するslot属性を使う必要があります。

スロットとCSS

前回、Shadow DOMとCSSの関係を見ました。前回の解説によれば、shadowツリーの中と外のスタイル定義は互いに影響を及ぼさないのでした。今、スロットを用いてshadowツリーの外の要素をshadowツリーの中に差し込むことができるようになりました。では、スロットに渡された要素は中なのでしょうか、それとも外なのでしょうか。

答えは外です。カスタム要素の子として存在する要素は、あくまで外の世界の住人です。スロットによりshadowツリーの中に差し込まれたとしても、それは依然として外の世界の存在なのです。そう考えると、スロットというのは外の世界の存在をその位置に招待するというようなイメージですね。

そのことを確かめてみましょう(サンプル5)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>スロットとCSSのサンプル</title>
    <style>
      p.foo { color: red; }
    </style>
  </head>
  <body>
    <my-element>
      <p class="foo">私は外の世界の住人です。</p>
      <p class="bar">私も外の世界の住人です。</p>
    </my-element>
    <template id="mytmp">
      <style>
        p.bar { color: blue; }
      </style>
      <slot></slot>
    </template>
    <script>
      class MyElement extends HTMLElement {
        constructor(){
          super();

          this.attachShadow({
            mode: 'open',
          }).appendChild(document.getElementById('mytmp').content.cloneNode(true));
        }
      }
      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

このサンプルでは、1つ目のp要素(12行目)は赤色で表示され、2つ目のp要素はデフォルトの色(普通は黒色)で表示されるはずです。

これが意味することとして、まず1つ目のp要素には外の世界のCSS定義(6–8行目)が適用されていることが分かります。1つ目のp要素が赤色になったのは7行目のスタイル定義のせいですね。

一方、shadowツリーの中のスタイル定義(16–18行目)は2つ目のp要素(13行目)には適用されていないことが分かります。つまり、2つ目のp要素はスロットに入るとはいえ、依然としてshadowツリーの中のCSSの影響は受けないということです。

この結果はわりと直感的なのではないかと思います。今回の2つのp要素はあくまでmy-elementを使う側(外の世界)の所有物なので、外の世界によってスタイルが決められるのです。shadowツリーの中のスタイル定義の影響を受けるのはshadowツリーの中の存在だけです。

以上の説明で、カスタム要素をかなり自由に作れるようになったのではないかと思います。あとひとつ紹介すべきことがあるのですが、それは次回にしましょう。