T-CREATOR

Web Components 全体像を一枚図で理解する:Shadow DOM/slots/ElementInternals の関係

Web Components 全体像を一枚図で理解する:Shadow DOM/slots/ElementInternals の関係

Web Components を学習する際、Shadow DOM、slots、ElementInternals という 3 つの核心技術を個別に理解することは重要ですが、それだけでは実際の開発で活用することは困難です。これらの技術は密接に連携し合い、統合的に動作することで真の価値を発揮します。

今回は、Web Components の全体像を一枚の図で理解し、3 つの技術がどのように関係し合って動作するのかを詳しく解説いたします。この統合的な理解により、より効率的で保守性の高いコンポーネント開発が可能になるでしょう。

背景

Web Components の 3 つの核心技術

モダンな Web 開発において、コンポーネント化は欠かせない設計手法となっています。Web Components は、ブラウザ標準でコンポーネント化を実現するための技術群であり、以下の 3 つの主要技術で構成されています。

以下の図は、Web Components の 3 つの核心技術とその役割を示しています。

mermaidflowchart TD
    WC[Web Components] --> SD[Shadow DOM]
    WC --> SL[slots]
    WC --> EI[ElementInternals]

    SD --> |カプセル化| Encap[スタイル・DOM隔離]
    SL --> |コンテンツ配信| Content[外部コンテンツ挿入]
    EI --> |状態管理| State[フォーム状態・アクセシビリティ]

    style WC fill:#e1f5fe
    style SD fill:#f3e5f5
    style SL fill:#e8f5e8
    style EI fill:#fff3e0

各技術が解決する課題は以下の通りです。

技術主な課題解決策
Shadow DOMスタイルの衝突、DOM 構造の露出カプセル化による隔離
slots固定的なコンテンツ構造柔軟なコンテンツ配信
ElementInternalsフォーム連携、アクセシビリティ標準的な状態管理

それぞれの技術が解決する課題

Shadow DOMは、コンポーネント内部のスタイルと DOM 構造を外部から隔離することで、グローバルなスタイルの衝突を防ぎます。これにより、コンポーネントの独立性が保たれ、再利用性が大幅に向上するのです。

slotsは、コンポーネントの使用者が任意のコンテンツを挿入できる仕組みを提供します。テンプレート化されたコンポーネントに動的なコンテンツを配置することで、柔軟性と再利用性を両立できます。

ElementInternalsは、カスタム要素がブラウザのフォームシステムやアクセシビリティ機能と適切に連携するための標準的な API を提供します。これにより、ネイティブ要素と同等の機能を持つカスタム要素の開発が可能になります。

課題

各技術の個別理解だけでは不十分

Web Components の各技術を個別に学習することは重要ですが、実際の開発現場では以下のような課題に直面することがあります。

  • 技術間の連携方法が不明確:どの技術をいつ、どのように組み合わせるべきかわからない
  • 設計指針の欠如:全体的な設計パターンが見えないため、一貫性のないコンポーネントになりがち
  • パフォーマンスへの影響:各技術の特性を理解せずに使用することで、不要な処理が発生する可能性

統合的な理解が必要な理由

現代の Web 開発では、ユーザー体験とメンテナンス性の両方を満たすコンポーネント設計が求められています。以下の理由から、統合的な理解が不可欠です。

開発効率の向上:3 つの技術の関係性を理解することで、設計段階から適切な技術選択ができ、後からの大幅な修正を避けられます。

コードの品質向上:各技術の役割分担を明確にすることで、責務の分離ができ、保守しやすいコードが書けるようになります。

チーム開発での一貫性:統合的な理解により、チームメンバー間で共通の設計指針を持つことができ、コードレビューや引き継ぎがスムーズになるでしょう。

解決策

Shadow DOM によるカプセル化

Shadow DOM は、コンポーネントの内部構造とスタイルを外部から隔離する基盤技術です。以下の図は、Shadow DOM のカプセル化メカニズムを示しています。

mermaidflowchart TB
    subgraph "Light DOM(通常のDOM)"
        LD[親要素]
        CE[カスタム要素]
    end

    subgraph "Shadow DOM"
        SR[Shadow Root]
        SI[Shadow内部要素]
        SS[Shadow内スタイル]
    end

    LD --> CE
    CE -.->|attachShadow| SR
    SR --> SI
    SR --> SS

    style SR fill:#f3e5f5
    style SI fill:#f3e5f5
    style SS fill:#f3e5f5

Shadow DOM の実装により、以下のメリットが得られます。

