2022年9月27日

Shadow DOM スロット、合成

タブ、メニュー、画像ギャラリーなど、多くの種類のコンポーネントは、コンテンツをレンダリングするために必要です。

組み込みブラウザの<select><option>アイテムを期待するように、私たちの<custom-tabs>は実際のタブコンテンツが渡されることを期待する場合があります。そして、<custom-menu>はメニューアイテムを期待する場合があります。

<custom-menu>を使用するコードは次のようになります。

<custom-menu>
  <title>Candy menu</title>
  <item>Lollipop</item>
  <item>Fruit Toast</item>
  <item>Cup Cake</item>
</custom-menu>

…その後、コンポーネントはそれを適切に、指定されたタイトルとアイテムを持つ適切なメニューとしてレンダリングし、メニューイベントなどを処理する必要があります。

どのように実装しますか?

要素の内容を分析して、DOMノードを動的にコピー・並べ替えることができます。それは可能ですが、要素をShadow DOMに移動する場合、ドキュメントのCSSスタイルはそこで適用されないため、視覚的なスタイルが失われる可能性があります。また、コーディングも必要です。

幸いなことに、そうする必要はありません。Shadow DOMは<slot>要素をサポートしており、それはライトDOMからのコンテンツによって自動的に埋められます。

名前付きスロット

簡単な例でスロットの動作を見てみましょう。

ここで、<user-card> Shadow DOMは、ライトDOMから埋められた2つのスロットを提供します。

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <div>Name:
        <slot name="username"></slot>
      </div>
      <div>Birthday:
        <slot name="birthday"></slot>
      </div>
    `;
  }
});
</script>

<user-card>
  <span slot="username">John Smith</span>
  <span slot="birthday">01.01.2001</span>
</user-card>

Shadow DOMでは、<slot name="X">は「挿入ポイント」、つまりslot="X"を持つ要素がレンダリングされる場所を定義します。

その後、ブラウザは「合成」を実行します。ライトDOMから要素を取り出して、Shadow DOMの対応するスロットにレンダリングします。最終的に、データで埋められるコンポーネントという、まさに私たちが望むものが得られます。

スクリプトの後、合成を考慮せずにDOM構造を作成します。

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username"></slot>
    </div>
    <div>Birthday:
      <slot name="birthday"></slot>
    </div>
  <span slot="username">John Smith</span>
  <span slot="birthday">01.01.2001</span>
</user-card>

Shadow DOMを作成したので、#shadow-rootの下にあります。これで、要素にはライトDOMとShadow DOMの両方があります。

レンダリング目的で、ブラウザはShadow DOMの各<slot name="...">に対して、ライトDOMで同じ名前を持つslot="..."を探します。これらの要素はスロット内にレンダリングされます。

結果は「フラット化された」DOMと呼ばれます。

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username">
        <!-- slotted element is inserted into the slot -->
        <span slot="username">John Smith</span>
      </slot>
    </div>
    <div>Birthday:
      <slot name="birthday">
        <span slot="birthday">01.01.2001</span>
      </slot>
    </div>
</user-card>

…しかし、フラット化されたDOMは、レンダリングとイベント処理の目的でのみ存在します。「仮想的な」ものです。このように表示されます。しかし、ドキュメント内のノードは実際には移動されていません!

querySelectorAllを実行すれば簡単に確認できます。ノードは元の場所にまだあります。

// light DOM <span> nodes are still at the same place, under `<user-card>`
alert( document.querySelectorAll('user-card span').length ); // 2

したがって、フラット化されたDOMは、スロットを挿入することでShadow DOMから派生します。ブラウザはそれをレンダリングし、スタイルの継承、イベントの伝播(詳細については後述)に使用します。しかし、JavaScriptはフラット化前と同様にドキュメントを「そのまま」認識します。

トップレベルの子のみがslot="…"属性を持つことができます。

slot="..."属性は、シャドウホスト(この例では<user-card>要素)の直接の子に対してのみ有効です。ネストされた要素では無視されます。

たとえば、ここの2番目の<span>は無視されます(<user-card>のトップレベルの子ではないため)。

<user-card>
  <span slot="username">John Smith</span>
  <div>
    <!-- invalid slot, must be direct child of user-card -->
    <span slot="birthday">01.01.2001</span>
  </div>
</user-card>

ライトDOMに同じスロット名を持つ複数の要素がある場合、それらは1つずつスロットに追加されます。

たとえば、これ

<user-card>
  <span slot="username">John</span>
  <span slot="username">Smith</span>
</user-card>

<slot name="username">に2つの要素を持つこのフラット化されたDOMを与えます。

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username">
        <span slot="username">John</span>
        <span slot="username">Smith</span>
      </slot>
    </div>
    <div>Birthday:
      <slot name="birthday"></slot>
    </div>
</user-card>

スロットのフォールバックコンテンツ

<slot>の中に何かを配置すると、それがフォールバック、「デフォルト」のコンテンツになります。ブラウザは、ライトDOMに対応するフィラーがない場合にそれを表示します。

たとえば、このShadow DOMの断片では、ライトDOMにslot="username"がない場合、Anonymousがレンダリングされます。

<div>Name:
  <slot name="username">Anonymous</slot>
</div>

デフォルトスロット:最初の名前なしスロット

名前のないShadow DOMの最初の<slot>は「デフォルト」スロットです。これは、他の場所にスロットされていないライトDOMのすべてのノードを取得します。

たとえば、ユーザーに関するスロットされていないすべての情報を表示する<user-card>にデフォルトスロットを追加してみましょう。

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
    <div>Name:
      <slot name="username"></slot>
    </div>
    <div>Birthday:
      <slot name="birthday"></slot>
    </div>
    <fieldset>
      <legend>Other information</legend>
      <slot></slot>
    </fieldset>
    `;
  }
});
</script>

