T-CREATOR

Web Components のスロット設計術:命名付き slot/fallback/`slotchange` の実戦パターン

Web Components のスロット設計術:命名付き slot/fallback/`slotchange` の実戦パターン

Web Components で再利用可能なコンポーネントを設計する際、スロット機構は欠かせない存在です。単に「穴を開けてコンテンツを挿入する」だけでなく、命名付き slot による柔軟な配置制御、fallback コンテンツによるデフォルト表示、そしてslotchangeイベントによる動的な制御まで、スロットには多彩な設計パターンが存在します。

本記事では、Web Components のスロット設計に焦点を当て、命名付き slot、fallback コンテンツ、slotchangeイベントの実戦的な活用パターンを解説します。初心者の方にもわかりやすく、段階的にコード例を示しながら説明していきますので、ぜひ最後までお読みください。

背景

Web Components は、HTML の標準技術としてカスタム要素を定義し、再利用可能なコンポーネントを構築できる仕組みです。その中核を担うのがShadow DOMスロット機構になります。

Web Components におけるスロットの役割

スロットは、コンポーネントの外部から提供されたコンテンツを、コンポーネント内部の特定の位置に配置するための仕組みです。これにより、コンポーネントの構造を保ちながら、利用者が自由にコンテンツをカスタマイズできるようになります。

以下の図は、Web Components におけるスロットの基本的な仕組みを示しています。

mermaidflowchart TB
    lightdom["Light DOM<br/>(利用者が提供)"]
    shadowdom["Shadow DOM<br/>(コンポーネント内部)"]
    slot["&lt;slot&gt; 要素"]
    rendered["レンダリング結果"]

    lightdom -->|投影| slot
    slot -.->|内包| shadowdom
    shadowdom -->|合成| rendered
    lightdom -.->|表示| rendered

上図のように、Light DOM(通常の DOM)のコンテンツが Shadow DOM 内の<slot>要素に投影され、最終的なレンダリング結果として表示されるのです。

スロット機構の 3 つの柱

Web Components のスロット設計には、主に 3 つの重要な要素があります。

#要素役割主な用途
1命名付き slot複数のスロットを名前で識別ヘッダー、フッター、サイドバーなど配置を明示
2fallback コンテンツスロットが空の場合のデフォルト表示プレースホルダーや初期状態の提供
3slotchangeイベントスロット内容の変化を検知動的なレイアウト調整や状態管理

これらを組み合わせることで、柔軟で堅牢なコンポーネント設計が可能になります。

課題

Web Components のスロットを実際に設計する際、いくつかの課題に直面することがあります。

複数コンテンツの配置制御

単一のスロットだけでは、複数の異なる種類のコンテンツを適切な位置に配置することができません。たとえば、カード型コンポーネントでヘッダー、本文、フッターを別々に制御したい場合、どのように実装すればよいでしょうか。

デフォルト表示の提供

利用者がコンテンツを提供しなかった場合、空白のまま表示されてしまうと見栄えが悪くなります。適切なデフォルト表示を提供する方法が必要です。

動的なコンテンツ変化への対応

SPA やインタラクティブなアプリケーションでは、スロットに挿入されるコンテンツが動的に変化することがあります。その変化を検知して、レイアウトや状態を調整する仕組みが求められるのです。

以下の図は、スロット設計における典型的な課題を示しています。

mermaidflowchart TD
    start["スロット設計開始"]
    q1{"複数の配置<br/>エリアが必要?"}
    q2{"デフォルト<br/>表示が必要?"}
    q3{"動的な変化<br/>を検知?"}

    problem1["課題1:配置制御"]
    problem2["課題2:空状態対応"]
    problem3["課題3:変化検知"]

    start --> q1
    q1 -->|はい| problem1
    q1 -->|いいえ| q2
    q2 -->|はい| problem2
    q2 -->|いいえ| q3
    q3 -->|はい| problem3

    problem1 -.-> solution["解決策が必要"]
    problem2 -.-> solution
    problem3 -.-> solution