typescriptclass CustomButton extends HTMLElement {
  constructor() {
    super();
    // Shadow Rootを作成してカプセル化を実現
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        /* このスタイルは外部に影響しない */
        button {
          background: #007bff;
          color: white;
          border: none;
          padding: 8px 16px;
          border-radius: 4px;
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}

このコードでは、Shadow DOM を作成することで、内部の button スタイルが外部のスタイルと衝突することを防いでいます。

slots によるコンテンツ配信

slots は、Shadow DOM 内に Light DOM のコンテンツを配信する仕組みです。以下の図で、slots のコンテンツフローを確認できます。

mermaidsequenceDiagram
    participant User as 利用者
    participant LE as Light DOM要素
    participant SD as Shadow DOM
    participant Slot as slot要素

    User->>LE: コンテンツを挿入
    LE->>SD: Shadow DOM内でslot検索
    SD->>Slot: 対応するslotを特定
    Slot->>SD: コンテンツを配置
    SD->>User: 最終的な表示

named slot を使用した柔軟なコンテンツ配信の実装例:

typescriptclass CustomCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          border: 1px solid #ddd;
          border-radius: 8px;
          padding: 16px;
        }
        .header { font-weight: bold; margin-bottom: 8px; }
        .content { line-height: 1.5; }
      </style>
      <div class="card">
        <div class="header">
          <slot name="header">デフォルトヘッダー</slot>
        </div>
        <div class="content">
          <slot>デフォルトコンテンツ</slot>
        </div>
      </div>
    `;
  }
}

この実装により、使用者は以下のように柔軟にコンテンツを配置できます。

html<custom-card>
  <span slot="header">カスタムタイトル</span>
  <p>カスタムコンテンツがここに表示されます。</p>
</custom-card>

ElementInternals による内部状態管理

ElementInternals は、カスタム要素がブラウザの標準機能と連携するための API です。フォーム連携とアクセシビリティの両方をサポートします。

typescriptclass CustomInput extends HTMLElement {
  private internals: ElementInternals;
  private input: HTMLInputElement;

  static formAssociated = true; // フォーム連携を有効化

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.internals = this.attachInternals(); // ElementInternalsを取得
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        input {
          border: 1px solid #ccc;
          padding: 8px;
          border-radius: 4px;
        }
        input:invalid {
          border-color: #dc3545;
        }
      </style>
      <input type="text" />
    `;

    this.input = this.shadowRoot.querySelector('input');
    this.input.addEventListener('input', this.handleInput.bind(this));
  }

ElementInternals を使用したバリデーション処理:

typescript  private handleInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;

    // フォーム値の設定
    this.internals.setFormValue(value);

    // バリデーション
    if (value.length < 3) {
      this.internals.setValidity(
        { tooShort: true },
        '3文字以上入力してください',
        this.input
      );
    } else {
      this.internals.setValidity({});
    }

    // アクセシビリティ情報の更新
    this.internals.ariaLabel = `入力値: ${value}`;
  }
}

この実装により、カスタム要素がネイティブフォーム要素と同様にブラウザの標準機能と連携できるようになります。

3 技術の相互作用パターン

3 つの技術は以下のように連携して動作します。

mermaidflowchart LR
    subgraph "統合アーキテクチャ"
        SD[Shadow DOM] -->|隔離環境提供| SL[slots]
        SL -->|コンテンツ配信| EI[ElementInternals]
        EI -->|状態通知| SD

        SD -->|カプセル化| Benefit1[スタイル隔離]
        SL -->|柔軟性| Benefit2[再利用性向上]
        EI -->|標準連携| Benefit3[ユーザビリティ]
    end

    style SD fill:#f3e5f5
    style SL fill:#e8f5e8
    style EI fill:#fff3e0

この相互作用により、独立性、柔軟性、標準準拠という 3 つのメリットを同時に実現できます。

具体例

カスタムボタンコンポーネントの実装

実際のプロダクト開発で使用できる、本格的なカスタムボタンコンポーネントを実装してみましょう。このコンポーネントでは、3 つの技術がどのように連携するかを具体的に確認できます。

まず、基本的なクラス定義と Shadow DOM の設定を行います。

typescriptclass AdvancedButton extends HTMLElement {
  private internals: ElementInternals;
  private button: HTMLButtonElement;

  static formAssociated = true;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.internals = this.attachInternals();
  }

次に、Shadow DOM 内でのスタイルとテンプレート定義を実装します。

