T-CREATOR

Web Components の 3 つの柱を実例で学ぶ - モダン Web 開発の新常識

Web Components の 3 つの柱を実例で学ぶ - モダン Web 開発の新常識

Web 開発の世界では、コンポーネント指向の開発が当たり前となりました。React、Vue、Angular といったフレームワークが普及し、再利用可能なコンポーネントを作ることで、効率的で保守性の高いアプリケーション開発が可能になっています。

しかし、これらのフレームワークには一つの大きな課題があります。それは「フレームワーク固有の技術」であることです。Reactで作ったコンポーネントはVueでは使えませんし、その逆も然りです。

そこで注目されているのが Web Components です。Web Components は、ブラウザがネイティブでサポートする標準技術であり、フレームワークに依存しない真の再利用可能コンポーネントを実現できます。本記事では、Web Components を支える3つの柱「Custom Elements」「Shadow DOM」「HTML Templates」について、実例を交えて詳しく解説いたします。

背景

なぜ Web Components が必要となったのか

Web 開発におけるコンポーネント化の歴史を振り返ると、jQueryプラグインの時代から始まり、BackboneJS、AngularJS、そして現在のReact、Vue、Angularへと進化してきました。それぞれの時代で、より良いコンポーネント化の手法が模索されてきたのです。

しかし、現在でも以下のような課題が残っています。

mermaidflowchart TD
    A[Web開発の課題] --> B[フレームワーク依存]
    A --> C[技術の分断]
    A --> D[学習コストの増大]
    
    B --> E[React専用コンポーネント]
    B --> F[Vue専用コンポーネント]
    B --> G[Angular専用コンポーネント]
    
    C --> H[チーム間での技術格差]
    C --> I[プロジェクト間での再利用困難]
    
    D --> J[新しいフレームワークの学習]
    D --> K[既存資産の移行コスト]

コンポーネント化の歴史とフレームワークの限界

従来のフレームワークベースのコンポーネント開発では、以下のような限界がありました。

課題内容影響
技術ロックイン特定フレームワークに依存他技術への移行困難
学習コストフレームワーク固有の記法習得開発者の負担増加
資産の分散プロジェクト間での再利用不可開発効率の低下

ブラウザネイティブな解決策としての位置づけ

Web Components は、これらの課題を根本的に解決する技術として位置づけられています。W3C(World Wide Web Consortium)によって標準化されており、主要なブラウザで既にサポートされているのです。

以下の図は、Web Components がどのようにフレームワークの壁を取り払うかを示しています。

mermaidflowchart LR
    subgraph traditional["従来の開発"]
        react[React Component]
        vue[Vue Component]
        angular[Angular Component]
    end
    
    subgraph webcomponents["Web Components"]
        wc[Web Component]
    end
    
    subgraph applications["アプリケーション"]
        app1[React App]
        app2[Vue App]
        app3[Angular App]
        app4[Vanilla JS App]
    end
    
    react -.->|使用不可| app2
    react -.->|使用不可| app3
    vue -.->|使用不可| app1
    vue -.->|使用不可| app3
    angular -.->|使用不可| app1
    angular -.->|使用不可| app2
    
    wc --> app1
    wc --> app2
    wc --> app3
    wc --> app4

この図からわかるように、Web Components は真の「書くのは一度、どこでも使える」コンポーネントを実現します。

課題

従来のコンポーネント開発で直面する問題

現在のフレームワークベースの開発では、以下のような問題に直面することが多々あります。

まず、技術選択の制約です。一度特定のフレームワークを選択すると、そのエコシステム内でしか開発を進められません。例えば、Reactで作成したコンポーネントライブラリは、Vueのプロジェクトでは一切使用できないのです。

javascript// React コンポーネント(Vue では使用不可)
function MyButton({ onClick, children }) {
  return (
    <button onClick={onClick} className="my-button">
      {children}
    </button>
  );
}
javascript// Vue コンポーネント(React では使用不可)
export default {
  template: `
    <button @click="handleClick" class="my-button">
      <slot></slot>
    </button>
  `,
  methods: {
    handleClick() {
      this.$emit('click');
    }
  }
};

フレームワーク依存の課題

フレームワーク依存による具体的な課題を整理すると、以下のようになります。

開発チーム間の技術格差

異なるフレームワークを使用するチーム間では、コンポーネントの共有が困難です。デザインシステムを統一したくても、技術的な壁が立ちはだかります。

長期保守性の不安