これらの課題を解決するためには、スロットの高度な機能を理解し、適切に活用する必要があります。

解決策

Web Components のスロット機構が提供する 3 つの主要機能を活用することで、前述の課題を解決できます。

命名付き slot による配置制御

命名付き slot(named slots)を使用すると、複数のスロットを名前で識別し、コンテンツを正確な位置に配置できます。

仕組み

  • Shadow DOM 内の<slot>要素にname属性を指定
  • Light DOM 側のコンテンツにslot属性で対応する名前を指定
  • 名前が一致するコンテンツが、対応するスロットに投影される

fallback コンテンツによるデフォルト表示

<slot>要素の内部にコンテンツを記述することで、スロットが空の場合に表示される fallback コンテンツを定義できます。

仕組み

  • <slot>タグの開始と終了の間にデフォルトコンテンツを記述
  • Light DOM からコンテンツが提供されない場合、fallback が表示される
  • コンテンツが提供されると、fallback は自動的に非表示になる

slotchangeイベントによる動的制御

slotchangeイベントをリスニングすることで、スロット内容の変化を検知し、適切な処理を実行できます。

仕組み

  • <slot>要素でslotchangeイベントが発火
  • イベントハンドラー内でassignedNodes()assignedElements()を使用
  • 投影されたノードの情報を取得し、動的に処理を実行

以下の図は、これら 3 つの解決策がどのように連携するかを示しています。

mermaidflowchart LR
    component["カスタムコンポーネント"]

    subgraph ShadowDOM["Shadow DOM"]
        namedSlot["命名付きslot<br/>(name属性)"]
        fallback["fallbackコンテンツ"]
        listener["slotchangeリスナー"]
    end

    subgraph LightDOM["Light DOM"]
        content["slot属性付き<br/>コンテンツ"]
    end

    content -->|投影| namedSlot
    namedSlot -.->|空の場合| fallback
    namedSlot -->|変化時| listener

    component -.-> ShadowDOM
    component -.-> LightDOM

これらの機能を組み合わせることで、柔軟で堅牢なスロット設計が実現できます。

具体例

ここからは、実際のコード例を通して、命名付き slot、fallback、slotchangeの実装パターンを段階的に見ていきましょう。

基本的な命名付き slot の実装

まずは、カード型コンポーネントで複数の命名付き slot を使用する基本的な例から始めます。

カスタム要素の定義

以下は、ヘッダー、本文、フッターの 3 つのスロットを持つカードコンポーネントの定義です。

typescript// カスタム要素クラスの定義
class CardComponent extends HTMLElement {
  constructor() {
    super();
    // Shadow DOMをアタッチ
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // コンポーネントがDOMに追加されたときに呼ばれる
    this.render();
  }

Shadow DOM のテンプレート作成

次に、Shadow DOM 内部の構造を定義します。ここで命名付き slot を配置していきます。

typescript  render() {
    // Shadow DOMの構造を定義
    this.shadowRoot.innerHTML = `
      <style>
        /* カードのスタイル定義 */
        .card {
          border: 1px solid #ddd;
          border-radius: 8px;
          overflow: hidden;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }

スロット領域のスタイリング

各スロットに対応する領域のスタイルを定義します。

typescript        /* ヘッダー領域 */
        .card-header {
          background: #f5f5f5;
          padding: 16px;
          border-bottom: 1px solid #ddd;
        }

        /* 本文領域 */
        .card-body {
          padding: 16px;
        }

        /* フッター領域 */
        .card-footer {
          background: #f5f5f5;
          padding: 12px 16px;
          border-top: 1px solid #ddd;
          font-size: 0.9em;
        }
      </style>

命名付き slot の配置

実際に HTML 構造内に命名付き slot を配置します。

typescript      <div class="card">
        <div class="card-header">
          <!-- ヘッダー用の命名付きslot -->
          <slot name="header"></slot>
        </div>
        <div class="card-body">
          <!-- 本文用の命名付きslot(デフォルトslot) -->
          <slot></slot>
        </div>
        <div class="card-footer">
          <!-- フッター用の命名付きslot -->
          <slot name="footer"></slot>
        </div>
      </div>
    `;
  }
}