<user-card>
  <div>I like to swim.</div>
  <span slot="username">John Smith</span>
  <span slot="birthday">01.01.2001</span>
  <div>...And play volleyball too!</div>
</user-card>

スロットされていないすべてのライトDOMコンテンツが「その他の情報」フィールドセットに入ります。

要素は1つずつスロットに追加されるため、スロットされていない情報の両方がデフォルトスロットに一緒に配置されます。

フラット化されたDOMは次のようになります。

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username">
        <span slot="username">John Smith</span>
      </slot>
    </div>
    <div>Birthday:
      <slot name="birthday">
        <span slot="birthday">01.01.2001</span>
      </slot>
    </div>
    <fieldset>
      <legend>Other information</legend>
      <slot>
        <div>I like to swim.</div>
        <div>...And play volleyball too!</div>
      </slot>
    </fieldset>
</user-card>

メニューの例

それでは、章の最初に述べた<custom-menu>に戻りましょう。

スロットを使用して要素を分散できます。

<custom-menu>のマークアップを次に示します。

<custom-menu>
  <span slot="title">Candy menu</span>
  <li slot="item">Lollipop</li>
  <li slot="item">Fruit Toast</li>
  <li slot="item">Cup Cake</li>
</custom-menu>

適切なスロットを含むShadow DOMテンプレート

<template id="tmpl">
  <style> /* menu styles */ </style>
  <div class="menu">
    <slot name="title"></slot>
    <ul><slot name="item"></slot></ul>
  </div>
</template>
  1. <span slot="title"><slot name="title">に入ります。
  2. <custom-menu>には多くの<li slot="item">がありますが、テンプレートには<slot name="item">が1つだけです。そのため、そのような<li slot="item">はすべて<slot name="item">に1つずつ追加され、リストが形成されます。

フラット化されたDOMは次のようになります。

<custom-menu>
  #shadow-root
    <style> /* menu styles */ </style>
    <div class="menu">
      <slot name="title">
        <span slot="title">Candy menu</span>
      </slot>
      <ul>
        <slot name="item">
          <li slot="item">Lollipop</li>
          <li slot="item">Fruit Toast</li>
          <li slot="item">Cup Cake</li>
        </slot>
      </ul>
    </div>
</custom-menu>

有効なDOMでは、<li><ul>の直接の子である必要がありますが、これはフラット化されたDOMであり、コンポーネントのレンダリング方法を示しており、このようなことが自然に発生します。

リストを開閉するクリックハンドラを追加するだけで、<custom-menu>の準備が整います。

customElements.define('custom-menu', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});

    // tmpl is the shadow DOM template (above)
    this.shadowRoot.append( tmpl.content.cloneNode(true) );

    // we can't select light DOM nodes, so let's handle clicks on the slot
    this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
      // open/close the menu
      this.shadowRoot.querySelector('.menu').classList.toggle('closed');
    };
  }
});

完全なデモを次に示します。

もちろん、イベント、メソッドなど、さらに多くの機能を追加できます。

スロットの更新

外部コードがメニューアイテムを動的に追加/削除したい場合はどうすればよいですか?

ブラウザはスロットを監視し、スロットされた要素が追加/削除された場合にレンダリングを更新します。

また、ライトDOMノードはコピーされずにスロットにレンダリングされるため、その内部の変更はすぐに表示されます。

そのため、レンダリングを更新するために何もする必要はありません。しかし、コンポーネントコードがスロットの変更を知りたい場合は、slotchangeイベントを使用できます。

たとえば、ここではメニューアイテムが1秒後に動的に挿入され、タイトルが2秒後に変更されます。

<custom-menu id="menu">
  <span slot="title">Candy menu</span>
</custom-menu>