フレームワークのライフサイクルに依存するため、フレームワークが廃れた場合、コンポーネント資産も同時に価値を失ってしまいます。

学習コストの増大

新しいプロジェクトが異なるフレームワークを採用した場合、開発者は一から学習し直す必要があります。

スタイルの分離とカプセル化の困難さ

従来の開発では、CSSのカプセル化が大きな課題でした。グローバルなCSSスコープにより、意図しないスタイルの競合が発生することが頻繁にありました。

css/* グローバルCSS - 意図しない影響を与える可能性 */
.button {
  background-color: blue;
  color: white;
  border: none;
  padding: 10px 20px;
}

/* 別のコンポーネントで同名クラスを使用 */
.button {
  background-color: red; /* 上記のスタイルを上書きしてしまう */
}

CSS-in-JSやCSS Modulesなどの解決策はありますが、これらもフレームワーク固有の実装に依存していました。

解決策

Web Components は、これらの課題を3つの核となる技術で解決します。これらを「Web Components の3つの柱」と呼びます。

Custom Elements:独自のHTML要素定義

Custom Elements は、独自のHTMLタグを定義できる仕組みです。<div><button> と同じように、<my-button><user-card> といった独自の要素を作成できます。

基本概念と仕組み

Custom Elements の仕組みを理解するために、簡単な例から始めましょう。

javascript// Custom Element の基本定義
class MyButton extends HTMLElement {
  constructor() {
    super(); // HTMLElement のコンストラクタを呼び出し
    console.log('MyButton が作成されました');
  }
  
  // 要素がDOMに挿入されたときに呼ばれる
  connectedCallback() {
    this.innerHTML = `
      <button style="
        background-color: #007bff;
        color: white;
        border: none;
        padding: 10px 20px;
        border-radius: 4px;
        cursor: pointer;
      ">
        ${this.textContent || 'クリック'}
      </button>
    `;
  }
}

// カスタム要素を登録
customElements.define('my-button', MyButton);

このコードを実行すると、以下のようにHTMLで使用できます。

html<!-- 通常のHTMLタグと同じように使用可能 -->
<my-button>送信</my-button>
<my-button>キャンセル</my-button>
<my-button></my-button> <!-- デフォルトで「クリック」と表示 -->

ライフサイクルメソッド

Custom Elements には、要素の状態変化に応じて自動的に呼び出されるライフサイクルメソッドがあります。

javascriptclass AdvancedButton extends HTMLElement {
  constructor() {
    super();
    this.clickCount = 0;
  }
  
  // 要素がDOMに追加されたとき
  connectedCallback() {
    console.log('要素がDOMに追加されました');
    this.render();
    this.addEventListener('click', this.handleClick.bind(this));
  }
  
  // 要素がDOMから削除されたとき
  disconnectedCallback() {
    console.log('要素がDOMから削除されました');
    this.removeEventListener('click', this.handleClick);
  }
  
  // 監視対象の属性が変更されたとき
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name}${oldValue} から ${newValue} に変更されました`);
    this.render();
  }
  
  // 監視する属性を指定
  static get observedAttributes() {
    return ['label', 'variant'];
  }
  
  handleClick() {
    this.clickCount++;
    this.render();
  }
  
  render() {
    const label = this.getAttribute('label') || 'ボタン';
    const variant = this.getAttribute('variant') || 'primary';
    
    this.innerHTML = `
      <button class="${variant}">
        ${label} (${this.clickCount}回クリック)
      </button>
    `;
  }
}

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

実装方法とベストプラクティス

Custom Elements を実装する際のベストプラクティスをご紹介します。

属性とプロパティの適切な使い分け

javascriptclass UserCard extends HTMLElement {
  constructor() {
    super();
    this._userData = null;
  }
  
  // 属性(HTML で設定可能)
  get name() {
    return this.getAttribute('name');
  }
  
  set name(value) {
    if (value) {
      this.setAttribute('name', value);
    } else {
      this.removeAttribute('name');
    }
  }
  
  // プロパティ(JavaScript でのみ設定可能)
  get userData() {
    return this._userData;
  }
  
  set userData(value) {
    this._userData = value;
    this.render();
  }
  
  static get observedAttributes() {
    return ['name', 'avatar'];
  }
  
  attributeChangedCallback() {
    this.render();
  }
  
  render() {
    const name = this.name || '名前未設定';
    const avatar = this.getAttribute('avatar') || '/default-avatar.png';
    
    this.innerHTML = `
      <div class="user-card">
        <img src="${avatar}" alt="${name}のアバター">
        <h3>${name}</h3>
        ${this._userData ? `<p>${this._userData.email}</p>` : ''}
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);