カスタム要素の登録

定義したクラスをカスタム要素として登録します。

typescript// カスタム要素として登録
customElements.define('card-component', CardComponent);

HTML での使用例

実際に HTML 内でカスタム要素を使用する例です。slot属性で対応する命名付き slot を指定します。

html<!-- カード型コンポーネントの使用 -->
<card-component>
  <!-- slot="header"でヘッダースロットに投影 -->
  <h2 slot="header">商品情報</h2>

  <!-- slot属性なしはデフォルトslotに投影 -->
  <p>この商品は高品質な素材を使用しています。</p>
  <p>価格:5,980円</p>

  <!-- slot="footer"でフッタースロットに投影 -->
  <button slot="footer">カートに追加</button>
</card-component>

上記のコードにより、ヘッダー、本文、フッターがそれぞれ適切な位置に配置されます。

fallback コンテンツの実装

次に、スロットが空の場合に表示される fallback コンテンツを追加します。

fallback 付きテンプレートの定義

以下は、各スロットに fallback コンテンツを定義した例です。

typescript  render() {
    this.shadowRoot.innerHTML = `
      <style>
        /* 前述のスタイルは省略 */
        .placeholder {
          color: #999;
          font-style: italic;
        }
      </style>
      <div class="card">
        <div class="card-header">
          <slot name="header">
            <!-- ヘッダーのfallback -->
            <span class="placeholder">タイトルなし</span>
          </slot>
        </div>

本文とフッターの fallback

本文とフッター部分にも fallback コンテンツを設定します。

typescript        <div class="card-body">
          <slot>
            <!-- 本文のfallback -->
            <p class="placeholder">コンテンツが提供されていません。</p>
          </slot>
        </div>
        <div class="card-footer">
          <slot name="footer">
            <!-- フッターのfallback -->
            <span class="placeholder">フッター情報なし</span>
          </slot>
        </div>
      </div>
    `;
  }

fallback の動作確認

コンテンツを提供しない場合、fallback が表示されます。

html<!-- コンテンツを一部だけ提供 -->
<card-component>
  <h2 slot="header">お知らせ</h2>
  <!-- 本文とフッターは提供しない → fallbackが表示される -->
</card-component>

この場合、ヘッダーには「お知らせ」が表示され、本文には「コンテンツが提供されていません。」、フッターには「フッター情報なし」という fallback が表示されます。

slotchangeイベントの実装

最後に、スロット内容の変化を検知して動的に処理を行う実装を見ていきましょう。

イベントリスナーの設定

connectedCallback内で、各スロットにslotchangeイベントリスナーを設定します。

typescript  connectedCallback() {
    this.render();

    // Shadow DOM内のすべてのslot要素を取得
    const slots = this.shadowRoot.querySelectorAll('slot');

    // 各slotにslotchangeイベントリスナーを追加
    slots.forEach(slot => {
      slot.addEventListener('slotchange', this.handleSlotChange.bind(this));
    });
  }

スロット変化時の処理

slotchangeイベントが発火したときの処理を定義します。

typescript  handleSlotChange(event) {
    // イベントが発生したslot要素を取得
    const slot = event.target;

    // 投影されたノードを取得
    const assignedNodes = slot.assignedNodes({ flatten: true });

    // テキストノードを除外して要素のみ取得
    const assignedElements = slot.assignedElements();

    console.log(`Slot "${slot.name || 'default'}" changed`);
    console.log('Assigned nodes:', assignedNodes);
    console.log('Assigned elements:', assignedElements);
  }

動的なスタイル調整の実装

スロットの内容に応じて、動的にスタイルを調整する例です。