<script>
customElements.define('custom-menu', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div class="menu">
      <slot name="title"></slot>
      <ul><slot name="item"></slot></ul>
    </div>`;

    // shadowRoot can't have event handlers, so using the first child
    this.shadowRoot.firstElementChild.addEventListener('slotchange',
      e => alert("slotchange: " + e.target.name)
    );
  }
});

setTimeout(() => {
  menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Lollipop</li>')
}, 1000);

setTimeout(() => {
  menu.querySelector('[slot="title"]').innerHTML = "New menu";
}, 2000);
</script>

メニューのレンダリングは、私たちの介入なしに毎回更新されます。

ここに2つのslotchangeイベントがあります。

  1. 初期化時

    slotchange: titleは、ライトDOMのslot="title"が対応するスロットに入るため、すぐにトリガーされます。

  2. 1秒後

    新しい<li slot="item">が追加されると、slotchange: itemがトリガーされます。

注:slot="title"の内容が変更された後、2秒後にslotchangeイベントは発生しません。これは、スロットの変更がないためです。スロットされた要素内のコンテンツを変更していますが、それは別の問題です。

JavaScriptからライトDOMの内部変更を追跡したい場合、より一般的なメカニズムであるMutationObserverを使用することも可能です。

スロットAPI

最後に、スロット関連のJavaScriptメソッドについて説明します。

これまで見てきたように、JavaScriptはフラット化されていない「実際の」DOMを見ます。しかし、シャドウツリーが{mode: 'open'}の場合、どの要素がスロットに割り当てられているか、逆にスロット内の要素によってスロットを特定することができます。

  • node.assignedSlotnodeが割り当てられている<slot>要素を返します。
  • slot.assignedNodes({flatten: true/false}) – スロットに割り当てられたDOMノード。flattenオプションはデフォルトでfalseです。明示的にtrueに設定すると、フラット化されたDOMをより深く調べ、ネストされたコンポーネントの場合にネストされたスロットを返し、ノードが割り当てられていない場合はフォールバックコンテンツを返します。
  • slot.assignedElements({flatten: true/false}) – スロットに割り当てられたDOM要素(上記と同じですが、要素ノードのみ)。

これらのメソッドは、スロットされたコンテンツを表示するだけでなく、JavaScriptで追跡する必要がある場合に役立ちます。

たとえば、<custom-menu>コンポーネントが表示内容を知りたい場合、slotchangeを追跡し、slot.assignedElementsからアイテムを取得できます。

<custom-menu id="menu">
  <span slot="title">Candy menu</span>
  <li slot="item">Lollipop</li>
  <li slot="item">Fruit Toast</li>
</custom-menu>

<script>
customElements.define('custom-menu', class extends HTMLElement {
  items = []

  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div class="menu">
      <slot name="title"></slot>
      <ul><slot name="item"></slot></ul>
    </div>`;

    // triggers when slot content changes
    this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
      let slot = e.target;
      if (slot.name == 'item') {
        this.items = slot.assignedElements().map(elem => elem.textContent);
        alert("Items: " + this.items);
      }
    });
  }
});

// items update after 1 second
setTimeout(() => {
  menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>')
}, 1000);
</script>

まとめ

通常、要素にShadow DOMがある場合、そのライトDOMは表示されません。スロットを使用すると、ライトDOMの要素をShadow DOMの指定された場所に表示できます。

2種類のスロットがあります。

  • 名前付きスロット:<slot name="X">...</slot>slot="X"を持つライトの子を取得します。
  • デフォルトスロット:名前のない最初の<slot>(後続の名前のないスロットは無視されます)– スロットされていないライトの子を取得します。
  • 同じスロットに複数の要素がある場合、それらは1つずつ追加されます。
  • <slot>要素の内容はフォールバックとして使用されます。スロットに対応するライトの子がない場合に表示されます。

スロットされた要素をそのスロット内にレンダリングするプロセスは「合成」と呼ばれます。結果は「フラット化されたDOM」と呼ばれます。

合成はノードを実際には移動しません。JavaScriptの観点からは、DOMは同じままです。

JavaScriptはメソッドを使用してスロットにアクセスできます。

  • slot.assignedNodes/Elements()slot内のノード/要素を返します。
  • node.assignedSlot – 逆のプロパティで、ノードからスロットを返します。

表示内容を知りたい場合は、次のものを使用してスロットの内容を追跡できます。

  • slotchangeイベント – スロットが最初に埋められたときに、そしてスロットされた要素の追加/削除/置換操作(ただし、その子ではない)でトリガーされます。スロットはevent.targetです。
  • スロットの内容をさらに深く掘り下げ、その内部の変更を監視するにはMutationObserverを使用します。

ライトDOMの要素をShadow DOMに表示する方法が分かったので、次にそれらを適切にスタイル設定する方法を見てみましょう。基本的なルールは、Shadow要素は内部で、ライト要素は外部でスタイル設定されるということですが、注目すべき例外があります。

詳細は次の章で説明します。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点に関する提案がある場合は、コメントする代わりにGitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 少数のコード行を挿入するには<code>タグを使用し、複数の行には<pre>タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。