使用例:

html<!-- 属性で基本情報を設定 -->
<user-card name="田中太郎" avatar="/tanaka.jpg"></user-card>

<script>
// プロパティで詳細情報を設定
const userCard = document.querySelector('user-card');
userCard.userData = {
  email: 'tanaka@example.com',
  role: 'developer'
};
</script>

Shadow DOM:スタイルとDOMの分離

Shadow DOM は、Web Components において最も革新的な機能の一つです。これにより、完全にカプセル化されたコンポーネントを作成できます。

カプセル化の概念

Shadow DOM を使用すると、コンポーネント内部のDOM構造とスタイルが、外部から完全に分離されます。これは、まさに「影」のように隠された DOM 空間を作ることを意味します。

mermaidflowchart TD
    subgraph document["Document DOM"]
        html[html]
        body[body]
        div[div]
        custom[my-component]
    end
    
    subgraph shadow["Shadow DOM"]
        shadowRoot[Shadow Root]
        shadowDiv[div.container]
        shadowButton[button]
        shadowStyle[style]
    end
    
    custom -.->|Shadow Host| shadowRoot
    shadowRoot --> shadowDiv
    shadowRoot --> shadowButton
    shadowRoot --> shadowStyle
    
    style shadow fill:#f9f9f9,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5

Shadowツリーの構造

Shadow DOM を実装してみましょう。

javascriptclass IsolatedButton extends HTMLElement {
  constructor() {
    super();
    
    // Shadow DOM を作成(closed: 外部からアクセス不可)
    this.attachShadow({ mode: 'closed' });
    
    // Shadow DOM 内にスタイルとHTMLを追加
    this.shadowRoot.innerHTML = `
      <style>
        /* このスタイルは外部に影響せず、外部からも影響されない */
        button {
          background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
          color: white;
          border: none;
          padding: 12px 24px;
          border-radius: 8px;
          font-size: 16px;
          cursor: pointer;
          transition: transform 0.2s;
        }
        
        button:hover {
          transform: translateY(-2px);
        }
        
        .icon {
          margin-right: 8px;
        }
      </style>
      
      <button>
        <span class="icon">🚀</span>
        <slot></slot> <!-- 外部コンテンツの挿入ポイント -->
      </button>
    `;
  }
}

customElements.define('isolated-button', IsolatedButton);

CSS分離の実現方法

Shadow DOM の威力を実感するために、スタイルの分離を確認してみましょう。

html<!DOCTYPE html>
<html>
<head>
  <style>
    /* グローバルスタイル */
    button {
      background-color: red !important;
      color: black !important;
      border: 5px solid black !important;
    }
  </style>
</head>
<body>
  <!-- 通常のボタン(グローバルスタイルの影響を受ける) -->
  <button>通常のボタン</button>
  
  <!-- Shadow DOM を使用したボタン(グローバルスタイルの影響を受けない) -->
  <isolated-button>分離されたボタン</isolated-button>
</body>
</html>

この例では、!important を使用した強力なグローバルスタイルがあっても、Shadow DOM 内のボタンには一切影響しません。

CSS カスタムプロパティによるテーマ設定

Shadow DOM を使用しながらも、外部からある程度のカスタマイズを可能にする方法があります。

javascriptclass ThemeableButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    this.shadowRoot.innerHTML = `
      <style>
        button {
          /* CSS カスタムプロパティで外部からの設定を受け取る */
          background-color: var(--button-bg, #007bff);
          color: var(--button-color, white);
          border: var(--button-border, none);
          padding: var(--button-padding, 10px 20px);
          border-radius: var(--button-radius, 4px);
          font-size: var(--button-font-size, 14px);
          cursor: pointer;
          transition: all 0.3s ease;
        }
        
        button:hover {
          filter: brightness(110%);
        }
      </style>
      
      <button><slot></slot></button>
    `;
  }
}

customElements.define('themeable-button', ThemeableButton);

使用例:

html<style>
  .dark-theme {
    --button-bg: #2d3748;
    --button-color: #e2e8f0;
    --button-border: 1px solid #4a5568;
  }
  
  .large-button {
    --button-padding: 16px 32px;
    --button-font-size: 18px;
    --button-radius: 8px;
  }
</style>

<themeable-button class="dark-theme">ダークテーマ</themeable-button>
<themeable-button class="large-button">大きなボタン</themeable-button>