typescript  handleSlotChange(event) {
    const slot = event.target;
    const assignedElements = slot.assignedElements();

    // スロットが空の場合、親要素を非表示にする
    const parent = slot.parentElement;
    if (assignedElements.length === 0) {
      parent.style.display = 'none';
    } else {
      parent.style.display = '';
    }
  }

この実装により、コンテンツが提供されていないスロットの領域を自動的に非表示にできます。

カウンター機能の追加

スロットに投影された要素の数をカウントして表示する機能を追加します。

typescript  handleSlotChange(event) {
    const slot = event.target;
    const assignedElements = slot.assignedElements();

    // カウンター表示用の要素を取得または作成
    let counter = this.shadowRoot.querySelector('.item-counter');
    if (!counter && slot.name === '') {
      counter = document.createElement('div');
      counter.className = 'item-counter';
      this.shadowRoot.querySelector('.card-body').appendChild(counter);
    }

    // 要素数を表示
    if (counter) {
      counter.textContent = `アイテム数: ${assignedElements.length}`;
    }
  }

HTML での動的変化の例

JavaScript で動的にコンテンツを追加・削除すると、slotchangeイベントが発火します。

html<card-component id="dynamic-card">
  <h2 slot="header">動的コンテンツ</h2>
  <p>最初のアイテム</p>
</card-component>

<script>
  const card = document.getElementById('dynamic-card');

  // 2秒後に新しいアイテムを追加
  setTimeout(() => {
    const newItem = document.createElement('p');
    newItem.textContent = '追加されたアイテム';
    card.appendChild(newItem);
    // slotchangeイベントが発火する
  }, 2000);
</script>

実戦的な組み合わせパターン

ここまで学んだ 3 つの要素を組み合わせた、より実戦的なコンポーネントを実装します。

タブコンポーネントの実装

タブ切り替え機能を持つコンポーネントで、命名付き slot、fallback、slotchangeをすべて活用します。

typescriptclass TabsComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.activeTab = 0;
  }

  connectedCallback() {
    this.render();
    this.setupSlotListeners();
  }

タブのテンプレート定義

タブのヘッダーとコンテンツ用のスロットを定義します。

typescript  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .tabs-container {
          border: 1px solid #ddd;
          border-radius: 4px;
        }
        .tab-headers {
          display: flex;
          background: #f5f5f5;
          border-bottom: 1px solid #ddd;
        }
        .tab-content {
          padding: 16px;
        }
      </style>
      <div class="tabs-container">
        <div class="tab-headers">
          <slot name="tab-header">
            <span class="placeholder">タブがありません</span>
          </slot>
        </div>

タブコンテンツ領域の定義

各タブのコンテンツを表示する領域を定義します。