typescript  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }

        button {
          background: var(--button-bg, #007bff);
          color: var(--button-color, white);
          border: none;
          padding: 12px 24px;
          border-radius: 6px;
          font-size: 14px;
          cursor: pointer;
          transition: all 0.2s ease;
          display: flex;
          align-items: center;
          gap: 8px;
        }

        button:hover {
          background: var(--button-bg-hover, #0056b3);
          transform: translateY(-1px);
        }

        button:disabled {
          opacity: 0.6;
          cursor: not-allowed;
          transform: none;
        }

        .icon {
          font-size: 16px;
        }
      </style>

      <button type="button">
        <slot name="icon" class="icon"></slot>
        <slot>ボタン</slot>
      </button>
    `;

    this.button = this.shadowRoot.querySelector('button');
    this.setupEventListeners();
  }

イベントリスナーの設定と ElementInternals との連携を実装します。

typescript  private setupEventListeners() {
    this.button.addEventListener('click', this.handleClick.bind(this));

    // disabled属性の監視
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.attributeName === 'disabled') {
          this.updateDisabledState();
        }
      });
    });

    observer.observe(this, { attributes: true });
    this.updateDisabledState();
  }

  private handleClick(event: Event) {
    if (this.hasAttribute('disabled')) {
      event.preventDefault();
      event.stopPropagation();
      return;
    }

    // カスタムイベントの発火
    this.dispatchEvent(new CustomEvent('button-click', {
      detail: { timestamp: Date.now() },
      bubbles: true
    }));

    // ElementInternalsによる状態更新
    this.internals.ariaPressed = 'true';
    setTimeout(() => {
      this.internals.ariaPressed = 'false';
    }, 100);
  }

disabled 状態の管理とアクセシビリティ対応を実装します。

typescript  private updateDisabledState() {
    const isDisabled = this.hasAttribute('disabled');
    this.button.disabled = isDisabled;

    // ElementInternalsによるアクセシビリティ設定
    this.internals.ariaDisabled = isDisabled ? 'true' : 'false';

    if (isDisabled) {
      this.internals.setValidity({ customError: true }, 'ボタンは無効です');
    } else {
      this.internals.setValidity({});
    }
  }

  // 属性変更の監視
  static get observedAttributes() {
    return ['disabled', 'aria-label'];
  }

  attributeChangedCallback(name: string, oldValue: string, newValue: string) {
    if (name === 'disabled') {
      this.updateDisabledState();
    } else if (name === 'aria-label') {
      this.internals.ariaLabel = newValue;
    }
  }
}

customElements.define('advanced-button', AdvancedButton);

各技術がどう連携するかの実例

実装したボタンコンポーネントの使用例を以下に示します。

html<!DOCTYPE html>
<html>
  <head>
    <style>
      /* カスタムプロパティによるテーマ設定 */
      .primary {
        --button-bg: #28a745;
        --button-bg-hover: #218838;
      }

      .secondary {
        --button-bg: #6c757d;
        --button-bg-hover: #545b62;
      }
    </style>
  </head>
  <body>
    <!-- アイコン付きボタン -->
    <advanced-button
      class="primary"
      aria-label="保存ボタン"
    >
      <span slot="icon">💾</span>
      保存
    </advanced-button>

    <!-- シンプルなボタン -->
    <advanced-button class="secondary">
      キャンセル
    </advanced-button>

    <!-- 無効状態のボタン -->
    <advanced-button disabled>
      無効なボタン
    </advanced-button>

    <script>
      // イベントリスナーの設定
      document.addEventListener('button-click', (event) => {
        console.log(
          'ボタンがクリックされました:',
          event.detail
        );
      });
    </script>
  </body>
</html>

この実装例では、以下のように 3 つの技術が連携しています:

Shadow DOM:ボタンのスタイルと DOM 構造を外部から隔離し、CSS カスタムプロパティで外部からの制御を可能にしています。

slots:アイコンとテキストコンテンツを柔軟に配置でき、使用者のニーズに応じてカスタマイズできます。

ElementInternals:disabled 状態や aria 属性を適切に管理し、スクリーンリーダーなどの支援技術との連携を実現しています。

まとめ

統合的理解のメリット

Web Components の Shadow DOM、slots、ElementInternals という 3 つの技術を統合的に理解することで、以下のメリットが得られます。

開発効率の向上:各技術の役割と連携方法を理解することで、設計段階から適切な技術選択ができ、開発時間の短縮につながります。

コード品質の向上:責務の分離が明確になることで、保守しやすく拡張性の高いコンポーネントを作成できるようになります。

ユーザー体験の向上:アクセシビリティやパフォーマンスを考慮した実装が自然に行えるようになり、より良いユーザー体験を提供できます。

今回解説した全体像の理解を基に、ぜひ実際のプロジェクトで Web Components を活用してみてください。統合的なアプローチにより、モダンで持続可能な Web 開発が実現できるでしょう。

関連リンク