HTML Templates:再利用可能なマークアップ

HTML Templates は、再利用可能なマークアップの塊を定義する仕組みです。テンプレートは解析されるものの、実際にページに表示されることはなく、必要なときにクローンして使用します。

templateタグとslotタグ

まず、基本的な template タグの使用方法を見てみましょう。

html<!-- HTML テンプレートの定義 -->
<template id="product-card-template">
  <style>
    .product-card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 16px;
      margin: 8px;
      max-width: 300px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
    
    .product-image {
      width: 100%;
      height: 200px;
      object-fit: cover;
      border-radius: 4px;
    }
    
    .product-name {
      font-size: 18px;
      font-weight: bold;
      margin: 12px 0 8px 0;
    }
    
    .product-price {
      color: #007bff;
      font-size: 20px;
      font-weight: bold;
    }
  </style>
  
  <div class="product-card">
    <img class="product-image" src="" alt="">
    <div class="product-name"></div>
    <div class="product-price"></div>
    <slot name="actions"></slot> <!-- 外部コンテンツの挿入ポイント -->
  </div>
</template>

このテンプレートを使用するCustom Elementを作成します。

javascriptclass ProductCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // テンプレートを取得してクローン
    const template = document.getElementById('product-card-template');
    const templateContent = template.content;
    
    this.shadowRoot.appendChild(templateContent.cloneNode(true));
  }
  
  connectedCallback() {
    this.updateContent();
  }
  
  static get observedAttributes() {
    return ['name', 'price', 'image'];
  }
  
  attributeChangedCallback() {
    this.updateContent();
  }
  
  updateContent() {
    const name = this.getAttribute('name') || '';
    const price = this.getAttribute('price') || '';
    const image = this.getAttribute('image') || '';
    
    // Shadow DOM 内の要素を更新
    const nameElement = this.shadowRoot.querySelector('.product-name');
    const priceElement = this.shadowRoot.querySelector('.product-price');
    const imageElement = this.shadowRoot.querySelector('.product-image');
    
    if (nameElement) nameElement.textContent = name;
    if (priceElement) priceElement.textContent = ${price}`;
    if (imageElement) {
      imageElement.src = image;
      imageElement.alt = name;
    }
  }
}

customElements.define('product-card', ProductCard);

動的コンテンツの挿入

Slot を使用することで、外部から動的にコンテンツを挿入できます。

html<product-card 
  name="JavaScript入門書" 
  price="2,980" 
  image="/book-cover.jpg">
  
  <!-- slot="actions" で指定された場所に挿入される -->
  <div slot="actions">
    <button onclick="addToCart()">カートに追加</button>
    <button onclick="addToWishlist()">ウィッシュリストに追加</button>
  </div>
</product-card>

名前付きスロットと複数コンテンツ

より複雑なレイアウトでは、複数の名前付きスロットを使用できます。

html<template id="article-template">
  <style>
    .article {
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
    }
    
    .article-header {
      border-bottom: 2px solid #eee;
      padding-bottom: 20px;
      margin-bottom: 20px;
    }
    
    .article-meta {
      color: #666;
      font-size: 14px;
      margin-top: 10px;
    }
    
    .article-content {
      line-height: 1.6;
      margin-bottom: 20px;
    }
    
    .article-footer {
      border-top: 1px solid #eee;
      padding-top: 20px;
      text-align: center;
    }
  </style>
  
  <article class="article">
    <header class="article-header">
      <slot name="title"></slot>
      <div class="article-meta">
        <slot name="meta"></slot>
      </div>
    </header>
    
    <div class="article-content">
      <slot></slot> <!-- デフォルトスロット -->
    </div>
    
    <footer class="article-footer">
      <slot name="footer"></slot>
    </footer>
  </article>
</template>

使用例:

html<blog-article>
  <h1 slot="title">Web Components入門</h1>
  <span slot="meta">2024年8月18日 | 著者: 山田太郎</span>
  
  <!-- デフォルトスロット(名前なし)に挿入される -->
  <p>Web Componentsは、モダンなWeb開発において...</p>
  <p>この技術により、フレームワークに依存しない...</p>
  
  <div slot="footer">
    <button>いいね</button>
    <button>シェア</button>
  </div>
</blog-article>

パフォーマンス最適化

HTML Templates を使用する際のパフォーマンス最適化のポイントをご紹介します。

テンプレートの再利用とクローニング

javascriptclass OptimizedCard extends HTMLElement {
  // クラスレベルでテンプレートをキャッシュ
  static template = null;
  
  static getTemplate() {
    if (!this.template) {
      this.template = document.createElement('template');
      this.template.innerHTML = `
        <style>
          /* スタイル定義 */
        </style>
        <div class="card">
          <slot name="header"></slot>
          <slot></slot>
          <slot name="footer"></slot>
        </div>
      `;
    }
    return this.template;
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // キャッシュされたテンプレートを使用
    const template = OptimizedCard.getTemplate();
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

customElements.define('optimized-card', OptimizedCard);

遅延ロードとバッチ処理

大量のコンポーネントを扱う場合は、Intersection Observer を使用した遅延ロードが効果的です。

javascriptclass LazyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // 最初はプレースホルダーのみ表示
    this.shadowRoot.innerHTML = `
      <style>
        .placeholder {
          height: 200px;
          background-color: #f0f0f0;
          display: flex;
          align-items: center;
          justify-content: center;
        }
      </style>
      <div class="placeholder">読み込み中...</div>
    `;
  }
  
  connectedCallback() {
    // Intersection Observer で可視領域に入ったら本格的にレンダリング
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.loadContent();
          observer.unobserve(this);
        }
      });
    });
    
    observer.observe(this);
  }
  
  loadContent() {
    // 実際のコンテンツをロード
    this.shadowRoot.innerHTML = `
      <style>
        .card { /* 実際のスタイル */ }
      </style>
      <div class="card">
        <!-- 実際のコンテンツ -->
      </div>
    `;
  }
}

customElements.define('lazy-card', LazyCard);

具体例

ここまでで Web Components の3つの柱について学びました。次に、これらの技術を組み合わせた実用的なコンポーネントを作成してみましょう。

実用的なボタンコンポーネントの作成

まず、実際のプロジェクトで使用できる高機能なボタンコンポーネントを作成します。

javascriptclass SmartButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // 内部状態の管理
    this._loading = false;
    this._disabled = false;
    
    this.render();
    this.attachEventListeners();
  }
  
  static get observedAttributes() {
    return ['variant', 'size', 'disabled', 'loading'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }
  
  // プロパティのゲッター・セッター
  get loading() {
    return this._loading;
  }
  
  set loading(value) {
    this._loading = Boolean(value);
    if (this._loading) {
      this.setAttribute('loading', '');
    } else {
      this.removeAttribute('loading');
    }
  }
  
  get disabled() {
    return this._disabled;
  }
  
  set disabled(value) {
    this._disabled = Boolean(value);
    if (this._disabled) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
  
  render() {
    const variant = this.getAttribute('variant') || 'primary';
    const size = this.getAttribute('size') || 'medium';
    const isLoading = this.hasAttribute('loading');
    const isDisabled = this.hasAttribute('disabled');
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
          --primary-color: #007bff;
          --secondary-color: #6c757d;
          --success-color: #28a745;
          --danger-color: #dc3545;
        }
        
        button {
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-family: inherit;
          font-weight: 500;
          transition: all 0.2s ease;
          position: relative;
          overflow: hidden;
        }
        
        button:disabled {
          opacity: 0.6;
          cursor: not-allowed;
        }
        
        /* サイズバリエーション */
        .small {
          padding: 6px 12px;
          font-size: 12px;
        }
        
        .medium {
          padding: 8px 16px;
          font-size: 14px;
        }
        
        .large {
          padding: 12px 24px;
          font-size: 16px;
        }
        
        /* カラーバリエーション */
        .primary {
          background-color: var(--primary-color);
          color: white;
        }
        
        .primary:hover:not(:disabled) {
          background-color: #0056b3;
        }
        
        .secondary {
          background-color: var(--secondary-color);
          color: white;
        }
        
        .secondary:hover:not(:disabled) {
          background-color: #545b62;
        }
        
        .success {
          background-color: var(--success-color);
          color: white;
        }
        
        .danger {
          background-color: var(--danger-color);
          color: white;
        }
        
        /* ローディングアニメーション */
        .loading-spinner {
          display: inline-block;
          width: 14px;
          height: 14px;
          border: 2px solid transparent;
          border-top: 2px solid currentColor;
          border-radius: 50%;
          animation: spin 1s linear infinite;
          margin-right: 8px;
        }
        
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
        
        .content {
          opacity: 1;
          transition: opacity 0.2s ease;
        }
        
        .content.loading {
          opacity: 0.7;
        }
      </style>
      
      <button 
        class="${variant} ${size}"
        ${isDisabled || isLoading ? 'disabled' : ''}
      >
        ${isLoading ? '<span class="loading-spinner"></span>' : ''}
        <span class="content ${isLoading ? 'loading' : ''}">
          <slot></slot>
        </span>
      </button>
    `;
  }
  
  attachEventListeners() {
    this.shadowRoot.addEventListener('click', (e) => {
      if (this._loading || this._disabled) {
        e.stopPropagation();
        return;
      }
      
      // カスタムイベントの発火
      this.dispatchEvent(new CustomEvent('smart-click', {
        bubbles: true,
        cancelable: true,
        detail: {
          variant: this.getAttribute('variant'),
          timestamp: Date.now()
        }
      }));
    });
  }
  
  // 外部から呼び出し可能なメソッド
  async simulateAsyncAction(duration = 2000) {
    this.loading = true;
    
    try {
      await new Promise(resolve => setTimeout(resolve, duration));
      // 成功時の処理
      this.dispatchEvent(new CustomEvent('action-complete', {
        bubbles: true,
        detail: { success: true }
      }));
    } catch (error) {
      // エラー時の処理
      this.dispatchEvent(new CustomEvent('action-error', {
        bubbles: true,
        detail: { error }
      }));
    } finally {
      this.loading = false;
    }
  }
}