typescript        <div class="tab-content">
          <slot name="tab-content">
            <p class="placeholder">コンテンツがありません</p>
          </slot>
        </div>
      </div>
    `;
  }

スロット変化の監視とタブ更新

slotchangeを利用して、タブの追加・削除を検知し、UI を更新します。

typescript  setupSlotListeners() {
    const headerSlot = this.shadowRoot.querySelector('slot[name="tab-header"]');
    const contentSlot = this.shadowRoot.querySelector('slot[name="tab-content"]');

    // タブヘッダーの変化を監視
    headerSlot.addEventListener('slotchange', () => {
      const headers = headerSlot.assignedElements();
      console.log(`タブヘッダー数: ${headers.length}`);
      this.updateTabState(headers);
    });

    // コンテンツの変化を監視
    contentSlot.addEventListener('slotchange', () => {
      const contents = contentSlot.assignedElements();
      console.log(`タブコンテンツ数: ${contents.length}`);
    });
  }

タブ状態の更新処理

タブの数に応じて、適切な状態管理を行います。

typescript  updateTabState(headers) {
    if (headers.length === 0) {
      // タブがない場合の処理
      console.log('タブが削除されました');
    } else if (this.activeTab >= headers.length) {
      // アクティブなタブが範囲外の場合、最初のタブに戻す
      this.activeTab = 0;
    }
  }
}

// カスタム要素として登録
customElements.define('tabs-component', TabsComponent);

以下の図は、実装したタブコンポーネントの動作フローを示しています。

mermaidsequenceDiagram
    participant User as 利用者
    participant LightDOM as Light DOM
    participant Slot as スロット
    participant Handler as slotchangeハンドラー
    participant UI as UI更新

    User->>LightDOM: タブ要素を追加
    LightDOM->>Slot: コンテンツ投影
    Slot->>Handler: slotchangeイベント発火
    Handler->>Handler: assignedElements()で要素取得
    Handler->>Handler: タブ数をカウント
    Handler->>UI: タブヘッダー表示更新
    UI->>User: 更新されたUIを表示

この実装パターンにより、タブの動的な追加・削除に対応した堅牢なコンポーネントが実現できます。

エラー処理とデバッグ

スロット実装時に遭遇しやすいエラーと、その対処法を紹介します。

よくあるエラー 1:slotchange が発火しない

エラーコード: なし(イベントが発火しない動作不具合)

エラーメッセージ:

textslotchangeイベントハンドラーが呼ばれない
コンソールにログが出力されない

発生条件:

  • Shadow DOM がアタッチされる前にイベントリスナーを設定
  • mode: 'closed'で Shadow DOM を作成し、外部から参照できない

解決方法:

  1. connectedCallback内でイベントリスナーを設定する
  2. Shadow DOM はmode: 'open'で作成する
  3. this.shadowRootが存在することを確認してから処理を実行
typescriptconnectedCallback() {
  // Shadow DOMの存在確認
  if (!this.shadowRoot) {
    console.error('Shadow DOMが存在しません');
    return;
  }

  this.render();
  this.setupSlotListeners();
}

よくあるエラー 2:assignedNodes が空配列を返す

エラーコード: なし(期待しない動作)

エラーメッセージ:

textassignedNodes()またはassignedElements()が空配列[]を返す
スロットにコンテンツが投影されていないように見える

発生条件:

  • Light DOM 側でslot属性の名前が一致していない
  • スロット名のスペルミス

解決方法:

  1. Shadow DOM 内の<slot name="xxx">と Light DOM 側のslot="xxx"が完全一致するか確認
  2. デフォルトスロット(名前なし)の場合、Light DOM 側もslot属性を付けない
  3. ブラウザの開発者ツールで実際の DOM 構造を確認
typescripthandleSlotChange(event) {
  const slot = event.target;
  const assignedElements = slot.assignedElements();

  // デバッグ用ログ出力
  console.log('Slot name:', slot.name || 'default');
  console.log('Assigned elements count:', assignedElements.length);

  if (assignedElements.length === 0) {
    console.warn('このスロットには要素が投影されていません');
  }
}

まとめ

Web Components のスロット設計において、命名付き slot、fallback コンテンツ、slotchangeイベントの 3 つの要素を適切に活用することで、柔軟で堅牢なコンポーネントを構築できます。

命名付き slotを使用することで、複数のコンテンツ領域を明示的に制御し、利用者にとってわかりやすい API を提供できます。ヘッダー、本文、フッターなど、役割が明確な領域には必ず名前を付けましょう。

fallback コンテンツは、コンポーネントの初期状態やエラー時の表示を改善します。ユーザーがコンテンツを提供しなかった場合でも、適切なプレースホルダーが表示されることで、UX が大きく向上するのです。

slotchangeイベントにより、動的なコンテンツ変化に対応できるようになります。SPA やインタラクティブなアプリケーションでは、この機能が特に重要です。

これらの機能を組み合わせることで、React や Vue.js などのフレームワークに依存せず、ブラウザ標準技術だけで高度なコンポーネント設計が可能になります。ぜひ本記事のパターンを参考に、実践的な Web Components の開発に挑戦してみてください。

関連リンク