customElements.define('smart-button', SmartButton);

使用例:

html<smart-button variant="primary" size="large">
  データを保存
</smart-button>

<smart-button variant="danger" size="small" disabled>
  削除
</smart-button>

<script>
document.addEventListener('smart-click', (e) => {
  console.log('ボタンがクリックされました:', e.detail);
  
  // 非同期処理のシミュレーション
  e.target.simulateAsyncAction(3000);
});

document.addEventListener('action-complete', (e) => {
  console.log('処理が完了しました');
});
</script>

カードコンポーネントの実装

次に、画像、テキスト、アクションボタンを含む汎用的なカードコンポーネントを作成します。

javascriptclass ContentCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.render();
  }
  
  static get observedAttributes() {
    return ['elevation', 'clickable'];
  }
  
  attributeChangedCallback() {
    this.render();
  }
  
  render() {
    const elevation = this.getAttribute('elevation') || '1';
    const clickable = this.hasAttribute('clickable');
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          --card-bg: white;
          --card-border: #e0e0e0;
          --card-text: #333;
          --card-secondary-text: #666;
        }
        
        .card {
          background-color: var(--card-bg);
          border-radius: 8px;
          overflow: hidden;
          transition: all 0.3s ease;
          position: relative;
        }
        
        /* エレベーション(影の深度) */
        .elevation-1 {
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        
        .elevation-2 {
          box-shadow: 0 4px 8px rgba(0,0,0,0.15);
        }
        
        .elevation-3 {
          box-shadow: 0 8px 16px rgba(0,0,0,0.2);
        }
        
        .clickable {
          cursor: pointer;
        }
        
        .clickable:hover {
          transform: translateY(-2px);
          box-shadow: 0 8px 25px rgba(0,0,0,0.15);
        }
        
        .card-media {
          width: 100%;
          height: 200px;
          object-fit: cover;
          display: block;
        }
        
        .card-content {
          padding: 16px;
        }
        
        .card-header {
          margin-bottom: 12px;
        }
        
        .card-title {
          margin: 0 0 8px 0;
          font-size: 18px;
          font-weight: 600;
          color: var(--card-text);
        }
        
        .card-subtitle {
          margin: 0;
          font-size: 14px;
          color: var(--card-secondary-text);
        }
        
        .card-body {
          margin-bottom: 16px;
          line-height: 1.5;
          color: var(--card-text);
        }
        
        .card-actions {
          padding: 8px 16px 16px;
          display: flex;
          gap: 8px;
          justify-content: flex-end;
        }
        
        /* スロットが空の場合は非表示 */
        .card-media:not([src]) {
          display: none;
        }
      </style>
      
      <div class="card elevation-${elevation} ${clickable ? 'clickable' : ''}">
        <slot name="media"></slot>
        
        <div class="card-content">
          <div class="card-header">
            <slot name="header"></slot>
          </div>
          
          <div class="card-body">
            <slot></slot>
          </div>
        </div>
        
        <div class="card-actions">
          <slot name="actions"></slot>
        </div>
      </div>
    `;
    
    this.attachEventListeners();
  }
  
  attachEventListeners() {
    if (this.hasAttribute('clickable')) {
      this.shadowRoot.querySelector('.card').addEventListener('click', () => {
        this.dispatchEvent(new CustomEvent('card-click', {
          bubbles: true,
          detail: {
            cardId: this.getAttribute('id'),
            timestamp: Date.now()
          }
        }));
      });
    }
  }
}

customElements.define('content-card', ContentCard);

使用例:

html<content-card elevation="2" clickable id="product-1">
  <img slot="media" src="/product-image.jpg" alt="商品画像" class="card-media">
  
  <div slot="header">
    <h3 class="card-title">プレミアムヘッドフォン</h3>
    <p class="card-subtitle">高音質・ノイズキャンセリング</p>
  </div>
  
  <!-- デフォルトスロット -->
  <p>音楽愛好家のために設計された、プロフェッショナルグレードのヘッドフォンです。</p>
  
  <div slot="actions">
    <smart-button variant="secondary" size="small">詳細</smart-button>
    <smart-button variant="primary" size="small">購入</smart-button>
  </div>
</content-card>

フォーム要素のカスタマイズ

最後に、バリデーション機能付きの入力フィールドコンポーネントを作成します。

javascriptclass SmartInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    this._value = '';
    this._valid = true;
    this._touched = false;
    
    this.render();
    this.attachEventListeners();
  }
  
  static get observedAttributes() {
    return ['type', 'placeholder', 'required', 'pattern', 'min-length', 'max-length'];
  }
  
  attributeChangedCallback() {
    this.render();
  }
  
  get value() {
    return this._value;
  }
  
  set value(val) {
    this._value = val;
    const input = this.shadowRoot.querySelector('input');
    if (input) {
      input.value = val;
    }
    this.validate();
  }
  
  get valid() {
    return this._valid;
  }
  
  render() {
    const type = this.getAttribute('type') || 'text';
    const placeholder = this.getAttribute('placeholder') || '';
    const required = this.hasAttribute('required');
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          margin-bottom: 16px;
          --input-border: #ddd;
          --input-border-focus: #007bff;
          --input-border-error: #dc3545;
          --input-bg: white;
          --input-text: #333;
        }
        
        .input-container {
          position: relative;
        }
        
        label {
          display: block;
          margin-bottom: 4px;
          font-weight: 500;
          color: var(--input-text);
        }
        
        .required::after {
          content: ' *';
          color: var(--input-border-error);
        }
        
        input {
          width: 100%;
          padding: 12px;
          border: 2px solid var(--input-border);
          border-radius: 4px;
          font-size: 14px;
          font-family: inherit;
          background-color: var(--input-bg);
          color: var(--input-text);
          transition: border-color 0.2s ease;
          box-sizing: border-box;
        }
        
        input:focus {
          outline: none;
          border-color: var(--input-border-focus);
        }
        
        input.error {
          border-color: var(--input-border-error);
        }
        
        .error-message {
          color: var(--input-border-error);
          font-size: 12px;
          margin-top: 4px;
          min-height: 16px;
        }
        
        .success-icon {
          position: absolute;
          right: 12px;
          top: 50%;
          transform: translateY(-50%);
          color: #28a745;
          font-size: 16px;
        }
      </style>
      
      <div class="input-container">
        <label class="${required ? 'required' : ''}">
          <slot name="label"></slot>
        </label>
        
        <input 
          type="${type}"
          placeholder="${placeholder}"
          ${required ? 'required' : ''}
        >
        
        <span class="success-icon" style="display: none;">✓</span>
        
        <div class="error-message"></div>
      </div>
    `;
  }
  
  attachEventListeners() {
    const input = this.shadowRoot.querySelector('input');
    
    input.addEventListener('input', (e) => {
      this._value = e.target.value;
      this.validate();
      
      this.dispatchEvent(new CustomEvent('input', {
        bubbles: true,
        detail: { value: this._value, valid: this._valid }
      }));
    });
    
    input.addEventListener('blur', () => {
      this._touched = true;
      this.validate();
    });
    
    input.addEventListener('focus', () => {
      this.clearError();
    });
  }
  
  validate() {
    const input = this.shadowRoot.querySelector('input');
    const errorElement = this.shadowRoot.querySelector('.error-message');
    const successIcon = this.shadowRoot.querySelector('.success-icon');
    
    let isValid = true;
    let errorMessage = '';
    
    // 必須チェック
    if (this.hasAttribute('required') && !this._value) {
      isValid = false;
      errorMessage = 'この項目は必須です';
    }
    
    // 最小文字数チェック
    const minLength = this.getAttribute('min-length');
    if (minLength && this._value.length < parseInt(minLength)) {
      isValid = false;
      errorMessage = `${minLength}文字以上で入力してください`;
    }
    
    // 最大文字数チェック
    const maxLength = this.getAttribute('max-length');
    if (maxLength && this._value.length > parseInt(maxLength)) {
      isValid = false;
      errorMessage = `${maxLength}文字以下で入力してください`;
    }
    
    // パターンチェック
    const pattern = this.getAttribute('pattern');
    if (pattern && this._value && !new RegExp(pattern).test(this._value)) {
      isValid = false;
      errorMessage = '入力形式が正しくありません';
    }
    
    this._valid = isValid;
    
    // UIの更新
    if (this._touched) {
      if (isValid && this._value) {
        input.classList.remove('error');
        errorElement.textContent = '';
        successIcon.style.display = 'block';
      } else if (!isValid) {
        input.classList.add('error');
        errorElement.textContent = errorMessage;
        successIcon.style.display = 'none';
      }
    }
    
    // バリデーション結果のイベント発火
    this.dispatchEvent(new CustomEvent('validation', {
      bubbles: true,
      detail: { valid: isValid, message: errorMessage }
    }));
  }
  
  clearError() {
    const input = this.shadowRoot.querySelector('input');
    const errorElement = this.shadowRoot.querySelector('.error-message');
    
    input.classList.remove('error');
    errorElement.textContent = '';
  }
  
  focus() {
    this.shadowRoot.querySelector('input').focus();
  }
}

customElements.define('smart-input', SmartInput);

使用例:

html<form id="userForm">
  <smart-input type="text" required min-length="2">
    <span slot="label">お名前</span>
  </smart-input>
  
  <smart-input 
    type="email" 
    required 
    pattern="[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}">
    <span slot="label">メールアドレス</span>
  </smart-input>
  
  <smart-input type="password" required min-length="8">
    <span slot="label">パスワード</span>
  </smart-input>
  
  <smart-button type="submit" variant="primary">
    アカウント作成
  </smart-button>
</form>

<script>
document.getElementById('userForm').addEventListener('submit', (e) => {
  e.preventDefault();
  
  const inputs = e.target.querySelectorAll('smart-input');
  let allValid = true;
  
  inputs.forEach(input => {
    if (!input.valid) {
      allValid = false;
    }
  });
  
  if (allValid) {
    console.log('フォームの送信を開始します');
  } else {
    console.log('入力エラーがあります');
  }
});
</script>

これらの具体例では、Web Components の3つの柱すべてを活用しています。Custom Elements でカスタムタグを定義し、Shadow DOM でスタイルとロジックをカプセル化し、HTML Templates とスロットで柔軟なコンテンツ挿入を実現しています。

まとめ

Web Components の3つの柱について詳しく解説してまいりました。この技術は、フレームワークに依存しない持続可能なWeb開発の新たな可能性を切り開いています。

Custom Elements により、HTMLの語彙を拡張し、意味のあるカスタムタグを作成できます。これにより、コードの可読性と保守性が大幅に向上いたします。

Shadow DOM は、真のカプセル化を実現し、スタイルの競合やグローバル汚染を完全に防げます。これまでCSS-in-JSやCSS Modulesで解決しようとしていた課題を、ブラウザネイティブな機能で解決できるのです。

HTML Templates と スロット機能により、再利用可能で柔軟なマークアップテンプレートを作成できます。パフォーマンスに優れ、動的なコンテンツ挿入も簡単に実現できます。

これら3つの技術が組み合わされることで、以下のメリットを享受できます。

メリット説明効果
フレームワーク非依存どの環境でも動作長期保守性の向上
標準準拠W3C標準技術将来性の保証
真のカプセル化完全な分離環境予期しない副作用の排除
パフォーマンスブラウザネイティブ高速な動作

Web Components は、まさにモダンWeb開発の新常識となりつつあります。React、Vue、Angularといったフレームワークと競合する技術ではなく、それらを補完し、より良いWeb体験を提供する基盤技術として位置づけられています。

今後のWeb開発では、フレームワーク選択の制約から解放され、真に再利用可能なコンポーネントライブラリの構築が可能になるでしょう。Web Components の習得は、未来のWeb開発者にとって必須のスキルとなることは間違いありません。

関連リンク